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.MediaSaveService;
46import com.android.camera.MediaSaveService.OnMediaSavedListener;
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.io.OutputStream;
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 String 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 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 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
159                        int width = mSourceBitmap.getWidth();
160                        int height = mSourceBitmap.getHeight();
161                        TinyPlanetNative.process(mSourceBitmap, width, height, mResultBitmap,
162                                mPreviewSizePx,
163                                mCurrentZoom, mCurrentAngle);
164                    } finally {
165                        mResultLock.unlock();
166                    }
167                    return null;
168                }
169
170                protected void onPostExecute(Void result) {
171                    mPreview.setBitmap(mResultBitmap, mResultLock);
172                    synchronized (mRendering) {
173                        mRendering = false;
174                        if (mRenderOneMore) {
175                            mRenderOneMore = false;
176                            scheduleUpdate();
177                        }
178                    }
179                }
180            }).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
181        }
182    };
183
184    @Override
185    public void onCreate(Bundle savedInstanceState) {
186        super.onCreate(savedInstanceState);
187        setStyle(DialogFragment.STYLE_NORMAL, R.style.Theme_Camera);
188    }
189
190    @Override
191    public View onCreateView(LayoutInflater inflater, ViewGroup container,
192            Bundle savedInstanceState) {
193        getDialog().getWindow().requestFeature(Window.FEATURE_NO_TITLE);
194        getDialog().setCanceledOnTouchOutside(true);
195
196        View view = inflater.inflate(R.layout.tinyplanet_editor,
197                container, false);
198        mPreview = (TinyPlanetPreview) view.findViewById(R.id.preview);
199        mPreview.setPreviewSizeChangeListener(this);
200
201        // Zoom slider setup.
202        SeekBar zoomSlider = (SeekBar) view.findViewById(R.id.zoomSlider);
203        zoomSlider.setOnSeekBarChangeListener(new OnSeekBarChangeListener() {
204            @Override
205            public void onStopTrackingTouch(SeekBar seekBar) {
206                // Do nothing.
207            }
208
209            @Override
210            public void onStartTrackingTouch(SeekBar seekBar) {
211                // Do nothing.
212            }
213
214            @Override
215            public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
216                onZoomChange(progress);
217            }
218        });
219
220        // Rotation slider setup.
221        SeekBar angleSlider = (SeekBar) view.findViewById(R.id.angleSlider);
222        angleSlider.setOnSeekBarChangeListener(new OnSeekBarChangeListener() {
223            @Override
224            public void onStopTrackingTouch(SeekBar seekBar) {
225                // Do nothing.
226            }
227
228            @Override
229            public void onStartTrackingTouch(SeekBar seekBar) {
230                // Do nothing.
231            }
232
233            @Override
234            public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
235                onAngleChange(progress);
236            }
237        });
238
239        Button createButton = (Button) view.findViewById(R.id.creatTinyPlanetButton);
240        createButton.setOnClickListener(new OnClickListener() {
241            @Override
242            public void onClick(View v) {
243                onCreateTinyPlanet();
244            }
245        });
246
247        mOriginalTitle = getArguments().getString(ARGUMENT_TITLE);
248        mSourceImageUri = Uri.parse(getArguments().getString(ARGUMENT_URI));
249        mSourceBitmap = createPaddedSourceImage(mSourceImageUri, true);
250
251        if (mSourceBitmap == null) {
252            Log.e(TAG, "Could not decode source image.");
253            dismiss();
254        }
255        return view;
256    }
257
258    /**
259     * From the given URI this method creates a 360/180 padded image that is
260     * ready to be made a tiny planet.
261     */
262    private Bitmap createPaddedSourceImage(Uri sourceImageUri, boolean previewSize) {
263        InputStream is = getInputStream(sourceImageUri);
264        if (is == null) {
265            Log.e(TAG, "Could not create input stream for image.");
266            dismiss();
267        }
268        Bitmap sourceBitmap = BitmapFactory.decodeStream(is);
269
270        is = getInputStream(sourceImageUri);
271        XMPMeta xmp = XmpUtil.extractXMPMeta(is);
272
273        if (xmp != null) {
274            int size = previewSize ? getDisplaySize() : sourceBitmap.getWidth();
275            sourceBitmap = createPaddedBitmap(sourceBitmap, xmp, size);
276        }
277        return sourceBitmap;
278    }
279
280    /**
281     * Starts an asynchronous task to create a tiny planet. Once done, will add
282     * the new image to the filmstrip and dismisses the fragment.
283     */
284    private void onCreateTinyPlanet() {
285        // Make sure we stop rendering before we create the high-res tiny
286        // planet.
287        synchronized (mRendering) {
288            mRenderOneMore = false;
289        }
290
291        final String savingTinyPlanet = getActivity().getResources().getString(
292                R.string.saving_tiny_planet);
293        (new AsyncTask<Void, Void, TinyPlanetImage>() {
294            @Override
295            protected void onPreExecute() {
296                mDialog = ProgressDialog.show(getActivity(), null, savingTinyPlanet, true, false);
297            }
298
299            @Override
300            protected TinyPlanetImage doInBackground(Void... params) {
301                return createTinyPlanet();
302            }
303
304            @Override
305            protected void onPostExecute(TinyPlanetImage image) {
306                // Once created, store the new file and add it to the filmstrip.
307                final CameraActivity activity = (CameraActivity) getActivity();
308                MediaSaveService mediaSaveService = activity.getMediaSaveService();
309                OnMediaSavedListener doneListener =
310                        new OnMediaSavedListener() {
311                            @Override
312                            public void onMediaSaved(Uri uri) {
313                                // Add the new photo to the filmstrip and exit
314                                // the fragment.
315                                activity.notifyNewMedia(uri);
316                                mDialog.dismiss();
317                                TinyPlanetFragment.this.dismiss();
318                            }
319                        };
320                String tinyPlanetTitle = FILENAME_PREFIX + mOriginalTitle;
321                mediaSaveService.addImage(image.mJpegData, tinyPlanetTitle, (new Date()).getTime(),
322                        null,
323                        image.mSize, image.mSize, 0, null, doneListener, getActivity()
324                                .getContentResolver());
325            }
326        }).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
327    }
328
329    /**
330     * Creates the high quality tiny planet file and adds it to the media
331     * service. Don't call this on the UI thread.
332     */
333    private TinyPlanetImage createTinyPlanet() {
334        // Free some memory we don't need anymore as we're going to dimiss the
335        // fragment after the tiny planet creation.
336        mResultLock.lock();
337        try {
338            mResultBitmap.recycle();
339            mResultBitmap = null;
340            mSourceBitmap.recycle();
341            mSourceBitmap = null;
342        } finally {
343            mResultLock.unlock();
344        }
345
346        // Create a high-resolution padded image.
347        Bitmap sourceBitmap = createPaddedSourceImage(mSourceImageUri, false);
348        int width = sourceBitmap.getWidth();
349        int height = sourceBitmap.getHeight();
350
351        int outputSize = width / 2;
352        Bitmap resultBitmap = Bitmap.createBitmap(outputSize, outputSize,
353                Bitmap.Config.ARGB_8888);
354
355        TinyPlanetNative.process(sourceBitmap, width, height, resultBitmap,
356                outputSize, mCurrentZoom, mCurrentAngle);
357
358        // Free the sourceImage memory as we don't need it and we need memory
359        // for the JPEG bytes.
360        sourceBitmap.recycle();
361        sourceBitmap = null;
362
363        ByteArrayOutputStream jpeg = new ByteArrayOutputStream();
364        resultBitmap.compress(CompressFormat.JPEG, 100, jpeg);
365        return new TinyPlanetImage(addExif(jpeg.toByteArray()), outputSize);
366    }
367
368    /**
369     * Adds basic EXIF data to the tiny planet image so it an be rewritten
370     * later.
371     *
372     * @param jpeg the JPEG data of the tiny planet.
373     * @return The JPEG data containing basic EXIF.
374     */
375    private byte[] addExif(byte[] jpeg) {
376        ExifInterface exif = new ExifInterface();
377        exif.addDateTimeStampTag(ExifInterface.TAG_DATE_TIME, System.currentTimeMillis(),
378                TimeZone.getDefault());
379        ByteArrayOutputStream jpegOut = new ByteArrayOutputStream();
380        try {
381            exif.writeExif(jpeg, jpegOut);
382        } catch (IOException e) {
383            Log.e(TAG, "Could not write EXIF", e);
384        }
385        return jpegOut.toByteArray();
386    }
387
388    private int getDisplaySize() {
389        Display display = getActivity().getWindowManager().getDefaultDisplay();
390        Point size = new Point();
391        display.getSize(size);
392        return Math.min(size.x, size.y);
393    }
394
395    @Override
396    public void onSizeChanged(int sizePx) {
397        mPreviewSizePx = sizePx;
398        mResultLock.lock();
399        try {
400            if (mResultBitmap == null || mResultBitmap.getWidth() != sizePx
401                    || mResultBitmap.getHeight() != sizePx) {
402                if (mResultBitmap != null) {
403                    mResultBitmap.recycle();
404                }
405                mResultBitmap = Bitmap.createBitmap(mPreviewSizePx, mPreviewSizePx,
406                        Bitmap.Config.ARGB_8888);
407            }
408        } finally {
409            mResultLock.unlock();
410        }
411
412        // Run directly and on this thread directly.
413        mCreateTinyPlanetRunnable.run();
414    }
415
416    private void onZoomChange(int zoom) {
417        // 1000 needs to be in sync with the max values declared in the layout
418        // xml file.
419        mCurrentZoom = zoom / 1000f;
420        scheduleUpdate();
421    }
422
423    private void onAngleChange(int angle) {
424        mCurrentAngle = (float) Math.toRadians(angle);
425        scheduleUpdate();
426    }
427
428    /**
429     * Delay-post a new preview rendering run.
430     */
431    private void scheduleUpdate() {
432        mHandler.removeCallbacks(mCreateTinyPlanetRunnable);
433        mHandler.postDelayed(mCreateTinyPlanetRunnable, RENDER_DELAY_MILLIS);
434    }
435
436    private InputStream getInputStream(Uri uri) {
437        try {
438            return getActivity().getContentResolver().openInputStream(uri);
439        } catch (FileNotFoundException e) {
440            Log.e(TAG, "Could not load source image.", e);
441        }
442        return null;
443    }
444
445    /**
446     * To create a proper TinyPlanet, the input image must be 2:1 (360:180
447     * degrees). So if needed, we pad the source image with black.
448     */
449    private static Bitmap createPaddedBitmap(Bitmap bitmapIn, XMPMeta xmp, int intermediateWidth) {
450        try {
451            int croppedAreaWidth =
452                    getInt(xmp, CROPPED_AREA_IMAGE_WIDTH_PIXELS);
453            int croppedAreaHeight =
454                    getInt(xmp, CROPPED_AREA_IMAGE_HEIGHT_PIXELS);
455            int fullPanoWidth =
456                    getInt(xmp, CROPPED_AREA_FULL_PANO_WIDTH_PIXELS);
457            int fullPanoHeight =
458                    getInt(xmp, CROPPED_AREA_FULL_PANO_HEIGHT_PIXELS);
459            int left = getInt(xmp, CROPPED_AREA_LEFT);
460            int top = getInt(xmp, CROPPED_AREA_TOP);
461
462            if (fullPanoWidth == 0 || fullPanoHeight == 0) {
463                return bitmapIn;
464            }
465            // Make sure the intermediate image has the similar size to the
466            // input.
467            Bitmap paddedBitmap = null;
468            float scale = intermediateWidth / (float) fullPanoWidth;
469            while (paddedBitmap == null) {
470                try {
471                    paddedBitmap = Bitmap.createBitmap(
472                            (int) (fullPanoWidth * scale), (int) (fullPanoHeight * scale),
473                            Bitmap.Config.ARGB_8888);
474                } catch (OutOfMemoryError e) {
475                    System.gc();
476                    scale /= 2;
477                }
478            }
479            Canvas paddedCanvas = new Canvas(paddedBitmap);
480
481            int right = left + croppedAreaWidth;
482            int bottom = top + croppedAreaHeight;
483            RectF destRect = new RectF(left * scale, top * scale, right * scale, bottom * scale);
484            paddedCanvas.drawBitmap(bitmapIn, null, destRect, null);
485            return paddedBitmap;
486        } catch (XMPException ex) {
487            // Do nothing, just use mSourceBitmap as is.
488        }
489        return bitmapIn;
490    }
491
492    private static int getInt(XMPMeta xmp, String key) throws XMPException {
493        if (xmp.doesPropertyExist(GOOGLE_PANO_NAMESPACE, key)) {
494            return xmp.getPropertyInteger(GOOGLE_PANO_NAMESPACE, key);
495        } else {
496            return 0;
497        }
498    }
499}
500