/* * Copyright (C) 2015 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.messaging.datamodel.data; import android.content.ContentValues; import android.database.Cursor; import android.database.sqlite.SQLiteStatement; import android.graphics.Rect; import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; import android.text.TextUtils; import com.android.messaging.Factory; import com.android.messaging.datamodel.DatabaseHelper; import com.android.messaging.datamodel.DatabaseHelper.PartColumns; import com.android.messaging.datamodel.DatabaseWrapper; import com.android.messaging.datamodel.MediaScratchFileProvider; import com.android.messaging.datamodel.MessagingContentProvider; import com.android.messaging.datamodel.action.UpdateMessagePartSizeAction; import com.android.messaging.datamodel.media.ImageRequest; import com.android.messaging.sms.MmsUtils; import com.android.messaging.util.Assert; import com.android.messaging.util.Assert.DoesNotRunOnMainThread; import com.android.messaging.util.ContentType; import com.android.messaging.util.GifTranscoder; import com.android.messaging.util.ImageUtils; import com.android.messaging.util.LogUtil; import com.android.messaging.util.SafeAsyncTask; import com.android.messaging.util.UriUtil; import java.util.Arrays; import java.util.concurrent.TimeUnit; /** * Represents a single message part. Messages consist of one or more parts which may contain * either text or media. */ public class MessagePartData implements Parcelable { public static final int UNSPECIFIED_SIZE = MessagingContentProvider.UNSPECIFIED_SIZE; public static final String[] ACCEPTABLE_GALLERY_MEDIA_TYPES = new String[] { // Acceptable image types ContentType.IMAGE_JPEG, ContentType.IMAGE_JPG, ContentType.IMAGE_PNG, ContentType.IMAGE_GIF, ContentType.IMAGE_WBMP, ContentType.IMAGE_X_MS_BMP, // Acceptable video types ContentType.VIDEO_3GP, ContentType.VIDEO_3GPP, ContentType.VIDEO_3G2, ContentType.VIDEO_H263, ContentType.VIDEO_M4V, ContentType.VIDEO_MP4, ContentType.VIDEO_MPEG, ContentType.VIDEO_MPEG4, ContentType.VIDEO_WEBM, // Acceptable audio types ContentType.AUDIO_MP3, ContentType.AUDIO_MP4, ContentType.AUDIO_MIDI, ContentType.AUDIO_MID, ContentType.AUDIO_AMR, ContentType.AUDIO_X_WAV, ContentType.AUDIO_AAC, ContentType.AUDIO_X_MIDI, ContentType.AUDIO_X_MID, ContentType.AUDIO_X_MP3 }; private static final String[] sProjection = { PartColumns._ID, PartColumns.MESSAGE_ID, PartColumns.TEXT, PartColumns.CONTENT_URI, PartColumns.CONTENT_TYPE, PartColumns.WIDTH, PartColumns.HEIGHT, }; private static final int INDEX_ID = 0; private static final int INDEX_MESSAGE_ID = 1; private static final int INDEX_TEXT = 2; private static final int INDEX_CONTENT_URI = 3; private static final int INDEX_CONTENT_TYPE = 4; private static final int INDEX_WIDTH = 5; private static final int INDEX_HEIGHT = 6; // This isn't part of the projection private static final int INDEX_CONVERSATION_ID = 7; // SQL statement to insert a "complete" message part row (columns based on projection above). private static final String INSERT_MESSAGE_PART_SQL = "INSERT INTO " + DatabaseHelper.PARTS_TABLE + " ( " + TextUtils.join(",", Arrays.copyOfRange(sProjection, 1, INDEX_CONVERSATION_ID)) + ", " + PartColumns.CONVERSATION_ID + ") VALUES (?, ?, ?, ?, ?, ?, ?)"; // Used for stuff that's ignored or arbitrarily compressed. private static final long NO_MINIMUM_SIZE = 0; private String mPartId; private String mMessageId; private String mText; private Uri mContentUri; private String mContentType; private int mWidth; private int mHeight; // This kind of part can only be attached once and with no other attachment private boolean mSinglePartOnly; /** Transient data: true if destroy was already called */ private boolean mDestroyed; /** * Create an "empty" message part */ protected MessagePartData() { this(null, null, UNSPECIFIED_SIZE, UNSPECIFIED_SIZE); } /** * Create a populated text message part */ protected MessagePartData(final String messageText) { this(null, messageText, ContentType.TEXT_PLAIN, null, UNSPECIFIED_SIZE, UNSPECIFIED_SIZE, false /*singlePartOnly*/); } /** * Create a populated attachment message part */ protected MessagePartData(final String contentType, final Uri contentUri, final int width, final int height) { this(null, null, contentType, contentUri, width, height, false /*singlePartOnly*/); } /** * Create a populated attachment message part, with additional caption text */ protected MessagePartData(final String messageText, final String contentType, final Uri contentUri, final int width, final int height) { this(null, messageText, contentType, contentUri, width, height, false /*singlePartOnly*/); } /** * Create a populated attachment message part, with additional caption text, single part only */ protected MessagePartData(final String messageText, final String contentType, final Uri contentUri, final int width, final int height, final boolean singlePartOnly) { this(null, messageText, contentType, contentUri, width, height, singlePartOnly); } /** * Create a populated message part */ private MessagePartData(final String messageId, final String messageText, final String contentType, final Uri contentUri, final int width, final int height, final boolean singlePartOnly) { mMessageId = messageId; mText = messageText; mContentType = contentType; mContentUri = contentUri; mWidth = width; mHeight = height; mSinglePartOnly = singlePartOnly; } /** * Create a "text" message part */ public static MessagePartData createTextMessagePart(final String messageText) { return new MessagePartData(messageText); } /** * Create a "media" message part */ public static MessagePartData createMediaMessagePart(final String contentType, final Uri contentUri, final int width, final int height) { return new MessagePartData(contentType, contentUri, width, height); } /** * Create a "media" message part with caption */ public static MessagePartData createMediaMessagePart(final String caption, final String contentType, final Uri contentUri, final int width, final int height) { return new MessagePartData(null, caption, contentType, contentUri, width, height, false /*singlePartOnly*/ ); } /** * Create an empty "text" message part */ public static MessagePartData createEmptyMessagePart() { return new MessagePartData(""); } /** * Creates a new message part reading from the cursor */ public static MessagePartData createFromCursor(final Cursor cursor) { final MessagePartData part = new MessagePartData(); part.bind(cursor); return part; } public static String[] getProjection() { return sProjection; } /** * Updates the part id. * Can be used to reset the partId just prior to persisting (which will assign a new partId) * or can be called on a part that does not yet have a valid part id to set it. */ public void updatePartId(final String partId) { Assert.isTrue(TextUtils.isEmpty(partId) || TextUtils.isEmpty(mPartId)); mPartId = partId; } /** * Updates the messageId for the part. * Can be used to reset the messageId prior to persisting (which will assign a new messageId) * or can be called on a part that does not yet have a valid messageId to set it. */ public void updateMessageId(final String messageId) { Assert.isTrue(TextUtils.isEmpty(messageId) || TextUtils.isEmpty(mMessageId)); mMessageId = messageId; } protected static String getMessageId(final Cursor cursor) { return cursor.getString(INDEX_MESSAGE_ID); } protected void bind(final Cursor cursor) { mPartId = cursor.getString(INDEX_ID); mMessageId = cursor.getString(INDEX_MESSAGE_ID); mText = cursor.getString(INDEX_TEXT); mContentUri = UriUtil.uriFromString(cursor.getString(INDEX_CONTENT_URI)); mContentType = cursor.getString(INDEX_CONTENT_TYPE); mWidth = cursor.getInt(INDEX_WIDTH); mHeight = cursor.getInt(INDEX_HEIGHT); } public final void populate(final ContentValues values) { // Must have a valid messageId on a part Assert.isTrue(!TextUtils.isEmpty(mMessageId)); values.put(PartColumns.MESSAGE_ID, mMessageId); values.put(PartColumns.TEXT, mText); values.put(PartColumns.CONTENT_URI, UriUtil.stringFromUri(mContentUri)); values.put(PartColumns.CONTENT_TYPE, mContentType); if (mWidth != UNSPECIFIED_SIZE) { values.put(PartColumns.WIDTH, mWidth); } if (mHeight != UNSPECIFIED_SIZE) { values.put(PartColumns.HEIGHT, mHeight); } } /** * Note this is not thread safe so callers need to make sure they own the wrapper + statements * while they call this and use the returned value. */ public SQLiteStatement getInsertStatement(final DatabaseWrapper db, final String conversationId) { final SQLiteStatement insert = db.getStatementInTransaction( DatabaseWrapper.INDEX_INSERT_MESSAGE_PART, INSERT_MESSAGE_PART_SQL); insert.clearBindings(); insert.bindString(INDEX_MESSAGE_ID, mMessageId); if (mText != null) { insert.bindString(INDEX_TEXT, mText); } if (mContentUri != null) { insert.bindString(INDEX_CONTENT_URI, mContentUri.toString()); } if (mContentType != null) { insert.bindString(INDEX_CONTENT_TYPE, mContentType); } insert.bindLong(INDEX_WIDTH, mWidth); insert.bindLong(INDEX_HEIGHT, mHeight); insert.bindString(INDEX_CONVERSATION_ID, conversationId); return insert; } public final String getPartId() { return mPartId; } public final String getMessageId() { return mMessageId; } public final String getText() { return mText; } public final Uri getContentUri() { return mContentUri; } public boolean isAttachment() { return mContentUri != null; } public boolean isText() { return ContentType.isTextType(mContentType); } public boolean isImage() { return ContentType.isImageType(mContentType); } public boolean isMedia() { return ContentType.isMediaType(mContentType); } public boolean isVCard() { return ContentType.isVCardType(mContentType); } public boolean isAudio() { return ContentType.isAudioType(mContentType); } public boolean isVideo() { return ContentType.isVideoType(mContentType); } public final String getContentType() { return mContentType; } public final int getWidth() { return mWidth; } public final int getHeight() { return mHeight; } public static boolean isSupportedMediaType(final String contentType) { return ContentType.isVCardType(contentType) || Arrays.asList(ACCEPTABLE_GALLERY_MEDIA_TYPES).contains(contentType); } /** * * @return true if this part can only exist by itself, with no other attachments */ public boolean getSinglePartOnly() { return mSinglePartOnly; } @Override public int describeContents() { return 0; } protected MessagePartData(final Parcel in) { mMessageId = in.readString(); mText = in.readString(); mContentUri = UriUtil.uriFromString(in.readString()); mContentType = in.readString(); mWidth = in.readInt(); mHeight = in.readInt(); } @Override public void writeToParcel(final Parcel dest, final int flags) { Assert.isTrue(!mDestroyed); dest.writeString(mMessageId); dest.writeString(mText); dest.writeString(UriUtil.stringFromUri(mContentUri)); dest.writeString(mContentType); dest.writeInt(mWidth); dest.writeInt(mHeight); } @Override public boolean equals(Object o) { if (this == o) { return true; } if (!(o instanceof MessagePartData)) { return false; } MessagePartData lhs = (MessagePartData) o; return mWidth == lhs.mWidth && mHeight == lhs.mHeight && TextUtils.equals(mMessageId, lhs.mMessageId) && TextUtils.equals(mText, lhs.mText) && TextUtils.equals(mContentType, lhs.mContentType) && (mContentUri == null ? lhs.mContentUri == null : mContentUri.equals(lhs.mContentUri)); } @Override public int hashCode() { int result = 17; result = 31 * result + mWidth; result = 31 * result + mHeight; result = 31 * result + (mMessageId == null ? 0 : mMessageId.hashCode()); result = 31 * result + (mText == null ? 0 : mText.hashCode()); result = 31 * result + (mContentType == null ? 0 : mContentType.hashCode()); result = 31 * result + (mContentUri == null ? 0 : mContentUri.hashCode()); return result; } public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { @Override public MessagePartData createFromParcel(final Parcel in) { return new MessagePartData(in); } @Override public MessagePartData[] newArray(final int size) { return new MessagePartData[size]; } }; protected Uri shouldDestroy() { // We should never double-destroy. Assert.isTrue(!mDestroyed); mDestroyed = true; Uri contentUri = mContentUri; mContentUri = null; mContentType = null; // Only destroy the image if it's staged in our scratch space. if (!MediaScratchFileProvider.isMediaScratchSpaceUri(contentUri)) { contentUri = null; } return contentUri; } /** * If application owns content associated with this part delete it (on background thread) */ public void destroyAsync() { final Uri contentUri = shouldDestroy(); if (contentUri != null) { SafeAsyncTask.executeOnThreadPool(new Runnable() { @Override public void run() { Factory.get().getApplicationContext().getContentResolver().delete( contentUri, null, null); } }); } } /** * If application owns content associated with this part delete it */ public void destroySync() { final Uri contentUri = shouldDestroy(); if (contentUri != null) { Factory.get().getApplicationContext().getContentResolver().delete( contentUri, null, null); } } /** * If this is an image part, decode the image header and potentially save the size to the db. */ public void decodeAndSaveSizeIfImage(final boolean saveToStorage) { if (isImage()) { final Rect imageSize = ImageUtils.decodeImageBounds( Factory.get().getApplicationContext(), mContentUri); if (imageSize.width() != ImageRequest.UNSPECIFIED_SIZE && imageSize.height() != ImageRequest.UNSPECIFIED_SIZE) { mWidth = imageSize.width(); mHeight = imageSize.height(); if (saveToStorage) { UpdateMessagePartSizeAction.updateSize(mPartId, mWidth, mHeight); } } } } /** * Computes the minimum size that this MessagePartData could be compressed/downsampled/encoded * before sending to meet the maximum message size imposed by the carriers. This is used to * determine right before sending a message whether a message could possibly be sent. If not * then the user is given a chance to unselect some/all of the attachments. * * TODO: computing the minimum size could be expensive. Should we cache the * computed value in db to be retrieved later? * * @return the carrier-independent minimum size, in bytes. */ @DoesNotRunOnMainThread public long getMinimumSizeInBytesForSending() { Assert.isNotMainThread(); if (!isAttachment()) { // No limit is imposed on non-attachment part (i.e. plain text), so treat it as zero. return NO_MINIMUM_SIZE; } else if (isImage()) { // GIFs are resized by the native transcoder (exposed by GifTranscoder). if (ImageUtils.isGif(mContentType, mContentUri)) { final long originalImageSize = UriUtil.getContentSize(mContentUri); // Wish we could save the size here, but we don't have a part id yet decodeAndSaveSizeIfImage(false /* saveToStorage */); return GifTranscoder.canBeTranscoded(mWidth, mHeight) ? GifTranscoder.estimateFileSizeAfterTranscode(originalImageSize) : originalImageSize; } // Other images should be arbitrarily resized by ImageResizer before sending. return MmsUtils.MIN_IMAGE_BYTE_SIZE; } else if (isMedia()) { // We can't compress attachments except images. return UriUtil.getContentSize(mContentUri); } else { // This is some unknown media type that we don't know how to handle. Log an error // and try sending it anyway. LogUtil.e(LogUtil.BUGLE_DATAMODEL_TAG, "Unknown attachment type " + getContentType()); return NO_MINIMUM_SIZE; } } @Override public String toString() { if (isText()) { return LogUtil.sanitizePII(getText()); } else { return getContentType() + " (" + getContentUri() + ")"; } } /** * * @return true if this part can only exist by itself, with no other attachments */ public boolean isSinglePartOnly() { return mSinglePartOnly; } public void setSinglePartOnly(final boolean isSinglePartOnly) { mSinglePartOnly = isSinglePartOnly; } }