1/*
2 * Copyright (C) 2008 Esmertec AG.
3 * Copyright (C) 2008 The Android Open Source Project
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 *      http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18package com.android.mms.ui;
19
20import android.content.ContentResolver;
21import android.content.Context;
22import android.content.UriMatcher;
23import android.database.Cursor;
24import android.database.sqlite.SQLiteException;
25import android.database.sqlite.SqliteWrapper;
26import android.graphics.Bitmap;
27import android.graphics.Bitmap.CompressFormat;
28import android.graphics.BitmapFactory;
29import android.graphics.Matrix;
30import android.net.Uri;
31import android.provider.MediaStore;
32import android.provider.MediaStore.Images;
33import android.provider.Telephony.Mms.Part;
34import android.text.TextUtils;
35import android.util.Log;
36import android.webkit.MimeTypeMap;
37
38import com.android.mms.LogTag;
39import com.android.mms.exif.ExifInterface;
40import com.android.mms.model.ImageModel;
41import com.google.android.mms.ContentType;
42import com.google.android.mms.pdu.PduPart;
43
44import java.io.ByteArrayOutputStream;
45import java.io.FileNotFoundException;
46import java.io.IOException;
47import java.io.InputStream;
48
49public class UriImage {
50    private static final String TAG = LogTag.TAG;
51    private static final boolean DEBUG = false;
52    private static final boolean LOCAL_LOGV = false;
53    private static final int MMS_PART_ID = 12;
54    private static final UriMatcher sURLMatcher = new UriMatcher(UriMatcher.NO_MATCH);
55    static {
56        sURLMatcher.addURI("mms", "part/#", MMS_PART_ID);
57    }
58
59    private final Context mContext;
60    private final Uri mUri;
61    private String mContentType;
62    private String mPath;
63    private String mSrc;
64    private int mWidth;
65    private int mHeight;
66
67    public UriImage(Context context, Uri uri) {
68        if ((null == context) || (null == uri)) {
69            throw new IllegalArgumentException();
70        }
71
72        String scheme = uri.getScheme();
73        if (scheme.equals("content")) {
74            initFromContentUri(context, uri);
75        } else if (uri.getScheme().equals("file")) {
76            initFromFile(context, uri);
77        }
78
79        mContext = context;
80        mUri = uri;
81
82        decodeBoundsInfo();
83
84        if (LOCAL_LOGV) {
85            Log.v(TAG, "UriImage uri: " + uri + " mPath: " + mPath + " mWidth: " + mWidth +
86                    " mHeight: " + mHeight);
87        }
88    }
89
90    private void initFromFile(Context context, Uri uri) {
91        mPath = uri.getPath();
92        MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
93        String extension = MimeTypeMap.getFileExtensionFromUrl(mPath);
94        if (TextUtils.isEmpty(extension)) {
95            // getMimeTypeFromExtension() doesn't handle spaces in filenames nor can it handle
96            // urlEncoded strings. Let's try one last time at finding the extension.
97            int dotPos = mPath.lastIndexOf('.');
98            if (0 <= dotPos) {
99                extension = mPath.substring(dotPos + 1);
100            }
101        }
102        mContentType = mimeTypeMap.getMimeTypeFromExtension(extension);
103        // It's ok if mContentType is null. Eventually we'll show a toast telling the
104        // user the picture couldn't be attached.
105
106        buildSrcFromPath();
107    }
108
109    private void buildSrcFromPath() {
110        mSrc = mPath.substring(mPath.lastIndexOf('/') + 1);
111
112        if(mSrc.startsWith(".") && mSrc.length() > 1) {
113            mSrc = mSrc.substring(1);
114        }
115
116        // Some MMSCs appear to have problems with filenames
117        // containing a space.  So just replace them with
118        // underscores in the name, which is typically not
119        // visible to the user anyway.
120        mSrc = mSrc.replace(' ', '_');
121    }
122
123    private void initFromContentUri(Context context, Uri uri) {
124        ContentResolver resolver = context.getContentResolver();
125        Cursor c = SqliteWrapper.query(context, resolver,
126                            uri, null, null, null, null);
127
128        mSrc = null;
129        if (c == null) {
130            throw new IllegalArgumentException(
131                    "Query on " + uri + " returns null result.");
132        }
133
134        try {
135            if ((c.getCount() != 1) || !c.moveToFirst()) {
136                throw new IllegalArgumentException(
137                        "Query on " + uri + " returns 0 or multiple rows.");
138            }
139
140            String filePath;
141            if (ImageModel.isMmsUri(uri)) {
142                filePath = c.getString(c.getColumnIndexOrThrow(Part.FILENAME));
143                if (TextUtils.isEmpty(filePath)) {
144                    filePath = c.getString(
145                            c.getColumnIndexOrThrow(Part._DATA));
146                }
147                mContentType = c.getString(
148                        c.getColumnIndexOrThrow(Part.CONTENT_TYPE));
149            } else {
150                filePath = uri.getPath();
151                try {
152                    mContentType = c.getString(
153                            c.getColumnIndexOrThrow(Images.Media.MIME_TYPE)); // mime_type
154                } catch (IllegalArgumentException e) {
155                    try {
156                        mContentType = c.getString(c.getColumnIndexOrThrow("mimetype"));
157                    } catch (IllegalArgumentException ex) {
158                        mContentType = resolver.getType(uri);
159                        Log.v(TAG, "initFromContentUri: " + uri + ", getType => " + mContentType);
160                    }
161                }
162
163                // use the original filename if possible
164                int nameIndex = c.getColumnIndex(Images.Media.DISPLAY_NAME);
165                if (nameIndex != -1) {
166                    mSrc = c.getString(nameIndex);
167                    if (!TextUtils.isEmpty(mSrc)) {
168                        // Some MMSCs appear to have problems with filenames
169                        // containing a space.  So just replace them with
170                        // underscores in the name, which is typically not
171                        // visible to the user anyway.
172                        mSrc = mSrc.replace(' ', '_');
173                    } else {
174                        mSrc = null;
175                    }
176                }
177            }
178            mPath = filePath;
179            if (mSrc == null) {
180                buildSrcFromPath();
181            }
182        } catch (IllegalArgumentException e) {
183            Log.e(TAG, "initFromContentUri couldn't load image uri: " + uri, e);
184        } finally {
185            c.close();
186        }
187    }
188
189    private void decodeBoundsInfo() {
190        InputStream input = null;
191        try {
192            input = mContext.getContentResolver().openInputStream(mUri);
193            BitmapFactory.Options opt = new BitmapFactory.Options();
194            opt.inJustDecodeBounds = true;
195            BitmapFactory.decodeStream(input, null, opt);
196            mWidth = opt.outWidth;
197            mHeight = opt.outHeight;
198        } catch (FileNotFoundException e) {
199            // Ignore
200            Log.e(TAG, "IOException caught while opening stream", e);
201        } finally {
202            if (null != input) {
203                try {
204                    input.close();
205                } catch (IOException e) {
206                    // Ignore
207                    Log.e(TAG, "IOException caught while closing stream", e);
208                }
209            }
210        }
211    }
212
213    public String getContentType() {
214        return mContentType;
215    }
216
217    public String getSrc() {
218        return mSrc;
219    }
220
221    public String getPath() {
222        return mPath;
223    }
224
225    public int getWidth() {
226        return mWidth;
227    }
228
229    public int getHeight() {
230        return mHeight;
231    }
232
233    /**
234     * Get a version of this image resized to fit the given dimension and byte-size limits. Note
235     * that the content type of the resulting PduPart may not be the same as the content type of
236     * this UriImage; always call {@link PduPart#getContentType()} to get the new content type.
237     *
238     * @param widthLimit The width limit, in pixels
239     * @param heightLimit The height limit, in pixels
240     * @param byteLimit The binary size limit, in bytes
241     * @return A new PduPart containing the resized image data
242     */
243    public PduPart getResizedImageAsPart(int widthLimit, int heightLimit, int byteLimit) {
244        PduPart part = new PduPart();
245
246        byte[] data =  getResizedImageData(mWidth, mHeight,
247                widthLimit, heightLimit, byteLimit, mUri, mContext);
248        if (data == null) {
249            if (LOCAL_LOGV) {
250                Log.v(TAG, "Resize image failed.");
251            }
252            return null;
253        }
254
255        part.setData(data);
256        // getResizedImageData ALWAYS compresses to JPEG, regardless of the original content type
257        part.setContentType(ContentType.IMAGE_JPEG.getBytes());
258
259        return part;
260    }
261
262    private static final int NUMBER_OF_RESIZE_ATTEMPTS = 4;
263
264    /**
265     * Resize and recompress the image such that it fits the given limits. The resulting byte
266     * array contains an image in JPEG format, regardless of the original image's content type.
267     * @param widthLimit The width limit, in pixels
268     * @param heightLimit The height limit, in pixels
269     * @param byteLimit The binary size limit, in bytes
270     * @return A resized/recompressed version of this image, in JPEG format
271     */
272    public static byte[] getResizedImageData(int width, int height,
273            int widthLimit, int heightLimit, int byteLimit, Uri uri, Context context) {
274        int outWidth = width;
275        int outHeight = height;
276
277        float scaleFactor = 1.F;
278        while ((outWidth * scaleFactor > widthLimit) || (outHeight * scaleFactor > heightLimit)) {
279            scaleFactor *= .75F;
280        }
281
282        int orientation = getOrientation(context, uri);
283
284        if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
285            Log.v(TAG, "getResizedBitmap: wlimit=" + widthLimit +
286                    ", hlimit=" + heightLimit + ", sizeLimit=" + byteLimit +
287                    ", width=" + width + ", height=" + height +
288                    ", initialScaleFactor=" + scaleFactor +
289                    ", uri=" + uri +
290                    ", orientation=" + orientation);
291        }
292
293        InputStream input = null;
294        ByteArrayOutputStream os = null;
295        try {
296            int attempts = 1;
297            int sampleSize = 1;
298            BitmapFactory.Options options = new BitmapFactory.Options();
299            int quality = MessageUtils.IMAGE_COMPRESSION_QUALITY;
300            Bitmap b = null;
301
302            // In this loop, attempt to decode the stream with the best possible subsampling (we
303            // start with 1, which means no subsampling - get the original content) without running
304            // out of memory.
305            do {
306                input = context.getContentResolver().openInputStream(uri);
307                options.inSampleSize = sampleSize;
308                try {
309                    b = BitmapFactory.decodeStream(input, null, options);
310                    if (b == null) {
311                        return null;    // Couldn't decode and it wasn't because of an exception,
312                                        // bail.
313                    }
314                } catch (OutOfMemoryError e) {
315                    Log.w(TAG, "getResizedBitmap: img too large to decode (OutOfMemoryError), " +
316                            "may try with larger sampleSize. Curr sampleSize=" + sampleSize);
317                    sampleSize *= 2;    // works best as a power of two
318                    attempts++;
319                    continue;
320                } finally {
321                    if (input != null) {
322                        try {
323                            input.close();
324                        } catch (IOException e) {
325                            Log.e(TAG, e.getMessage(), e);
326                        }
327                    }
328                }
329            } while (b == null && attempts < NUMBER_OF_RESIZE_ATTEMPTS);
330
331            if (b == null) {
332                if (Log.isLoggable(LogTag.APP, Log.VERBOSE)
333                        && attempts >= NUMBER_OF_RESIZE_ATTEMPTS) {
334                    Log.v(TAG, "getResizedImageData: gave up after too many attempts to resize");
335                }
336                return null;
337            }
338
339            boolean resultTooBig = true;
340            attempts = 1;   // reset count for second loop
341            // In this loop, we attempt to compress/resize the content to fit the given dimension
342            // and file-size limits.
343            do {
344                try {
345                    if (options.outWidth > widthLimit || options.outHeight > heightLimit ||
346                            (os != null && os.size() > byteLimit)) {
347                        // The decoder does not support the inSampleSize option.
348                        // Scale the bitmap using Bitmap library.
349                        int scaledWidth = (int)(outWidth * scaleFactor);
350                        int scaledHeight = (int)(outHeight * scaleFactor);
351
352                        if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
353                            Log.v(TAG, "getResizedImageData: retry scaling using " +
354                                    "Bitmap.createScaledBitmap: w=" + scaledWidth +
355                                    ", h=" + scaledHeight);
356                        }
357
358                        b = Bitmap.createScaledBitmap(b, scaledWidth, scaledHeight, false);
359                        if (b == null) {
360                            if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
361                                Log.v(TAG, "Bitmap.createScaledBitmap returned NULL!");
362                            }
363                            return null;
364                        }
365                    }
366
367                    // Compress the image into a JPG. Start with MessageUtils.IMAGE_COMPRESSION_QUALITY.
368                    // In case that the image byte size is still too large reduce the quality in
369                    // proportion to the desired byte size.
370                    if (os != null) {
371                        try {
372                            os.close();
373                        } catch (IOException e) {
374                            Log.e(TAG, e.getMessage(), e);
375                        }
376                    }
377                    os = new ByteArrayOutputStream();
378                    b.compress(CompressFormat.JPEG, quality, os);
379                    int jpgFileSize = os.size();
380                    if (jpgFileSize > byteLimit) {
381                        quality = (quality * byteLimit) / jpgFileSize;  // watch for int division!
382                        if (quality < MessageUtils.MINIMUM_IMAGE_COMPRESSION_QUALITY) {
383                            quality = MessageUtils.MINIMUM_IMAGE_COMPRESSION_QUALITY;
384                        }
385
386                        if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
387                            Log.v(TAG, "getResizedImageData: compress(2) w/ quality=" + quality);
388                        }
389
390                        if (os != null) {
391                            try {
392                                os.close();
393                            } catch (IOException e) {
394                                Log.e(TAG, e.getMessage(), e);
395                            }
396                        }
397                        os = new ByteArrayOutputStream();
398                        b.compress(CompressFormat.JPEG, quality, os);
399                    }
400                } catch (java.lang.OutOfMemoryError e) {
401                    Log.w(TAG, "getResizedImageData - image too big (OutOfMemoryError), will try "
402                            + " with smaller scale factor, cur scale factor: " + scaleFactor);
403                    // fall through and keep trying with a smaller scale factor.
404                }
405                if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
406                    Log.v(TAG, "attempt=" + attempts
407                            + " size=" + (os == null ? 0 : os.size())
408                            + " width=" + outWidth * scaleFactor
409                            + " height=" + outHeight * scaleFactor
410                            + " scaleFactor=" + scaleFactor
411                            + " quality=" + quality);
412                }
413                scaleFactor *= .75F;
414                attempts++;
415                resultTooBig = os == null || os.size() > byteLimit;
416            } while (resultTooBig && attempts < NUMBER_OF_RESIZE_ATTEMPTS);
417            if (!resultTooBig && orientation != 0) {
418                // Rotate the final bitmap if we need to.
419                try {
420                    b = UriImage.rotateBitmap(b, orientation);
421                    os = new ByteArrayOutputStream();
422                    b.compress(CompressFormat.JPEG, quality, os);
423                    resultTooBig = os == null || os.size() > byteLimit;
424                } catch (java.lang.OutOfMemoryError e) {
425                    Log.w(TAG, "getResizedImageData - image too big (OutOfMemoryError)");
426                    if (os == null) {
427                        return null;
428                    }
429                }
430            }
431
432            b.recycle();        // done with the bitmap, release the memory
433            if (Log.isLoggable(LogTag.APP, Log.VERBOSE) && resultTooBig) {
434                Log.v(TAG, "getResizedImageData returning NULL because the result is too big: " +
435                        " requested max: " + byteLimit + " actual: " + os.size());
436            }
437
438            return resultTooBig ? null : os.toByteArray();
439        } catch (FileNotFoundException e) {
440            Log.e(TAG, e.getMessage(), e);
441            return null;
442        } catch (java.lang.OutOfMemoryError e) {
443            Log.e(TAG, e.getMessage(), e);
444            return null;
445        } finally {
446            if (input != null) {
447                try {
448                    input.close();
449                } catch (IOException e) {
450                    Log.e(TAG, e.getMessage(), e);
451                }
452            }
453            if (os != null) {
454                try {
455                    os.close();
456                } catch (IOException e) {
457                    Log.e(TAG, e.getMessage(), e);
458                }
459            }
460        }
461    }
462
463    /**
464     * Bitmap rotation method
465     *
466     * @param bitmap The input bitmap
467     * @param degrees The rotation angle
468     */
469    public static Bitmap rotateBitmap(Bitmap bitmap, int degrees) {
470        if (degrees != 0 && bitmap != null) {
471            final Matrix m = new Matrix();
472            final int w = bitmap.getWidth();
473            final int h = bitmap.getHeight();
474            m.setRotate(degrees, (float) w / 2, (float) h / 2);
475
476            try {
477                final Bitmap rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, w, h, m, true);
478                if (bitmap != rotatedBitmap && rotatedBitmap != null) {
479                    bitmap.recycle();
480                    bitmap = rotatedBitmap;
481                }
482            } catch (OutOfMemoryError ex) {
483                Log.e(TAG, "OOM in rotateBitmap", ex);
484                // We have no memory to rotate. Return the original bitmap.
485            }
486        }
487
488        return bitmap;
489    }
490
491    /**
492     * Returns the number of degrees to rotate the picture, based on the orientation tag in
493     * the exif data or the orientation column in the database. If there's no tag or column,
494     * 0 degrees is returned.
495     *
496     * @param context Used to get the ContentResolver
497     * @param uri Path to the image
498     */
499    public static int getOrientation(Context context, Uri uri) {
500        long dur = System.currentTimeMillis();
501        if (ContentResolver.SCHEME_FILE.equals(uri.getScheme()) ||
502                sURLMatcher.match(uri) == MMS_PART_ID) {
503            // If the uri is a file or an mms part, we have to look at the exif data in the
504            // file for the orientation because there is no column in the db for the orientation.
505            try {
506                InputStream inputStream = context.getContentResolver().openInputStream(uri);
507                ExifInterface exif = new ExifInterface();
508                try {
509                    exif.readExif(inputStream);
510                    Integer val = exif.getTagIntValue(ExifInterface.TAG_ORIENTATION);
511                    if (val == null){
512                        return 0;
513                    }
514                    int orientation =
515                            ExifInterface.getRotationForOrientationValue(val.shortValue());
516                    return orientation;
517                } catch (IOException e) {
518                    Log.w(TAG, "Failed to read EXIF orientation", e);
519                } finally {
520                    if (inputStream != null) {
521                        try {
522                            inputStream.close();
523                        } catch (IOException e) {
524                        }
525                    }
526                }
527            } catch (FileNotFoundException e) {
528                Log.e(TAG, "Can't open uri: " + uri, e);
529            } finally {
530                dur = System.currentTimeMillis() - dur;
531                if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
532                    Log.v(TAG, "UriImage.getOrientation (exif path) took: " + dur + " ms");
533                }
534            }
535        } else {
536            // Try to get the orientation from the ORIENTATION column in the database. This is much
537            // faster than reading all the exif tags from the file.
538            Cursor cursor = null;
539            try {
540                cursor = context.getContentResolver().query(uri,
541                        new String[] {
542                            MediaStore.Images.ImageColumns.ORIENTATION
543                        },
544                        null, null, null);
545                if (cursor.moveToNext()) {
546                    int ori = cursor.getInt(0);
547                    return ori;
548                }
549            } catch (SQLiteException e) {
550            } catch (IllegalArgumentException e) {
551            } finally {
552                if (cursor != null) {
553                    cursor.close();
554                }
555                dur = System.currentTimeMillis() - dur;
556                if (Log.isLoggable(LogTag.APP, Log.VERBOSE)) {
557                    Log.v(TAG, "UriImage.getOrientation (db column path) took: " + dur + " ms");
558                }
559            }
560        }
561        return 0;
562    }
563}
564