From a5a08d7642a1fdf961b057cc90e76c4c93103c15 Mon Sep 17 00:00:00 2001 From: Sascha Haeberling Date: Wed, 11 Sep 2013 20:30:52 -0700 Subject: 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 --- src/com/android/camera/CameraActivity.java | 92 ++-- src/com/android/camera/MediaSaveService.java | 2 +- .../camera/tinyplanet/TinyPlanetFragment.java | 475 +++++++++++++++++++++ .../camera/tinyplanet/TinyPlanetNative.java | 42 ++ .../camera/tinyplanet/TinyPlanetPreview.java | 117 +++++ src/com/android/camera/ui/FilmStripView.java | 6 +- src/com/android/camera/util/XmpUtil.java | 405 ++++++++++++++++++ 7 files changed, 1109 insertions(+), 30 deletions(-) create mode 100644 src/com/android/camera/tinyplanet/TinyPlanetFragment.java create mode 100644 src/com/android/camera/tinyplanet/TinyPlanetNative.java create mode 100644 src/com/android/camera/tinyplanet/TinyPlanetPreview.java create mode 100644 src/com/android/camera/util/XmpUtil.java (limited to 'src') 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() { + @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() { + @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
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
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
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
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
insertXMPSection( + List
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 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
newSections = new ArrayList
(); + 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 " 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
parse(InputStream is, boolean readMetaOnly) { + try { + if (is.read() != 0xff || is.read() != M_SOI) { + return null; + } + List
sections = new ArrayList
(); + 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() {} +} -- cgit v1.2.3