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.view.Display;
32import android.view.LayoutInflater;
33import android.view.View;
34import android.view.View.OnClickListener;
35import android.view.ViewGroup;
36import android.view.Window;
37import android.widget.Button;
38import android.widget.SeekBar;
39import android.widget.SeekBar.OnSeekBarChangeListener;
40
41import com.adobe.xmp.XMPException;
42import com.adobe.xmp.XMPMeta;
43import com.android.camera.CameraActivity;
44import com.android.camera.app.CameraServicesImpl;
45import com.android.camera.app.MediaSaver;
46import com.android.camera.app.MediaSaver.OnMediaSavedListener;
47import com.android.camera.debug.Log;
48import com.android.camera.exif.ExifInterface;
49import com.android.camera.tinyplanet.TinyPlanetPreview.PreviewSizeListener;
50import com.android.camera.util.XmpUtil;
51import com.android.camera2.R;
52
53import java.io.ByteArrayOutputStream;
54import java.io.FileNotFoundException;
55import java.io.IOException;
56import java.io.InputStream;
57import java.util.Date;
58import java.util.TimeZone;
59import java.util.concurrent.locks.Lock;
60import java.util.concurrent.locks.ReentrantLock;
61
62/**
63 * An activity that provides an editor UI to create a TinyPlanet image from a
64 * 360 degree stereographically mapped panoramic image.
65 */
66public class TinyPlanetFragment extends DialogFragment implements PreviewSizeListener {
67    /** Argument to tell the fragment the URI of the original panoramic image. */
68    public static final String ARGUMENT_URI = "uri";
69    /** Argument to tell the fragment the title of the original panoramic image. */
70    public static final String ARGUMENT_TITLE = "title";
71
72    public static final String CROPPED_AREA_IMAGE_WIDTH_PIXELS =
73            "CroppedAreaImageWidthPixels";
74    public static final String CROPPED_AREA_IMAGE_HEIGHT_PIXELS =
75            "CroppedAreaImageHeightPixels";
76    public static final String CROPPED_AREA_FULL_PANO_WIDTH_PIXELS =
77            "FullPanoWidthPixels";
78    public static final String CROPPED_AREA_FULL_PANO_HEIGHT_PIXELS =
79            "FullPanoHeightPixels";
80    public static final String CROPPED_AREA_LEFT =
81            "CroppedAreaLeftPixels";
82    public static final String CROPPED_AREA_TOP =
83            "CroppedAreaTopPixels";
84    public static final String GOOGLE_PANO_NAMESPACE = "http://ns.google.com/photos/1.0/panorama/";
85
86    private static final Log.Tag TAG = new Log.Tag("TinyPlanetActivity");
87    /** Delay between a value update and the renderer running. */
88    private static final int RENDER_DELAY_MILLIS = 50;
89    /** Filename prefix to prepend to the original name for the new file. */
90    private static final String FILENAME_PREFIX = "TINYPLANET_";
91
92    private Uri mSourceImageUri;
93    private TinyPlanetPreview mPreview;
94    private int mPreviewSizePx = 0;
95    private float mCurrentZoom = 0.5f;
96    private float mCurrentAngle = 0;
97    private ProgressDialog mDialog;
98
99    /**
100     * Lock for the result preview bitmap. We can't change it while we're trying
101     * to draw it.
102     */
103    private final Lock mResultLock = new ReentrantLock();
104
105    /** The title of the original panoramic image. */
106    private String mOriginalTitle = "";
107
108    /** The padded source bitmap. */
109    private Bitmap mSourceBitmap;
110    /** The resulting preview bitmap. */
111    private Bitmap mResultBitmap;
112
113    /** Used to delay-post a tiny planet rendering task. */
114    private final Handler mHandler = new Handler();
115    /** Whether rendering is in progress right now. */
116    private Boolean mRendering = false;
117    /**
118     * Whether we should render one more time after the current rendering run is
119     * done. This is needed when there was an update to the values during the
120     * current rendering.
121     */
122    private Boolean mRenderOneMore = false;
123
124    /** Tiny planet data plus size. */
125    private static final class TinyPlanetImage {
126        public final byte[] mJpegData;
127        public final int mSize;
128
129        public TinyPlanetImage(byte[] jpegData, int size) {
130            mJpegData = jpegData;
131            mSize = size;
132        }
133    }
134
135    /**
136     * Creates and executes a task to create a tiny planet with the current
137     * values.
138     */
139    private final Runnable mCreateTinyPlanetRunnable = new Runnable() {
140        @Override
141        public void run() {
142            synchronized (mRendering) {
143                if (mRendering) {
144                    mRenderOneMore = true;
145                    return;
146                }
147                mRendering = true;
148            }
149
150            (new AsyncTask<Void, Void, Void>() {
151                @Override
152                protected Void doInBackground(Void... params) {
153                    mResultLock.lock();
154                    try {
155                        if (mSourceBitmap == null || mResultBitmap == null) {
156                            return null;
157                        }
158                        int width = mSourceBitmap.getWidth();
159                        int height = mSourceBitmap.getHeight();
160                        TinyPlanetNative.process(mSourceBitmap, width, height, mResultBitmap,
161                                mPreviewSizePx, mCurrentZoom, mCurrentAngle);
162                    } finally {
163                        mResultLock.unlock();
164                    }
165                    return null;
166                }
167
168                @Override
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 createFinalTinyPlanet();
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 = CameraServicesImpl.instance().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);
323            }
324        }).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
325    }
326
327    /**
328     * Creates the high quality tiny planet file and adds it to the media
329     * service. Don't call this on the UI thread.
330     */
331    private TinyPlanetImage createFinalTinyPlanet() {
332        // Free some memory we don't need anymore as we're going to dimiss the
333        // fragment after the tiny planet creation.
334        mResultLock.lock();
335        try {
336            mResultBitmap.recycle();
337            mResultBitmap = null;
338            mSourceBitmap.recycle();
339            mSourceBitmap = null;
340        } finally {
341            mResultLock.unlock();
342        }
343
344        // Create a high-resolution padded image.
345        Bitmap sourceBitmap = createPaddedSourceImage(mSourceImageUri, false);
346        int width = sourceBitmap.getWidth();
347        int height = sourceBitmap.getHeight();
348
349        int outputSize = width / 2;
350        Bitmap resultBitmap = Bitmap.createBitmap(outputSize, outputSize,
351                Bitmap.Config.ARGB_8888);
352
353        TinyPlanetNative.process(sourceBitmap, width, height, resultBitmap,
354                outputSize, mCurrentZoom, mCurrentAngle);
355
356        // Free the sourceImage memory as we don't need it and we need memory
357        // for the JPEG bytes.
358        sourceBitmap.recycle();
359        sourceBitmap = null;
360
361        ByteArrayOutputStream jpeg = new ByteArrayOutputStream();
362        resultBitmap.compress(CompressFormat.JPEG, 100, jpeg);
363        return new TinyPlanetImage(addExif(jpeg.toByteArray()), outputSize);
364    }
365
366    /**
367     * Adds basic EXIF data to the tiny planet image so it an be rewritten
368     * later.
369     *
370     * @param jpeg the JPEG data of the tiny planet.
371     * @return The JPEG data containing basic EXIF.
372     */
373    private byte[] addExif(byte[] jpeg) {
374        ExifInterface exif = new ExifInterface();
375        exif.addDateTimeStampTag(ExifInterface.TAG_DATE_TIME, System.currentTimeMillis(),
376                TimeZone.getDefault());
377        ByteArrayOutputStream jpegOut = new ByteArrayOutputStream();
378        try {
379            exif.writeExif(jpeg, jpegOut);
380        } catch (IOException e) {
381            Log.e(TAG, "Could not write EXIF", e);
382        }
383        return jpegOut.toByteArray();
384    }
385
386    private int getDisplaySize() {
387        Display display = getActivity().getWindowManager().getDefaultDisplay();
388        Point size = new Point();
389        display.getSize(size);
390        return Math.min(size.x, size.y);
391    }
392
393    @Override
394    public void onSizeChanged(int sizePx) {
395        mPreviewSizePx = sizePx;
396        mResultLock.lock();
397        try {
398            if (mResultBitmap == null || mResultBitmap.getWidth() != sizePx
399                    || mResultBitmap.getHeight() != sizePx) {
400                if (mResultBitmap != null) {
401                    mResultBitmap.recycle();
402                }
403                mResultBitmap = Bitmap.createBitmap(mPreviewSizePx, mPreviewSizePx,
404                        Bitmap.Config.ARGB_8888);
405            }
406        } finally {
407            mResultLock.unlock();
408        }
409        scheduleUpdate();
410    }
411
412    private void onZoomChange(int zoom) {
413        // 1000 needs to be in sync with the max values declared in the layout
414        // xml file.
415        mCurrentZoom = zoom / 1000f;
416        scheduleUpdate();
417    }
418
419    private void onAngleChange(int angle) {
420        mCurrentAngle = (float) Math.toRadians(angle);
421        scheduleUpdate();
422    }
423
424    /**
425     * Delay-post a new preview rendering run.
426     */
427    private void scheduleUpdate() {
428        mHandler.removeCallbacks(mCreateTinyPlanetRunnable);
429        mHandler.postDelayed(mCreateTinyPlanetRunnable, RENDER_DELAY_MILLIS);
430    }
431
432    private InputStream getInputStream(Uri uri) {
433        try {
434            return getActivity().getContentResolver().openInputStream(uri);
435        } catch (FileNotFoundException e) {
436            Log.e(TAG, "Could not load source image.", e);
437        }
438        return null;
439    }
440
441    /**
442     * To create a proper TinyPlanet, the input image must be 2:1 (360:180
443     * degrees). So if needed, we pad the source image with black.
444     */
445    private static Bitmap createPaddedBitmap(Bitmap bitmapIn, XMPMeta xmp, int intermediateWidth) {
446        try {
447            int croppedAreaWidth =
448                    getInt(xmp, CROPPED_AREA_IMAGE_WIDTH_PIXELS);
449            int croppedAreaHeight =
450                    getInt(xmp, CROPPED_AREA_IMAGE_HEIGHT_PIXELS);
451            int fullPanoWidth =
452                    getInt(xmp, CROPPED_AREA_FULL_PANO_WIDTH_PIXELS);
453            int fullPanoHeight =
454                    getInt(xmp, CROPPED_AREA_FULL_PANO_HEIGHT_PIXELS);
455            int left = getInt(xmp, CROPPED_AREA_LEFT);
456            int top = getInt(xmp, CROPPED_AREA_TOP);
457
458            if (fullPanoWidth == 0 || fullPanoHeight == 0) {
459                return bitmapIn;
460            }
461            // Make sure the intermediate image has the similar size to the
462            // input.
463            Bitmap paddedBitmap = null;
464            float scale = intermediateWidth / (float) fullPanoWidth;
465            while (paddedBitmap == null) {
466                try {
467                    paddedBitmap = Bitmap.createBitmap(
468                            (int) (fullPanoWidth * scale), (int) (fullPanoHeight * scale),
469                            Bitmap.Config.ARGB_8888);
470                } catch (OutOfMemoryError e) {
471                    System.gc();
472                    scale /= 2;
473                }
474            }
475            Canvas paddedCanvas = new Canvas(paddedBitmap);
476
477            int right = left + croppedAreaWidth;
478            int bottom = top + croppedAreaHeight;
479            RectF destRect = new RectF(left * scale, top * scale, right * scale, bottom * scale);
480            paddedCanvas.drawBitmap(bitmapIn, null, destRect, null);
481            return paddedBitmap;
482        } catch (XMPException ex) {
483            // Do nothing, just use mSourceBitmap as is.
484        }
485        return bitmapIn;
486    }
487
488    private static int getInt(XMPMeta xmp, String key) throws XMPException {
489        if (xmp.doesPropertyExist(GOOGLE_PANO_NAMESPACE, key)) {
490            return xmp.getPropertyInteger(GOOGLE_PANO_NAMESPACE, key);
491        } else {
492            return 0;
493        }
494    }
495}
496