diff --git a/Android.mk b/Android.mk
index ef47728440d1221156b89bac607377d758e24039..285e59543148b59c8e4bbcee952f75538fb1a98e 100644
--- a/Android.mk
+++ b/Android.mk
@@ -30,9 +30,9 @@ LOCAL_PROGUARD_FLAG_FILES := proguard.flags
 # the libraries in the APK, otherwise just put them in /system/lib and
 # leave them out of the APK
 ifneq (,$(TARGET_BUILD_APPS))
-  LOCAL_JNI_SHARED_LIBRARIES := libjni_mosaic
+  LOCAL_JNI_SHARED_LIBRARIES := libjni_mosaic libjni_tinyplanet
 else
-  LOCAL_REQUIRED_MODULES := libjni_mosaic
+  LOCAL_REQUIRED_MODULES := libjni_mosaic libjni_tinyplanet
 endif
 
 include $(BUILD_PACKAGE)
diff --git a/jni/Android.mk b/jni/Android.mk
index 9f6f7392505ccc337f7c61b6571169280b2eadda..b9bafcf6c2e348f4986678a82bf0a21c88299a8d 100755
--- a/jni/Android.mk
+++ b/jni/Android.mk
@@ -58,3 +58,18 @@ LOCAL_MODULE_TAGS := optional
 
 LOCAL_MODULE    := libjni_mosaic
 include $(BUILD_SHARED_LIBRARY)
+
+# TinyPlanet
+include $(CLEAR_VARS)
+
+LOCAL_CPP_EXTENSION := .cc
+LOCAL_LDFLAGS   := -llog -ljnigraphics
+LOCAL_SDK_VERSION := 9
+LOCAL_MODULE    := libjni_tinyplanet
+LOCAL_SRC_FILES := tinyplanet.cc
+
+LOCAL_CFLAGS    += -ffast-math -O3 -funroll-loops
+LOCAL_ARM_MODE := arm
+
+include $(BUILD_SHARED_LIBRARY)
+
diff --git a/jni/tinyplanet.cc b/jni/tinyplanet.cc
new file mode 100644
index 0000000000000000000000000000000000000000..dfb31d7687e3ba27f1d8c3df1a20168fcc1d2591
--- /dev/null
+++ b/jni/tinyplanet.cc
@@ -0,0 +1,151 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (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.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include <jni.h>
+#include <math.h>
+#include <android/bitmap.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+
+#define PI_F 3.141592653589f
+
+class ImageRGBA {
+ public:
+  ImageRGBA(unsigned char* image, int width, int height)
+   : image_(image), width_(width), height_(height) {
+    width_step_ = width * 4;
+  }
+
+  int Width() const {
+    return width_;
+  }
+
+  int Height() const {
+    return height_;
+  }
+
+  // Pixel accessor.
+  unsigned char* operator()(int x, int y) {
+    return image_ + y * width_step_ + x * 4;
+  }
+  const unsigned char* operator()(int x, int y) const {
+    return image_ + y * width_step_ + x * 4;
+  }
+
+ private:
+  unsigned char* image_;
+  int width_;
+  int height_;
+  int width_step_;
+};
+
+// Interpolate a pixel in a 3 channel image.
+inline void InterpolatePixel(const ImageRGBA &image, float x, float y,
+                             unsigned char* dest) {
+  // Get pointers and scale factors for the source pixels.
+  float ax = x - floor(x);
+  float ay = y - floor(y);
+  float axn = 1.0f - ax;
+  float ayn = 1.0f - ay;
+  const unsigned char *p = image(x, y);
+  const unsigned char *p2 = image(x, y + 1);
+
+  // Interpolate each image color plane.
+  dest[0] = static_cast<unsigned char>(axn * ayn * p[0] + ax * ayn * p[4] +
+             ax * ay * p2[4] + axn * ay * p2[0] + 0.5f);
+  p++;
+  p2++;
+
+  dest[1] = static_cast<unsigned char>(axn * ayn * p[0] + ax * ayn * p[4] +
+             ax * ay * p2[4] + axn * ay * p2[0] + 0.5f);
+  p++;
+  p2++;
+
+  dest[2] = static_cast<unsigned char>(axn * ayn * p[0] + ax * ayn * p[4] +
+             ax * ay * p2[4] + axn * ay * p2[0] + 0.5f);
+  p++;
+  p2++;
+  dest[3] = 0xFF;
+}
+
+// Wrap circular coordinates around the globe
+inline float wrap(float value, float dimension) {
+  return value - (dimension * floor(value/dimension));
+}
+
+void StereographicProjection(float scale, float angle, unsigned char* input_image,
+                             int input_width, int input_height,
+                             unsigned char* output_image, int output_width,
+                             int output_height) {
+  ImageRGBA input(input_image, input_width, input_height);
+  ImageRGBA output(output_image, output_width, output_height);
+
+  const float image_scale = output_width * scale;
+
+  for (int x = 0; x < output_width; x++) {
+    // Center and scale x
+    float xf = (x - output_width / 2.0f) / image_scale;
+
+    for (int y = 0; y < output_height; y++) {
+      // Center and scale y
+      float yf = (y - output_height / 2.0f) / image_scale;
+
+      // Convert to polar
+      float r = hypotf(xf, yf);
+      float theta = angle+atan2(yf, xf);
+      if (theta>PI_F) theta-=2*PI_F;
+
+      // Project onto plane
+      float phi = 2 * atan(1 / r);
+      // (theta stays the same)
+
+      // Map to panorama image
+      float px = (theta / (2 * PI_F)) * input_width;
+      float py = (phi / PI_F) * input_height;
+
+      // Wrap around the globe
+      px = wrap(px, input_width);
+      py = wrap(py, input_height);
+
+      // Write the interpolated pixel
+      InterpolatePixel(input, px, py, output(x, y));
+    }
+  }
+}
+
+
+JNIEXPORT void JNICALL Java_com_android_camera_tinyplanet_TinyPlanetNative_process(JNIEnv* env, jobject obj, jobject bitmap_in, jint width, jint height, jobject bitmap_out, jint output_size, jfloat scale, jfloat angle)
+{
+    char* source = 0;
+    char* destination = 0;
+    AndroidBitmap_lockPixels(env, bitmap_in, (void**) &source);
+    AndroidBitmap_lockPixels(env, bitmap_out, (void**) &destination);
+    unsigned char * rgb_in = (unsigned char * )source;
+    unsigned char * rgb_out = (unsigned char * )destination;
+
+    StereographicProjection(scale, angle, rgb_in, width, height, rgb_out, output_size, output_size);
+    AndroidBitmap_unlockPixels(env, bitmap_in);
+    AndroidBitmap_unlockPixels(env, bitmap_out);
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+
diff --git a/res/layout/tinyplanet_editor.xml b/res/layout/tinyplanet_editor.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8880c2cfb17068dbfc96d39a60d62f2fe3212541
--- /dev/null
+++ b/res/layout/tinyplanet_editor.xml
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2013 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (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.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical" >
+
+    <com.android.camera.tinyplanet.TinyPlanetPreview
+        android:id="@+id/preview"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_gravity="center_horizontal"
+        android:layout_weight="1" />
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1"
+        android:orientation="vertical" >
+
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/tiny_planet_zoom"
+            android:textColor="#FFF" />
+
+        <SeekBar
+            android:id="@+id/zoomSlider"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:contentDescription="@string/tiny_planet_zoom"
+            android:max="1000"
+            android:progress="500" />
+
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="5dp"
+            android:text="@string/tiny_planet_rotate"
+            android:textColor="#FFF" />
+
+        <SeekBar
+            android:id="@+id/angleSlider"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:contentDescription="@string/tiny_planet_rotate"
+            android:max="360"
+            android:progress="0" />
+
+        <Button
+            android:id="@+id/creatTinyPlanetButton"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="5dp"
+            android:text="@string/create_tiny_planet" />
+    </LinearLayout>
+
+</LinearLayout>
\ No newline at end of file
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 16a3bedd1f456d1130596288bce5c3d6d097ee22..fe4bbfc8c2e88990c9a06493d0534bf90a8a01ed 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -625,4 +625,14 @@ CHAR LIMIT = NONE] -->
     <string name="camera_menu_more_label">MORE OPTIONS</string>
     <!-- settings label [CHAR LIMIT=50] -->
     <string name="camera_menu_settings_label">SETTINGS</string>
+
+    <!--  Tiny Planet -->
+    <!-- Button to press for creating a tiny planet image. [CHAR LIMIT = 30] -->
+    <string name="create_tiny_planet">Create Tiny Planet</string>
+    <!-- Message shown while a tiny planet image is being saved. [CHAR LIMIT = 30] -->
+    <string name="saving_tiny_planet">Saving Tiny Planet …</string>
+    <!-- Label above a slider that let's the user set the zoom of a tiny planet image. [CHAR LIMIT = 15] -->
+    <string name="tiny_planet_zoom">Zoom</string>
+    <!-- Label above a slider that let's the user set the rotation of a tiny planet image. [CHAR LIMIT = 15] -->
+    <string name="tiny_planet_rotate">Rotate</string>
 </resources>
diff --git a/src/com/android/camera/CameraActivity.java b/src/com/android/camera/CameraActivity.java
index 41511039f381c00928d4022341598793f647981c..fe65c0290987244d940ead1c204be16d3884f571 100644
--- a/src/com/android/camera/CameraActivity.java
+++ b/src/com/android/camera/CameraActivity.java
@@ -65,6 +65,7 @@ import com.android.camera.data.LocalData;
 import com.android.camera.data.LocalDataAdapter;
 import com.android.camera.data.MediaDetails;
 import com.android.camera.data.SimpleViewData;
+import com.android.camera.tinyplanet.TinyPlanetFragment;
 import com.android.camera.ui.ModuleSwitcher;
 import com.android.camera.ui.DetailsDialog;
 import com.android.camera.ui.FilmStripView;
@@ -75,7 +76,7 @@ import com.android.camera.util.PhotoSphereHelper.PanoramaViewHelper;
 import com.android.camera2.R;
 
 public class CameraActivity extends Activity
-    implements ModuleSwitcher.ModuleSwitchListener {
+        implements ModuleSwitcher.ModuleSwitchListener {
 
     private static final String TAG = "CAM_Activity";
 
@@ -167,7 +168,7 @@ public class CameraActivity extends Activity
     }
 
     private class MyOrientationEventListener
-        extends OrientationEventListener {
+            extends OrientationEventListener {
         public MyOrientationEventListener(Context context) {
             super(context);
         }
@@ -177,7 +178,9 @@ public class CameraActivity extends Activity
             // We keep the last known orientation. So if the user first orient
             // the camera then point the camera to floor or sky, we still have
             // the correct orientation.
-            if (orientation == ORIENTATION_UNKNOWN) return;
+            if (orientation == ORIENTATION_UNKNOWN) {
+                return;
+            }
             mLastRawOrientation = orientation;
             mCurrentModule.onOrientationChanged(orientation);
         }
@@ -185,18 +188,20 @@ public class CameraActivity extends Activity
 
     private MediaSaveService mMediaSaveService;
     private ServiceConnection mConnection = new ServiceConnection() {
-            @Override
-            public void onServiceConnected(ComponentName className, IBinder b) {
-                mMediaSaveService = ((MediaSaveService.LocalBinder) b).getService();
-                mCurrentModule.onMediaSaveServiceConnected(mMediaSaveService);
+        @Override
+        public void onServiceConnected(ComponentName className, IBinder b) {
+            mMediaSaveService = ((MediaSaveService.LocalBinder) b).getService();
+            mCurrentModule.onMediaSaveServiceConnected(mMediaSaveService);
+        }
+
+        @Override
+        public void onServiceDisconnected(ComponentName className) {
+            if (mMediaSaveService != null) {
+                mMediaSaveService.setListener(null);
+                mMediaSaveService = null;
             }
-            @Override
-            public void onServiceDisconnected(ComponentName className) {
-                if (mMediaSaveService != null) {
-                    mMediaSaveService.setListener(null);
-                    mMediaSaveService = null;
-                }
-            }};
+        }
+    };
 
     // close activity when screen turns off
     private BroadcastReceiver mScreenOffReceiver = new BroadcastReceiver() {
@@ -207,6 +212,7 @@ public class CameraActivity extends Activity
     };
 
     private static BroadcastReceiver sScreenOffReceiver;
+
     private static class ScreenOffReceiver extends BroadcastReceiver {
         @Override
         public void onReceive(Context context, Intent intent) {
@@ -281,7 +287,8 @@ public class CameraActivity extends Activity
                             } else {
                                 if (isCameraID) {
                                     mCurrentModule.onPreviewFocusChanged(true);
-                                    // Don't show the action bar in Camera preview.
+                                    // Don't show the action bar in Camera
+                                    // preview.
                                     setActionBarVisibilityAndLightsOut(true);
                                     if (mPendingDeletion) {
                                         performDeletion();
@@ -296,7 +303,7 @@ public class CameraActivity extends Activity
                                     return;
                                 }
                                 int panoStitchingProgress = mPanoramaManager.getTaskProgress(
-                                    contentUri);
+                                        contentUri);
                                 if (panoStitchingProgress < 0) {
                                     hidePanoStitchingProgress();
                                     return;
@@ -376,6 +383,7 @@ public class CameraActivity extends Activity
     /**
      * According to the data type, make the menu items for supported operations
      * visible.
+     *
      * @param dataID the data ID of the current item.
      */
     private void updateActionBarMenu(int dataID) {
@@ -655,7 +663,7 @@ public class CameraActivity extends Activity
             case R.id.action_show_on_map:
                 double[] latLong = localData.getLatLong();
                 if (latLong != null) {
-                  CameraUtil.showOnMap(this, latLong);
+                    CameraUtil.showOnMap(this, latLong);
                 }
                 return true;
             default:
@@ -775,7 +783,8 @@ public class CameraActivity extends Activity
                 mDataAdapter.requestLoad(getContentResolver());
             }
         } else {
-            // Put a lock placeholder as the last image by setting its date to 0.
+            // Put a lock placeholder as the last image by setting its date to
+            // 0.
             ImageView v = (ImageView) getLayoutInflater().inflate(
                     R.layout.secure_album_placeholder, null);
             mDataAdapter = new FixedLastDataAdapter(
@@ -856,8 +865,9 @@ public class CameraActivity extends Activity
     @Override
     public void onResume() {
         // TODO: Handle this in OrientationManager.
+        // Auto-rotate off
         if (Settings.System.getInt(getContentResolver(),
-                Settings.System.ACCELEROMETER_ROTATION, 0) == 0) {// auto-rotate off
+                Settings.System.ACCELEROMETER_ROTATION, 0) == 0) {
             setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
             mAutoRotateScreen = false;
         } else {
@@ -897,7 +907,9 @@ public class CameraActivity extends Activity
 
     @Override
     public void onDestroy() {
-        if (mSecureCamera) unregisterReceiver(mScreenOffReceiver);
+        if (mSecureCamera) {
+            unregisterReceiver(mScreenOffReceiver);
+        }
         super.onDestroy();
     }
 
@@ -909,11 +921,15 @@ public class CameraActivity extends Activity
 
     @Override
     public boolean onKeyDown(int keyCode, KeyEvent event) {
-        if (mCurrentModule.onKeyDown(keyCode, event)) return true;
+        if (mCurrentModule.onKeyDown(keyCode, event)) {
+            return true;
+        }
         // Prevent software keyboard or voice search from showing up.
         if (keyCode == KeyEvent.KEYCODE_SEARCH
                 || keyCode == KeyEvent.KEYCODE_MENU) {
-            if (event.isLongPress()) return true;
+            if (event.isLongPress()) {
+                return true;
+            }
         }
 
         return super.onKeyDown(keyCode, event);
@@ -921,7 +937,9 @@ public class CameraActivity extends Activity
 
     @Override
     public boolean onKeyUp(int keyCode, KeyEvent event) {
-        if (mCurrentModule.onKeyUp(keyCode, event)) return true;
+        if (mCurrentModule.onKeyUp(keyCode, event)) {
+            return true;
+        }
         return super.onKeyUp(keyCode, event);
     }
 
@@ -1009,7 +1027,9 @@ public class CameraActivity extends Activity
 
     @Override
     public void onModuleSelected(int moduleIndex) {
-        if (mCurrentModuleIndex == moduleIndex) return;
+        if (mCurrentModuleIndex == moduleIndex) {
+            return;
+        }
 
         CameraHolder.instance().keep();
         closeModule(mCurrentModule);
@@ -1028,8 +1048,8 @@ public class CameraActivity extends Activity
     }
 
     /**
-     * Sets the mCurrentModuleIndex, creates a new module instance for the
-     * given index an sets it as mCurrentModule.
+     * Sets the mCurrentModuleIndex, creates a new module instance for the given
+     * index an sets it as mCurrentModule.
      */
     private void setModuleFromIndex(int moduleIndex) {
         mCurrentModuleIndex = moduleIndex;
@@ -1069,6 +1089,22 @@ public class CameraActivity extends Activity
         startActivityForResult(Intent.createChooser(intent, null), REQ_CODE_EDIT);
     }
 
+    /**
+     * Launch the tiny planet editor.
+     *
+     * @param data the data must be a 360 degree stereographically mapped
+     *            panoramic image. It will not be modified, instead a new item
+     *            with the result will be added to the filmstrip.
+     */
+    public void launchTinyPlanetEditor(LocalData data) {
+        TinyPlanetFragment fragment = new TinyPlanetFragment();
+        Bundle bundle = new Bundle();
+        bundle.putString(TinyPlanetFragment.ARGUMENT_URI, data.getContentUri().toString());
+        bundle.putString(TinyPlanetFragment.ARGUMENT_TITLE, data.getTitle());
+        fragment.setArguments(bundle);
+        fragment.show(getFragmentManager(), "tiny_planet");
+    }
+
     private void openModule(CameraModule module) {
         module.init(this, mCameraModuleRootView);
         module.onResumeBeforeSuper();
@@ -1170,8 +1206,8 @@ public class CameraActivity extends Activity
     }
 
     /**
-     * Enable/disable swipe-to-filmstrip.
-     * Will always disable swipe if in capture intent.
+     * Enable/disable swipe-to-filmstrip. Will always disable swipe if in
+     * capture intent.
      *
      * @param enable {@code true} to enable swipe.
      */
diff --git a/src/com/android/camera/MediaSaveService.java b/src/com/android/camera/MediaSaveService.java
index 988f17f949dececa0a9154679852bc245594aebb..9c42a5c0702de992c4431f1e7a9999ae9942a822 100644
--- a/src/com/android/camera/MediaSaveService.java
+++ b/src/com/android/camera/MediaSaveService.java
@@ -52,7 +52,7 @@ public class MediaSaveService extends Service {
         public void onQueueStatus(boolean full);
     }
 
-    interface OnMediaSavedListener {
+    public interface OnMediaSavedListener {
         public void onMediaSaved(Uri uri);
     }
 
diff --git a/src/com/android/camera/tinyplanet/TinyPlanetFragment.java b/src/com/android/camera/tinyplanet/TinyPlanetFragment.java
new file mode 100644
index 0000000000000000000000000000000000000000..2c9ad41a2273df27830a7dccdfd1414bf58091b1
--- /dev/null
+++ b/src/com/android/camera/tinyplanet/TinyPlanetFragment.java
@@ -0,0 +1,475 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (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.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.camera.tinyplanet;
+
+import android.app.DialogFragment;
+import android.app.ProgressDialog;
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.CompressFormat;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Point;
+import android.graphics.RectF;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Bundle;
+import android.os.Handler;
+import android.util.Log;
+import android.view.Display;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.widget.Button;
+import android.widget.SeekBar;
+import android.widget.SeekBar.OnSeekBarChangeListener;
+
+import com.adobe.xmp.XMPException;
+import com.adobe.xmp.XMPMeta;
+import com.android.camera.CameraActivity;
+import com.android.camera.MediaSaveService;
+import com.android.camera.MediaSaveService.OnMediaSavedListener;
+import com.android.camera.tinyplanet.TinyPlanetPreview.PreviewSizeListener;
+import com.android.camera.util.XmpUtil;
+import com.android.camera2.R;
+
+import java.io.ByteArrayOutputStream;
+import java.io.FileNotFoundException;
+import java.io.InputStream;
+import java.util.Date;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * An activity that provides an editor UI to create a TinyPlanet image from a
+ * 360 degree stereographically mapped panoramic image.
+ */
+public class TinyPlanetFragment extends DialogFragment implements PreviewSizeListener {
+    /** Argument to tell the fragment the URI of the original panoramic image. */
+    public static final String ARGUMENT_URI = "uri";
+    /** Argument to tell the fragment the title of the original panoramic image. */
+    public static final String ARGUMENT_TITLE = "title";
+
+    public static final String CROPPED_AREA_IMAGE_WIDTH_PIXELS =
+            "CroppedAreaImageWidthPixels";
+    public static final String CROPPED_AREA_IMAGE_HEIGHT_PIXELS =
+            "CroppedAreaImageHeightPixels";
+    public static final String CROPPED_AREA_FULL_PANO_WIDTH_PIXELS =
+            "FullPanoWidthPixels";
+    public static final String CROPPED_AREA_FULL_PANO_HEIGHT_PIXELS =
+            "FullPanoHeightPixels";
+    public static final String CROPPED_AREA_LEFT =
+            "CroppedAreaLeftPixels";
+    public static final String CROPPED_AREA_TOP =
+            "CroppedAreaTopPixels";
+    public static final String GOOGLE_PANO_NAMESPACE = "http://ns.google.com/photos/1.0/panorama/";
+
+    private static final String TAG = "TinyPlanetActivity";
+    /** Delay between a value update and the renderer running. */
+    private static final int RENDER_DELAY_MILLIS = 50;
+    /** Filename prefix to prepend to the original name for the new file. */
+    private static final String FILENAME_PREFIX = "TINYPLANET_";
+
+    private Uri mSourceImageUri;
+    private TinyPlanetPreview mPreview;
+    private int mPreviewSizePx = 0;
+    private float mCurrentZoom = 0.5f;
+    private float mCurrentAngle = 0;
+    private ProgressDialog mDialog;
+
+    /**
+     * Lock for the result preview bitmap. We can't change it while we're trying
+     * to draw it.
+     */
+    private Lock mResultLock = new ReentrantLock();
+
+    /** The title of the original panoramic image. */
+    private String mOriginalTitle = "";
+
+    /** The padded source bitmap. */
+    private Bitmap mSourceBitmap;
+    /** The resulting preview bitmap. */
+    private Bitmap mResultBitmap;
+
+    /** Used to delay-post a tiny planet rendering task. */
+    private Handler mHandler = new Handler();
+    /** Whether rendering is in progress right now. */
+    private Boolean mRendering = false;
+    /**
+     * Whether we should render one more time after the current rendering run is
+     * done. This is needed when there was an update to the values during the
+     * current rendering.
+     */
+    private Boolean mRenderOneMore = false;
+
+    /** Tiny planet data plus size. */
+    private static final class TinyPlanetImage {
+        public final byte[] mJpegData;
+        public final int mSize;
+
+        public TinyPlanetImage(byte[] jpegData, int size) {
+            mJpegData = jpegData;
+            mSize = size;
+        }
+    }
+
+    /**
+     * Creates and executes a task to create a tiny planet with the current
+     * values.
+     */
+    private final Runnable mCreateTinyPlanetRunnable = new Runnable() {
+        @Override
+        public void run() {
+            synchronized (mRendering) {
+                if (mRendering) {
+                    mRenderOneMore = true;
+                    return;
+                }
+                mRendering = true;
+            }
+
+            (new AsyncTask<Void, Void, Void>() {
+                @Override
+                protected Void doInBackground(Void... params) {
+                    mResultLock.lock();
+                    try {
+                        if (mSourceBitmap == null || mResultBitmap == null) {
+                            return null;
+                        }
+
+                        int width = mSourceBitmap.getWidth();
+                        int height = mSourceBitmap.getHeight();
+                        TinyPlanetNative.process(mSourceBitmap, width, height, mResultBitmap,
+                                mPreviewSizePx,
+                                mCurrentZoom, mCurrentAngle);
+                    } finally {
+                        mResultLock.unlock();
+                    }
+                    return null;
+                }
+
+                protected void onPostExecute(Void result) {
+                    mPreview.setBitmap(mResultBitmap, mResultLock);
+                    synchronized (mRendering) {
+                        mRendering = false;
+                        if (mRenderOneMore) {
+                            mRenderOneMore = false;
+                            scheduleUpdate();
+                        }
+                    }
+                }
+            }).execute();
+        }
+    };
+
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setStyle(DialogFragment.STYLE_NORMAL, R.style.Theme_Camera);
+    }
+
+    @Override
+    public View onCreateView(LayoutInflater inflater, ViewGroup container,
+            Bundle savedInstanceState) {
+        getDialog().getWindow().requestFeature(Window.FEATURE_NO_TITLE);
+        getDialog().setCanceledOnTouchOutside(true);
+
+        View view = inflater.inflate(R.layout.tinyplanet_editor,
+                container, false);
+        mPreview = (TinyPlanetPreview) view.findViewById(R.id.preview);
+        mPreview.setPreviewSizeChangeListener(this);
+
+        // Zoom slider setup.
+        SeekBar zoomSlider = (SeekBar) view.findViewById(R.id.zoomSlider);
+        zoomSlider.setOnSeekBarChangeListener(new OnSeekBarChangeListener() {
+            @Override
+            public void onStopTrackingTouch(SeekBar seekBar) {
+                // Do nothing.
+            }
+
+            @Override
+            public void onStartTrackingTouch(SeekBar seekBar) {
+                // Do nothing.
+            }
+
+            @Override
+            public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+                onZoomChange(progress);
+            }
+        });
+
+        // Rotation slider setup.
+        SeekBar angleSlider = (SeekBar) view.findViewById(R.id.angleSlider);
+        angleSlider.setOnSeekBarChangeListener(new OnSeekBarChangeListener() {
+            @Override
+            public void onStopTrackingTouch(SeekBar seekBar) {
+                // Do nothing.
+            }
+
+            @Override
+            public void onStartTrackingTouch(SeekBar seekBar) {
+                // Do nothing.
+            }
+
+            @Override
+            public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+                onAngleChange(progress);
+            }
+        });
+
+        Button createButton = (Button) view.findViewById(R.id.creatTinyPlanetButton);
+        createButton.setOnClickListener(new OnClickListener() {
+            @Override
+            public void onClick(View v) {
+                onCreateTinyPlanet();
+            }
+        });
+
+        mOriginalTitle = getArguments().getString(ARGUMENT_TITLE);
+        mSourceImageUri = Uri.parse(getArguments().getString(ARGUMENT_URI));
+        mSourceBitmap = createPaddedSourceImage(mSourceImageUri, true);
+
+        if (mSourceBitmap == null) {
+            Log.e(TAG, "Could not decode source image.");
+            dismiss();
+        }
+        return view;
+    }
+
+    /**
+     * From the given URI this method creates a 360/180 padded image that is
+     * ready to be made a tiny planet.
+     */
+    private Bitmap createPaddedSourceImage(Uri sourceImageUri, boolean previewSize) {
+        InputStream is = getInputStream(sourceImageUri);
+        if (is == null) {
+            Log.e(TAG, "Could not create input stream for image.");
+            dismiss();
+        }
+        Bitmap sourceBitmap = BitmapFactory.decodeStream(is);
+
+        is = getInputStream(sourceImageUri);
+        XMPMeta xmp = XmpUtil.extractXMPMeta(is);
+
+        if (xmp != null) {
+            int size = previewSize ? getDisplaySize() : sourceBitmap.getWidth();
+            sourceBitmap = createPaddedBitmap(sourceBitmap, xmp, size);
+        }
+        return sourceBitmap;
+    }
+
+    /**
+     * Starts an asynchronous task to create a tiny planet. Once done, will add
+     * the new image to the filmstrip and dismisses the fragment.
+     */
+    private void onCreateTinyPlanet() {
+        // Make sure we stop rendering before we create the high-res tiny
+        // planet.
+        synchronized (mRendering) {
+            mRenderOneMore = false;
+        }
+
+        final String savingTinyPlanet = getActivity().getResources().getString(
+                R.string.saving_tiny_planet);
+        (new AsyncTask<Void, Void, TinyPlanetImage>() {
+            @Override
+            protected void onPreExecute() {
+                mDialog = ProgressDialog.show(getActivity(), null, savingTinyPlanet, true, false);
+            }
+
+            @Override
+            protected TinyPlanetImage doInBackground(Void... params) {
+                return createTinyPlanet();
+            }
+
+            @Override
+            protected void onPostExecute(TinyPlanetImage image) {
+                // Once created, store the new file and add it to the filmstrip.
+                final CameraActivity activity = (CameraActivity) getActivity();
+                MediaSaveService mediaSaveService = activity.getMediaSaveService();
+                OnMediaSavedListener doneListener =
+                        new OnMediaSavedListener() {
+                            @Override
+                            public void onMediaSaved(Uri uri) {
+                                // Add the new photo to the filmstrip and exit
+                                // the fragment.
+                                activity.notifyNewMedia(uri);
+                                mDialog.dismiss();
+                                TinyPlanetFragment.this.dismiss();
+                            }
+                        };
+                String tinyPlanetTitle = FILENAME_PREFIX + mOriginalTitle;
+                mediaSaveService.addImage(image.mJpegData, tinyPlanetTitle, (new Date()).getTime(),
+                        null,
+                        image.mSize, image.mSize, 0, null, doneListener, getActivity()
+                                .getContentResolver());
+            }
+        }).execute();
+    }
+
+    /**
+     * Creates the high quality tiny planet file and adds it to the media
+     * service. Don't call this on the UI thread.
+     */
+    private TinyPlanetImage createTinyPlanet() {
+        // Free some memory we don't need anymore as we're going to dimiss the
+        // fragment after the tiny planet creation.
+        mResultLock.lock();
+        try {
+            mResultBitmap.recycle();
+            mResultBitmap = null;
+            mSourceBitmap.recycle();
+            mSourceBitmap = null;
+        } finally {
+            mResultLock.unlock();
+        }
+
+        // Create a high-resolution padded image.
+        Bitmap sourceBitmap = createPaddedSourceImage(mSourceImageUri, false);
+        int width = sourceBitmap.getWidth();
+        int height = sourceBitmap.getHeight();
+
+        int outputSize = width / 2;
+        Bitmap resultBitmap = Bitmap.createBitmap(outputSize, outputSize,
+                Bitmap.Config.ARGB_8888);
+
+        TinyPlanetNative.process(sourceBitmap, width, height, resultBitmap,
+                outputSize, mCurrentZoom, mCurrentAngle);
+
+        // Free the sourceImage memory as we don't need it and we need memory
+        // for the JPEG bytes.
+        sourceBitmap.recycle();
+        sourceBitmap = null;
+
+        ByteArrayOutputStream jpeg = new ByteArrayOutputStream();
+        resultBitmap.compress(CompressFormat.JPEG, 100, jpeg);
+        return new TinyPlanetImage(jpeg.toByteArray(), outputSize);
+    }
+
+    private int getDisplaySize() {
+        Display display = getActivity().getWindowManager().getDefaultDisplay();
+        Point size = new Point();
+        display.getSize(size);
+        return Math.min(size.x, size.y);
+    }
+
+    @Override
+    public void onSizeChanged(int sizePx) {
+        mPreviewSizePx = sizePx;
+        mResultLock.lock();
+        try {
+            if (mResultBitmap == null || mResultBitmap.getWidth() != sizePx
+                    || mResultBitmap.getHeight() != sizePx) {
+                if (mResultBitmap != null) {
+                    mResultBitmap.recycle();
+                }
+                mResultBitmap = Bitmap.createBitmap(mPreviewSizePx, mPreviewSizePx,
+                        Bitmap.Config.ARGB_8888);
+            }
+        } finally {
+            mResultLock.unlock();
+        }
+
+        // Run directly and on this thread directly.
+        mCreateTinyPlanetRunnable.run();
+    }
+
+    private void onZoomChange(int zoom) {
+        // 1000 needs to be in sync with the max values declared in the layout
+        // xml file.
+        mCurrentZoom = zoom / 1000f;
+        scheduleUpdate();
+    }
+
+    private void onAngleChange(int angle) {
+        mCurrentAngle = (float) Math.toRadians(angle);
+        scheduleUpdate();
+    }
+
+    /**
+     * Delay-post a new preview rendering run.
+     */
+    private void scheduleUpdate() {
+        mHandler.removeCallbacks(mCreateTinyPlanetRunnable);
+        mHandler.postDelayed(mCreateTinyPlanetRunnable, RENDER_DELAY_MILLIS);
+    }
+
+    private InputStream getInputStream(Uri uri) {
+        try {
+            return getActivity().getContentResolver().openInputStream(uri);
+        } catch (FileNotFoundException e) {
+            Log.e(TAG, "Could not load source image.", e);
+        }
+        return null;
+    }
+
+    /**
+     * To create a proper TinyPlanet, the input image must be 2:1 (360:180
+     * degrees). So if needed, we pad the source image with black.
+     */
+    private static Bitmap createPaddedBitmap(Bitmap bitmapIn, XMPMeta xmp, int intermediateWidth) {
+        try {
+            int croppedAreaWidth =
+                    getInt(xmp, CROPPED_AREA_IMAGE_WIDTH_PIXELS);
+            int croppedAreaHeight =
+                    getInt(xmp, CROPPED_AREA_IMAGE_HEIGHT_PIXELS);
+            int fullPanoWidth =
+                    getInt(xmp, CROPPED_AREA_FULL_PANO_WIDTH_PIXELS);
+            int fullPanoHeight =
+                    getInt(xmp, CROPPED_AREA_FULL_PANO_HEIGHT_PIXELS);
+            int left = getInt(xmp, CROPPED_AREA_LEFT);
+            int top = getInt(xmp, CROPPED_AREA_TOP);
+
+            if (fullPanoWidth == 0 || fullPanoHeight == 0) {
+                return bitmapIn;
+            }
+            // Make sure the intermediate image has the similar size to the
+            // input.
+            Bitmap paddedBitmap = null;
+            float scale = intermediateWidth / (float) fullPanoWidth;
+            while (paddedBitmap == null) {
+                try {
+                    paddedBitmap = Bitmap.createBitmap(
+                            (int) (fullPanoWidth * scale), (int) (fullPanoHeight * scale),
+                            Bitmap.Config.ARGB_8888);
+                } catch (OutOfMemoryError e) {
+                    System.gc();
+                    scale /= 2;
+                }
+            }
+            Canvas paddedCanvas = new Canvas(paddedBitmap);
+
+            int right = left + croppedAreaWidth;
+            int bottom = top + croppedAreaHeight;
+            RectF destRect = new RectF(left * scale, top * scale, right * scale, bottom * scale);
+            paddedCanvas.drawBitmap(bitmapIn, null, destRect, null);
+            return paddedBitmap;
+        } catch (XMPException ex) {
+            // Do nothing, just use mSourceBitmap as is.
+        }
+        return bitmapIn;
+    }
+
+    private static int getInt(XMPMeta xmp, String key) throws XMPException {
+        if (xmp.doesPropertyExist(GOOGLE_PANO_NAMESPACE, key)) {
+            return xmp.getPropertyInteger(GOOGLE_PANO_NAMESPACE, key);
+        } else {
+            return 0;
+        }
+    }
+}
diff --git a/src/com/android/camera/tinyplanet/TinyPlanetNative.java b/src/com/android/camera/tinyplanet/TinyPlanetNative.java
new file mode 100644
index 0000000000000000000000000000000000000000..301db59cecb7fbcde00cc0d9b1f8a2f0db5a682a
--- /dev/null
+++ b/src/com/android/camera/tinyplanet/TinyPlanetNative.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (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.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.camera.tinyplanet;
+
+import android.graphics.Bitmap;
+
+/**
+ * TinyPlanet native interface.
+ */
+public class TinyPlanetNative {
+    static {
+        System.loadLibrary("jni_tinyplanet");
+    }
+
+    /**
+     * Create a tiny planet.
+     *
+     * @param in the 360 degree stereographically mapped panoramic input image.
+     * @param width the width of the input image.
+     * @param height the height of the input image.
+     * @param out the resulting tiny planet.
+     * @param outputSize the width and height of the square output image.
+     * @param scale the scale factor (used for fast previews).
+     * @param angleRadians the angle of the tiny planet in radians.
+     */
+    public static native void process(Bitmap in, int width, int height, Bitmap out, int outputSize,
+            float scale, float angleRadians);
+}
diff --git a/src/com/android/camera/tinyplanet/TinyPlanetPreview.java b/src/com/android/camera/tinyplanet/TinyPlanetPreview.java
new file mode 100644
index 0000000000000000000000000000000000000000..7e7aff5fa5ff4fb1ca7c7db28ea9a4a36449b883
--- /dev/null
+++ b/src/com/android/camera/tinyplanet/TinyPlanetPreview.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (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.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.camera.tinyplanet;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.util.AttributeSet;
+import android.view.View;
+
+import java.util.concurrent.locks.Lock;
+
+/**
+ * Shows a preview of the TinyPlanet on the screen while editing.
+ */
+public class TinyPlanetPreview extends View {
+    /**
+     * Classes implementing this interface get informed about changes to the
+     * preview size.
+     */
+    public static interface PreviewSizeListener {
+        /**
+         * Called when the preview size has changed.
+         *
+         * @param sizePx the size in pixels of the square preview area
+         */
+        public void onSizeChanged(int sizePx);
+    }
+
+    private Paint mPaint = new Paint();
+    private Bitmap mPreview;
+    private Lock mLock;
+    private PreviewSizeListener mPreviewSizeListener;
+    private int mSize = 0;
+
+    public TinyPlanetPreview(Context context) {
+        super(context);
+    }
+
+    public TinyPlanetPreview(Context context, AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    public TinyPlanetPreview(Context context, AttributeSet attrs, int defStyle) {
+        super(context, attrs, defStyle);
+    }
+
+    /**
+     * Sets the bitmap and waits for a draw to happen before returning.
+     */
+    public void setBitmap(Bitmap preview, Lock lock) {
+        mPreview = preview;
+        mLock = lock;
+        invalidate();
+    }
+
+    public void setPreviewSizeChangeListener(PreviewSizeListener listener) {
+        mPreviewSizeListener = listener;
+        if (mSize > 0) {
+            mPreviewSizeListener.onSizeChanged(mSize);
+        }
+    }
+
+    @Override
+    protected void onDraw(Canvas canvas) {
+        super.onDraw(canvas);
+        if (mLock != null && mLock.tryLock() && mPreview != null && !mPreview.isRecycled()) {
+            try {
+                canvas.drawBitmap(mPreview, 0, 0, mPaint);
+            } finally {
+                mLock.unlock();
+            }
+        }
+    }
+
+    @Override
+    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+        // Make sure the view is square
+        int size = Math.min(getMeasuredWidth(), getMeasuredHeight());
+        setMeasuredDimension(size, size);
+    }
+
+    @Override
+    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+        super.onLayout(changed, left, top, right, bottom);
+        if (changed && mPreviewSizeListener != null) {
+            int width = right - left;
+            int height = bottom - top;
+
+            // These should be the same as we enforce a square layout, but let's
+            // be safe.
+            int mSize = Math.min(width, height);
+
+            // Tell the listener about our new size so the renderer can adapt.
+            if (mSize > 0 && mPreviewSizeListener != null) {
+                mPreviewSizeListener.onSizeChanged(mSize);
+            }
+        }
+    }
+}
diff --git a/src/com/android/camera/ui/FilmStripView.java b/src/com/android/camera/ui/FilmStripView.java
index 7803b2b727743ca75f509289a0eee937fe52bbaf..b5dfee5576ca53716df3a80e5d89befb13792ba6 100644
--- a/src/com/android/camera/ui/FilmStripView.java
+++ b/src/com/android/camera/ui/FilmStripView.java
@@ -938,7 +938,11 @@ public class FilmStripView extends ViewGroup implements BottomControlsListener {
 
     @Override
     public void onTinyPlanet() {
-        // TODO: Bring tiny planet to Camera2.
+        ImageData data = mDataAdapter.getImageData(getCurrentId());
+        if (data == null || !(data instanceof LocalData)) {
+            return;
+        }
+        mActivity.launchTinyPlanetEditor((LocalData) data);
     }
 
     /**
diff --git a/src/com/android/camera/util/XmpUtil.java b/src/com/android/camera/util/XmpUtil.java
new file mode 100644
index 0000000000000000000000000000000000000000..c985a6bd82cb222edb3c99352e7df7feb23713bc
--- /dev/null
+++ b/src/com/android/camera/util/XmpUtil.java
@@ -0,0 +1,405 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (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.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.camera.util;
+
+import android.util.Log;
+
+import com.adobe.xmp.XMPException;
+import com.adobe.xmp.XMPMeta;
+import com.adobe.xmp.XMPMetaFactory;
+import com.adobe.xmp.options.SerializeOptions;
+
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Util class to read/write xmp from a jpeg image file. It only supports jpeg
+ * image format, and doesn't support extended xmp now.
+ * To use it:
+ * XMPMeta xmpMeta = XmpUtil.extractOrCreateXMPMeta(filename);
+ * xmpMeta.setProperty(PanoConstants.GOOGLE_PANO_NAMESPACE, "property_name", "value");
+ * XmpUtil.writeXMPMeta(filename, xmpMeta);
+ *
+ * Or if you don't care the existing XMP meta data in image file:
+ * XMPMeta xmpMeta = XmpUtil.createXMPMeta();
+ * xmpMeta.setPropertyBoolean(PanoConstants.GOOGLE_PANO_NAMESPACE, "bool_property_name", "true");
+ * XmpUtil.writeXMPMeta(filename, xmpMeta);
+ */
+public class XmpUtil {
+  private static final String TAG = "XmpUtil";
+  private static final int XMP_HEADER_SIZE = 29;
+  private static final String XMP_HEADER = "http://ns.adobe.com/xap/1.0/\0";
+  private static final int MAX_XMP_BUFFER_SIZE = 65502;
+
+  private static final String GOOGLE_PANO_NAMESPACE = "http://ns.google.com/photos/1.0/panorama/";
+  private static final String PANO_PREFIX = "GPano";
+
+  private static final int M_SOI = 0xd8; // File start marker.
+  private static final int M_APP1 = 0xe1; // Marker for Exif or XMP.
+  private static final int M_SOS = 0xda; // Image data marker.
+
+  // Jpeg file is composed of many sections and image data. This class is used
+  // to hold the section data from image file.
+  private static class Section {
+    public int marker;
+    public int length;
+    public byte[] data;
+  }
+
+  static {
+    try {
+      XMPMetaFactory.getSchemaRegistry().registerNamespace(
+          GOOGLE_PANO_NAMESPACE, PANO_PREFIX);
+    } catch (XMPException e) {
+      e.printStackTrace();
+    }
+  }
+
+  /**
+   * Extracts XMPMeta from JPEG image file.
+   *
+   * @param filename JPEG image file name.
+   * @return Extracted XMPMeta or null.
+   */
+  public static XMPMeta extractXMPMeta(String filename) {
+    if (!filename.toLowerCase().endsWith(".jpg")
+        && !filename.toLowerCase().endsWith(".jpeg")) {
+      Log.d(TAG, "XMP parse: only jpeg file is supported");
+      return null;
+    }
+
+    try {
+      return extractXMPMeta(new FileInputStream(filename));
+    } catch (FileNotFoundException e) {
+      Log.e(TAG, "Could not read file: " + filename, e);
+      return null;
+    }
+  }
+
+  /**
+   *  Extracts XMPMeta from a JPEG image file stream.
+   *
+   * @param is the input stream containing the JPEG image file.
+   * @return Extracted XMPMeta or null.
+   */
+  public static XMPMeta extractXMPMeta(InputStream is) {
+    List<Section> sections = parse(is, true);
+    if (sections == null) {
+      return null;
+    }
+    // Now we don't support extended xmp.
+    for (Section section : sections) {
+      if (hasXMPHeader(section.data)) {
+        int end = getXMPContentEnd(section.data);
+        byte[] buffer = new byte[end - XMP_HEADER_SIZE];
+        System.arraycopy(
+            section.data, XMP_HEADER_SIZE, buffer, 0, buffer.length);
+        try {
+          XMPMeta result = XMPMetaFactory.parseFromBuffer(buffer);
+          return result;
+        } catch (XMPException e) {
+          Log.d(TAG, "XMP parse error", e);
+          return null;
+        }
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Creates a new XMPMeta.
+   */
+  public static XMPMeta createXMPMeta() {
+    return XMPMetaFactory.create();
+  }
+
+  /**
+   * Tries to extract XMP meta from image file first, if failed, create one.
+   */
+  public static XMPMeta extractOrCreateXMPMeta(String filename) {
+    XMPMeta meta = extractXMPMeta(filename);
+    return meta == null ? createXMPMeta() : meta;
+  }
+
+  /**
+   * Writes the XMPMeta to the jpeg image file.
+   */
+  public static boolean writeXMPMeta(String filename, XMPMeta meta) {
+    if (!filename.toLowerCase().endsWith(".jpg")
+        && !filename.toLowerCase().endsWith(".jpeg")) {
+      Log.d(TAG, "XMP parse: only jpeg file is supported");
+      return false;
+    }
+    List<Section> sections = null;
+    try {
+      sections = parse(new FileInputStream(filename), false);
+      sections = insertXMPSection(sections, meta);
+      if (sections == null) {
+        return false;
+      }
+    } catch (FileNotFoundException e) {
+      Log.e(TAG, "Could not read file: " + filename, e);
+      return false;
+    }
+    FileOutputStream os = null;
+    try {
+      // Overwrite the image file with the new meta data.
+      os = new FileOutputStream(filename);
+      writeJpegFile(os, sections);
+    } catch (IOException e) {
+      Log.d(TAG, "Write file failed:" + filename, e);
+      return false;
+    } finally {
+      if (os != null) {
+        try {
+          os.close();
+        } catch (IOException e) {
+          // Ignore.
+        }
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Updates a jpeg file from inputStream with XMPMeta to outputStream.
+   */
+  public static boolean writeXMPMeta(InputStream inputStream, OutputStream outputStream,
+      XMPMeta meta) {
+    List<Section> sections = parse(inputStream, false);
+      sections = insertXMPSection(sections, meta);
+      if (sections == null) {
+        return false;
+      }
+    try {
+      // Overwrite the image file with the new meta data.
+      writeJpegFile(outputStream, sections);
+    } catch (IOException e) {
+      Log.d(TAG, "Write to stream failed", e);
+      return false;
+    } finally {
+      if (outputStream != null) {
+        try {
+          outputStream.close();
+        } catch (IOException e) {
+          // Ignore.
+        }
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Write a list of sections to a Jpeg file.
+   */
+  private static void writeJpegFile(OutputStream os, List<Section> sections)
+      throws IOException {
+    // Writes the jpeg file header.
+    os.write(0xff);
+    os.write(M_SOI);
+    for (Section section : sections) {
+      os.write(0xff);
+      os.write(section.marker);
+      if (section.length > 0) {
+        // It's not the image data.
+        int lh = section.length >> 8;
+        int ll = section.length & 0xff;
+        os.write(lh);
+        os.write(ll);
+      }
+      os.write(section.data);
+    }
+  }
+
+  private static List<Section> insertXMPSection(
+      List<Section> sections, XMPMeta meta) {
+    if (sections == null || sections.size() <= 1) {
+      return null;
+    }
+    byte[] buffer;
+    try {
+      SerializeOptions options = new SerializeOptions();
+      options.setUseCompactFormat(true);
+      // We have to omit packet wrapper here because
+      // javax.xml.parsers.DocumentBuilder
+      // fails to parse the packet end <?xpacket end="w"?> in android.
+      options.setOmitPacketWrapper(true);
+      buffer = XMPMetaFactory.serializeToBuffer(meta, options);
+    } catch (XMPException e) {
+      Log.d(TAG, "Serialize xmp failed", e);
+      return null;
+    }
+    if (buffer.length > MAX_XMP_BUFFER_SIZE) {
+      // Do not support extended xmp now.
+      return null;
+    }
+    // The XMP section starts with XMP_HEADER and then the real xmp data.
+    byte[] xmpdata = new byte[buffer.length + XMP_HEADER_SIZE];
+    System.arraycopy(XMP_HEADER.getBytes(), 0, xmpdata, 0, XMP_HEADER_SIZE);
+    System.arraycopy(buffer, 0, xmpdata, XMP_HEADER_SIZE, buffer.length);
+    Section xmpSection = new Section();
+    xmpSection.marker = M_APP1;
+    // Adds the length place (2 bytes) to the section length.
+    xmpSection.length = xmpdata.length + 2;
+    xmpSection.data = xmpdata;
+
+    for (int i = 0; i < sections.size(); ++i) {
+      // If we can find the old xmp section, replace it with the new one.
+      if (sections.get(i).marker == M_APP1
+          && hasXMPHeader(sections.get(i).data)) {
+        // Replace with the new xmp data.
+        sections.set(i, xmpSection);
+        return sections;
+      }
+    }
+    // If the first section is Exif, insert XMP data before the second section,
+    // otherwise, make xmp data the first section.
+    List<Section> newSections = new ArrayList<Section>();
+    int position = (sections.get(0).marker == M_APP1) ? 1 : 0;
+    newSections.addAll(sections.subList(0, position));
+    newSections.add(xmpSection);
+    newSections.addAll(sections.subList(position, sections.size()));
+    return newSections;
+  }
+
+  /**
+   * Checks whether the byte array has XMP header. The XMP section contains
+   * a fixed length header XMP_HEADER.
+   *
+   * @param data Xmp metadata.
+   */
+  private static boolean hasXMPHeader(byte[] data) {
+    if (data.length < XMP_HEADER_SIZE) {
+      return false;
+    }
+    try {
+      byte[] header = new byte[XMP_HEADER_SIZE];
+      System.arraycopy(data, 0, header, 0, XMP_HEADER_SIZE);
+      if (new String(header, "UTF-8").equals(XMP_HEADER)) {
+        return true;
+      }
+    } catch (UnsupportedEncodingException e) {
+      return false;
+    }
+    return false;
+  }
+
+  /**
+   * Gets the end of the xmp meta content. If there is no packet wrapper,
+   * return data.length, otherwise return 1 + the position of last '>'
+   * without '?' before it.
+   * Usually the packet wrapper end is "<?xpacket end="w"?> but
+   * javax.xml.parsers.DocumentBuilder fails to parse it in android.
+   *
+   * @param data xmp metadata bytes.
+   * @return The end of the xmp metadata content.
+   */
+  private static int getXMPContentEnd(byte[] data) {
+    for (int i = data.length - 1; i >= 1; --i) {
+      if (data[i] == '>') {
+        if (data[i - 1] != '?') {
+          return i + 1;
+        }
+      }
+    }
+    // It should not reach here for a valid xmp meta.
+    return data.length;
+  }
+
+  /**
+   * Parses the jpeg image file. If readMetaOnly is true, only keeps the Exif
+   * and XMP sections (with marker M_APP1) and ignore others; otherwise, keep
+   * all sections. The last section with image data will have -1 length.
+   *
+   * @param is Input image data stream.
+   * @param readMetaOnly Whether only reads the metadata in jpg.
+   * @return The parse result.
+   */
+  private static List<Section> parse(InputStream is, boolean readMetaOnly) {
+    try {
+      if (is.read() != 0xff || is.read() != M_SOI) {
+        return null;
+      }
+      List<Section> sections = new ArrayList<Section>();
+      int c;
+      while ((c = is.read()) != -1) {
+        if (c != 0xff) {
+          return null;
+        }
+        // Skip padding bytes.
+        while ((c = is.read()) == 0xff) {
+        }
+        if (c == -1) {
+          return null;
+        }
+        int marker = c;
+        if (marker == M_SOS) {
+          // M_SOS indicates the image data will follow and no metadata after
+          // that, so read all data at one time.
+          if (!readMetaOnly) {
+            Section section = new Section();
+            section.marker = marker;
+            section.length = -1;
+            section.data = new byte[is.available()];
+            is.read(section.data, 0, section.data.length);
+            sections.add(section);
+          }
+          return sections;
+        }
+        int lh = is.read();
+        int ll = is.read();
+        if (lh == -1 || ll == -1) {
+          return null;
+        }
+        int length = lh << 8 | ll;
+        if (!readMetaOnly || c == M_APP1) {
+          Section section = new Section();
+          section.marker = marker;
+          section.length = length;
+          section.data = new byte[length - 2];
+          is.read(section.data, 0, length - 2);
+          sections.add(section);
+        } else {
+          // Skip this section since all exif/xmp meta will be in M_APP1
+          // section.
+          is.skip(length - 2);
+        }
+      }
+      return sections;
+    } catch (IOException e) {
+      Log.d(TAG, "Could not parse file.", e);
+      return null;
+    } finally {
+      if (is != null) {
+        try {
+          is.close();
+        } catch (IOException e) {
+          // Ignore.
+        }
+      }
+    }
+  }
+
+  private XmpUtil() {}
+}