TinyPlanetFragment.java revision fd4fc0e52ad69c2d486f5f46c2d465b4c4ba2849
1/*
2 * Copyright (C) 2013 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.camera.tinyplanet;
18
19import android.app.DialogFragment;
20import android.app.ProgressDialog;
21import android.graphics.Bitmap;
22import android.graphics.Bitmap.CompressFormat;
23import android.graphics.BitmapFactory;
24import android.graphics.Canvas;
25import android.graphics.Point;
26import android.graphics.RectF;
27import android.net.Uri;
28import android.os.AsyncTask;
29import android.os.Bundle;
30import android.os.Handler;
31import android.util.Log;
32import android.view.Display;
33import android.view.LayoutInflater;
34import android.view.View;
35import android.view.View.OnClickListener;
36import android.view.ViewGroup;
37import android.view.Window;
38import android.widget.Button;
39import android.widget.SeekBar;
40import android.widget.SeekBar.OnSeekBarChangeListener;
41
42import com.adobe.xmp.XMPException;
43import com.adobe.xmp.XMPMeta;
44import com.android.camera.CameraActivity;
45import com.android.camera.app.MediaSaver.OnMediaSavedListener;
46import com.android.camera.app.MediaSaver;
47import com.android.camera.exif.ExifInterface;
48import com.android.camera.tinyplanet.TinyPlanetPreview.PreviewSizeListener;
49import com.android.camera.util.XmpUtil;
50import com.android.camera2.R;
51
52import java.io.ByteArrayOutputStream;
53import java.io.FileNotFoundException;
54import java.io.IOException;
55import java.io.InputStream;
56import java.util.Date;
57import java.util.TimeZone;
58import java.util.concurrent.locks.Lock;
59import java.util.concurrent.locks.ReentrantLock;
60
61/**
62 * An activity that provides an editor UI to create a TinyPlanet image from a
63 * 360 degree stereographically mapped panoramic image.
64 */
65public class TinyPlanetFragment extends DialogFragment implements PreviewSizeListener {
66    /** Argument to tell the fragment the URI of the original panoramic image. */
67    public static final String ARGUMENT_URI = "uri";
68    /** Argument to tell the fragment the title of the original panoramic image. */
69    public static final String ARGUMENT_TITLE = "title";
70
71    public static final String CROPPED_AREA_IMAGE_WIDTH_PIXELS =
72            "CroppedAreaImageWidthPixels";
73    public static final String CROPPED_AREA_IMAGE_HEIGHT_PIXELS =
74            "CroppedAreaImageHeightPixels";
75    public static final String CROPPED_AREA_FULL_PANO_WIDTH_PIXELS =
76            "FullPanoWidthPixels";
77    public static final String CROPPED_AREA_FULL_PANO_HEIGHT_PIXELS =
78            "FullPanoHeightPixels";
79    public static final String CROPPED_AREA_LEFT =
80            "CroppedAreaLeftPixels";
81    public static final String CROPPED_AREA_TOP =
82            "CroppedAreaTopPixels";
83    public static final String GOOGLE_PANO_NAMESPACE = "http://ns.google.com/photos/1.0/panorama/";
84
85    private static final String TAG = "TinyPlanetActivity";
86    /** Delay between a value update and the renderer running. */
87    private static final int RENDER_DELAY_MILLIS = 50;
88    /** Filename prefix to prepend to the original name for the new file. */
89    private static final String FILENAME_PREFIX = "TINYPLANET_";
90
91    private Uri mSourceImageUri;
92    private TinyPlanetPreview mPreview;
93    private int mPreviewSizePx = 0;
94    private float mCurrentZoom = 0.5f;
95    private float mCurrentAngle = 0;
96    private ProgressDialog mDialog;
97
98    /**
99     * Lock for the result preview bitmap. We can't change it while we're trying
100     * to draw it.
101     */
102    private Lock mResultLock = new ReentrantLock();
103
104    /** The title of the original panoramic image. */
105    private String mOriginalTitle = "";
106
107    /** The padded source bitmap. */
108    private Bitmap mSourceBitmap;
109    /** The resulting preview bitmap. */
110    private Bitmap mResultBitmap;
111
112    /** Used to delay-post a tiny planet rendering task. */
113    private Handler mHandler = new Handler();
114    /** Whether rendering is in progress right now. */
115    private Boolean mRendering = false;
116    /**
117     * Whether we should render one more time after the current rendering run is
118     * done. This is needed when there was an update to the values during the
119     * current rendering.
120     */
121    private Boolean mRenderOneMore = false;
122
123    /** Tiny planet data plus size. */
124    private static final class TinyPlanetImage {
125        public final byte[] mJpegData;
126        public final int mSize;
127
128        public TinyPlanetImage(byte[] jpegData, int size) {
129            mJpegData = jpegData;
130            mSize = size;
131        }
132    }
133
134    /**
135     * Creates and executes a task to create a tiny planet with the current
136     * values.
137     */
138    private final Runnable mCreateTinyPlanetRunnable = new Runnable() {
139        @Override
140        public void run() {
141            synchronized (mRendering) {
142                if (mRendering) {
143                    mRenderOneMore = true;
144                    return;
145                }
146                mRendering = true;
147            }
148
149            (new AsyncTask<Void, Void, Void>() {
150                @Override
151                protected Void doInBackground(Void... params) {
152                    mResultLock.lock();
153                    try {
154                        if (mSourceBitmap == null || mResultBitmap == null) {
155                            return null;
156                        }
157
158                        int width = mSourceBitmap.getWidth();
159                        int height = mSourceBitmap.getHeight();
160                        TinyPlanetNative.process(mSourceBitmap, width, height, mResultBitmap,
161                                mPreviewSizePx,
162                                mCurrentZoom, mCurrentAngle);
163                    } finally {
164                        mResultLock.unlock();
165                    }
166                    return null;
167                }
168
169                protected void onPostExecute(Void result) {
170                    mPreview.setBitmap(mResultBitmap, mResultLock);
171                    synchronized (mRendering) {
172                        mRendering = false;
173                        if (mRenderOneMore) {
174                            mRenderOneMore = false;
175                            scheduleUpdate();
176                        }
177                    }
178                }
179            }).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
180        }
181    };
182
183    @Override
184    public void onCreate(Bundle savedInstanceState) {
185        super.onCreate(savedInstanceState);
186        setStyle(DialogFragment.STYLE_NORMAL, R.style.Theme_Camera);
187    }
188
189    @Override
190    public View onCreateView(LayoutInflater inflater, ViewGroup container,
191            Bundle savedInstanceState) {
192        getDialog().getWindow().requestFeature(Window.FEATURE_NO_TITLE);
193        getDialog().setCanceledOnTouchOutside(true);
194
195        View view = inflater.inflate(R.layout.tinyplanet_editor,
196                container, false);
197        mPreview = (TinyPlanetPreview) view.findViewById(R.id.preview);
198        mPreview.setPreviewSizeChangeListener(this);
199
200        // Zoom slider setup.
201        SeekBar zoomSlider = (SeekBar) view.findViewById(R.id.zoomSlider);
202        zoomSlider.setOnSeekBarChangeListener(new OnSeekBarChangeListener() {
203            @Override
204            public void onStopTrackingTouch(SeekBar seekBar) {
205                // Do nothing.
206            }
207
208            @Override
209            public void onStartTrackingTouch(SeekBar seekBar) {
210                // Do nothing.
211            }
212
213            @Override
214            public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
215                onZoomChange(progress);
216            }
217        });
218
219        // Rotation slider setup.
220        SeekBar angleSlider = (SeekBar) view.findViewById(R.id.angleSlider);
221        angleSlider.setOnSeekBarChangeListener(new OnSeekBarChangeListener() {
222            @Override
223            public void onStopTrackingTouch(SeekBar seekBar) {
224                // Do nothing.
225            }
226
227            @Override
228            public void onStartTrackingTouch(SeekBar seekBar) {
229                // Do nothing.
230            }
231
232            @Override
233            public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
234                onAngleChange(progress);
235            }
236        });
237
238        Button createButton = (Button) view.findViewById(R.id.creatTinyPlanetButton);
239        createButton.setOnClickListener(new OnClickListener() {
240            @Override
241            public void onClick(View v) {
242                onCreateTinyPlanet();
243            }
244        });
245
246        mOriginalTitle = getArguments().getString(ARGUMENT_TITLE);
247        mSourceImageUri = Uri.parse(getArguments().getString(ARGUMENT_URI));
248        mSourceBitmap = createPaddedSourceImage(mSourceImageUri, true);
249
250        if (mSourceBitmap == null) {
251            Log.e(TAG, "Could not decode source image.");
252            dismiss();
253        }
254        return view;
255    }
256
257    /**
258     * From the given URI this method creates a 360/180 padded image that is
259     * ready to be made a tiny planet.
260     */
261    private Bitmap createPaddedSourceImage(Uri sourceImageUri, boolean previewSize) {
262        InputStream is = getInputStream(sourceImageUri);
263        if (is == null) {
264            Log.e(TAG, "Could not create input stream for image.");
265            dismiss();
266        }
267        Bitmap sourceBitmap = BitmapFactory.decodeStream(is);
268
269        is = getInputStream(sourceImageUri);
270        XMPMeta xmp = XmpUtil.extractXMPMeta(is);
271
272        if (xmp != null) {
273            int size = previewSize ? getDisplaySize() : sourceBitmap.getWidth();
274            sourceBitmap = createPaddedBitmap(sourceBitmap, xmp, size);
275        }
276        return sourceBitmap;
277    }
278
279    /**
280     * Starts an asynchronous task to create a tiny planet. Once done, will add
281     * the new image to the filmstrip and dismisses the fragment.
282     */
283    private void onCreateTinyPlanet() {
284        // Make sure we stop rendering before we create the high-res tiny
285        // planet.
286        synchronized (mRendering) {
287            mRenderOneMore = false;
288        }
289
290        final String savingTinyPlanet = getActivity().getResources().getString(
291                R.string.saving_tiny_planet);
292        (new AsyncTask<Void, Void, TinyPlanetImage>() {
293            @Override
294            protected void onPreExecute() {
295                mDialog = ProgressDialog.show(getActivity(), null, savingTinyPlanet, true, false);
296            }
297
298            @Override
299            protected TinyPlanetImage doInBackground(Void... params) {
300                return createTinyPlanet();
301            }
302
303            @Override
304            protected void onPostExecute(TinyPlanetImage image) {
305                // Once created, store the new file and add it to the filmstrip.
306                final CameraActivity activity = (CameraActivity) getActivity();
307                MediaSaver mediaSaver = activity.getMediaSaver();
308                OnMediaSavedListener doneListener =
309                        new OnMediaSavedListener() {
310                            @Override
311                            public void onMediaSaved(Uri uri) {
312                                // Add the new photo to the filmstrip and exit
313                                // the fragment.
314                                activity.notifyNewMedia(uri);
315                                mDialog.dismiss();
316                                TinyPlanetFragment.this.dismiss();
317                            }
318                        };
319                String tinyPlanetTitle = FILENAME_PREFIX + mOriginalTitle;
320                mediaSaver.addImage(image.mJpegData, tinyPlanetTitle, (new Date()).getTime(),
321                        null,
322                        image.mSize, image.mSize, 0, null, doneListener, getActivity()
323                                .getContentResolver());
324            }
325        }).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
326    }
327
328    /**
329     * Creates the high quality tiny planet file and adds it to the media
330     * service. Don't call this on the UI thread.
331     */
332    private TinyPlanetImage createTinyPlanet() {
333        // Free some memory we don't need anymore as we're going to dimiss the
334        // fragment after the tiny planet creation.
335        mResultLock.lock();
336        try {
337            mResultBitmap.recycle();
338            mResultBitmap = null;
339            mSourceBitmap.recycle();
340            mSourceBitmap = null;
341        } finally {
342            mResultLock.unlock();
343        }
344
345        // Create a high-resolution padded image.
346        Bitmap sourceBitmap = createPaddedSourceImage(mSourceImageUri, false);
347        int width = sourceBitmap.getWidth();
348        int height = sourceBitmap.getHeight();
349
350        int outputSize = width / 2;
351        Bitmap resultBitmap = Bitmap.createBitmap(outputSize, outputSize,
352                Bitmap.Config.ARGB_8888);
353
354        TinyPlanetNative.process(sourceBitmap, width, height, resultBitmap,
355                outputSize, mCurrentZoom, mCurrentAngle);
356
357        // Free the sourceImage memory as we don't need it and we need memory
358        // for the JPEG bytes.
359        sourceBitmap.recycle();
360        sourceBitmap = null;
361
362        ByteArrayOutputStream jpeg = new ByteArrayOutputStream();
363        resultBitmap.compress(CompressFormat.JPEG, 100, jpeg);
364        return new TinyPlanetImage(addExif(jpeg.toByteArray()), outputSize);
365    }
366
367    /**
368     * Adds basic EXIF data to the tiny planet image so it an be rewritten
369     * later.
370     *
371     * @param jpeg the JPEG data of the tiny planet.
372     * @return The JPEG data containing basic EXIF.
373     */
374    private byte[] addExif(byte[] jpeg) {
375        ExifInterface exif = new ExifInterface();
376        exif.addDateTimeStampTag(ExifInterface.TAG_DATE_TIME, System.currentTimeMillis(),
377                TimeZone.getDefault());
378        ByteArrayOutputStream jpegOut = new ByteArrayOutputStream();
379        try {
380            exif.writeExif(jpeg, jpegOut);
381        } catch (IOException e) {
382            Log.e(TAG, "Could not write EXIF", e);
383        }
384        return jpegOut.toByteArray();
385    }
386
387    private int getDisplaySize() {
388        Display display = getActivity().getWindowManager().getDefaultDisplay();
389        Point size = new Point();
390        display.getSize(size);
391        return Math.min(size.x, size.y);
392    }
393
394    @Override
395    public void onSizeChanged(int sizePx) {
396        mPreviewSizePx = sizePx;
397        mResultLock.lock();
398        try {
399            if (mResultBitmap == null || mResultBitmap.getWidth() != sizePx
400                    || mResultBitmap.getHeight() != sizePx) {
401                if (mResultBitmap != null) {
402                    mResultBitmap.recycle();
403                }
404                mResultBitmap = Bitmap.createBitmap(mPreviewSizePx, mPreviewSizePx,
405                        Bitmap.Config.ARGB_8888);
406            }
407        } finally {
408            mResultLock.unlock();
409        }
410
411        // Run directly and on this thread directly.
412        mCreateTinyPlanetRunnable.run();
413    }
414
415    private void onZoomChange(int zoom) {
416        // 1000 needs to be in sync with the max values declared in the layout
417        // xml file.
418        mCurrentZoom = zoom / 1000f;
419        scheduleUpdate();
420    }
421
422    private void onAngleChange(int angle) {
423        mCurrentAngle = (float) Math.toRadians(angle);
424        scheduleUpdate();
425    }
426
427    /**
428     * Delay-post a new preview rendering run.
429     */
430    private void scheduleUpdate() {
431        mHandler.removeCallbacks(mCreateTinyPlanetRunnable);
432        mHandler.postDelayed(mCreateTinyPlanetRunnable, RENDER_DELAY_MILLIS);
433    }
434
435    private InputStream getInputStream(Uri uri) {
436        try {
437            return getActivity().getContentResolver().openInputStream(uri);
438        } catch (FileNotFoundException e) {
439            Log.e(TAG, "Could not load source image.", e);
440        }
441        return null;
442    }
443
444    /**
445     * To create a proper TinyPlanet, the input image must be 2:1 (360:180
446     * degrees). So if needed, we pad the source image with black.
447     */
448    private static Bitmap createPaddedBitmap(Bitmap bitmapIn, XMPMeta xmp, int intermediateWidth) {
449        try {
450            int croppedAreaWidth =
451                    getInt(xmp, CROPPED_AREA_IMAGE_WIDTH_PIXELS);
452            int croppedAreaHeight =
453                    getInt(xmp, CROPPED_AREA_IMAGE_HEIGHT_PIXELS);
454            int fullPanoWidth =
455                    getInt(xmp, CROPPED_AREA_FULL_PANO_WIDTH_PIXELS);
456            int fullPanoHeight =
457                    getInt(xmp, CROPPED_AREA_FULL_PANO_HEIGHT_PIXELS);
458            int left = getInt(xmp, CROPPED_AREA_LEFT);
459            int top = getInt(xmp, CROPPED_AREA_TOP);
460
461            if (fullPanoWidth == 0 || fullPanoHeight == 0) {
462                return bitmapIn;
463            }
464            // Make sure the intermediate image has the similar size to the
465            // input.
466            Bitmap paddedBitmap = null;
467            float scale = intermediateWidth / (float) fullPanoWidth;
468            while (paddedBitmap == null) {
469                try {
470                    paddedBitmap = Bitmap.createBitmap(
471                            (int) (fullPanoWidth * scale), (int) (fullPanoHeight * scale),
472                            Bitmap.Config.ARGB_8888);
473                } catch (OutOfMemoryError e) {
474                    System.gc();
475                    scale /= 2;
476                }
477            }
478            Canvas paddedCanvas = new Canvas(paddedBitmap);
479
480            int right = left + croppedAreaWidth;
481            int bottom = top + croppedAreaHeight;
482            RectF destRect = new RectF(left * scale, top * scale, right * scale, bottom * scale);
483            paddedCanvas.drawBitmap(bitmapIn, null, destRect, null);
484            return paddedBitmap;
485        } catch (XMPException ex) {
486            // Do nothing, just use mSourceBitmap as is.
487        }
488        return bitmapIn;
489    }
490
491    private static int getInt(XMPMeta xmp, String key) throws XMPException {
492        if (xmp.doesPropertyExist(GOOGLE_PANO_NAMESPACE, key)) {
493            return xmp.getPropertyInteger(GOOGLE_PANO_NAMESPACE, key);
494        } else {
495            return 0;
496        }
497    }
498}
499