summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSascha Haeberling <haeberling@google.com>2013-09-11 20:30:52 -0700
committerSascha Haeberling <haeberling@google.com>2013-09-12 18:10:57 -0700
commita5a08d7642a1fdf961b057cc90e76c4c93103c15 (patch)
treeb9681bfdc4935424736aec2e6d11e3b0b48e13b1
parent742cd5b18e1899bee1d430b61891238c0f3502b6 (diff)
downloadandroid_packages_apps_Snap-a5a08d7642a1fdf961b057cc90e76c4c93103c15.tar.gz
android_packages_apps_Snap-a5a08d7642a1fdf961b057cc90e76c4c93103c15.tar.bz2
android_packages_apps_Snap-a5a08d7642a1fdf961b057cc90e76c4c93103c15.zip
Bring back tiny planet to the Camera filmstrip
Bug: 10393598 The native code and XmpUtil are mostly 1:1 copies from Gallery2. The UI is new and should work on all form factors. Change-Id: Ia302a4a7a24cf0b3aa583836683c459e9e7e1f85
-rw-r--r--Android.mk4
-rwxr-xr-xjni/Android.mk15
-rw-r--r--jni/tinyplanet.cc151
-rw-r--r--res/layout/tinyplanet_editor.xml73
-rw-r--r--res/values/strings.xml10
-rw-r--r--src/com/android/camera/CameraActivity.java92
-rw-r--r--src/com/android/camera/MediaSaveService.java2
-rw-r--r--src/com/android/camera/tinyplanet/TinyPlanetFragment.java475
-rw-r--r--src/com/android/camera/tinyplanet/TinyPlanetNative.java42
-rw-r--r--src/com/android/camera/tinyplanet/TinyPlanetPreview.java117
-rw-r--r--src/com/android/camera/ui/FilmStripView.java6
-rw-r--r--src/com/android/camera/util/XmpUtil.java405
12 files changed, 1360 insertions, 32 deletions
diff --git a/Android.mk b/Android.mk
index ef4772844..285e59543 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 9f6f73925..b9bafcf6c 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 000000000..dfb31d768
--- /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 000000000..8880c2cfb
--- /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 16a3bedd1..fe4bbfc8c 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 41511039f..fe65c0290 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 988f17f94..9c42a5c07 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 000000000..2c9ad41a2
--- /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 000000000..301db59ce
--- /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 000000000..7e7aff5fa
--- /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 7803b2b72..b5dfee557 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 000000000..c985a6bd8
--- /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() {}
+}