diff --git a/androidTest/java/org/thoughtcrime/securesms/database/PartDatabaseTest.java b/androidTest/java/org/thoughtcrime/securesms/database/PartDatabaseTest.java new file mode 100644 index 0000000000..dfba5cbccc --- /dev/null +++ b/androidTest/java/org/thoughtcrime/securesms/database/PartDatabaseTest.java @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.database; + +import android.net.Uri; +import android.test.InstrumentationTestCase; + +import org.thoughtcrime.securesms.crypto.MasterSecret; + +import java.io.FileNotFoundException; +import java.io.InputStream; + +import ws.com.google.android.mms.pdu.PduPart; + +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyFloat; +import static org.mockito.Matchers.anyLong; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class PartDatabaseTest extends InstrumentationTestCase { + private static final long PART_ID = 1L; + + private PartDatabase database; + + @Override + public void setUp() { + database = spy(DatabaseFactory.getPartDatabase(getInstrumentation().getTargetContext())); + } + + public void testTaskNotRunWhenThumbnailExists() throws Exception { + when(database.getPart(eq(PART_ID))).thenReturn(getPduPartSkeleton("x/x")); + doReturn(mock(InputStream.class)).when(database).getDataStream(any(MasterSecret.class), anyLong(), eq("thumbnail")); + + database.getThumbnailStream(null, PART_ID); + + verify(database, never()).updatePartThumbnail(any(MasterSecret.class), anyLong(), any(PduPart.class), any(InputStream.class), anyFloat()); + } + + public void testTaskRunWhenThumbnailMissing() throws Exception { + when(database.getPart(eq(PART_ID))).thenReturn(getPduPartSkeleton("image/png")); + doReturn(null).when(database).getDataStream(any(MasterSecret.class), anyLong(), eq("thumbnail")); + doNothing().when(database).updatePartThumbnail(any(MasterSecret.class), anyLong(), any(PduPart.class), any(InputStream.class), anyFloat()); + + try { + database.new ThumbnailFetchCallable(mock(MasterSecret.class), PART_ID).call(); + throw new AssertionError("didn't try to generate thumbnail"); + } catch (FileNotFoundException fnfe) { + // success + } + } + + private PduPart getPduPartSkeleton(String contentType) { + PduPart part = new PduPart(); + part.setContentType(contentType.getBytes()); + part.setDataUri(Uri.EMPTY); + return part; + } +} diff --git a/build.gradle b/build.gradle index d5e78eafca..4e9c4d16ab 100644 --- a/build.gradle +++ b/build.gradle @@ -16,7 +16,7 @@ apply plugin: 'witness' repositories { maven { - url "https://repo1.maven.org/maven2" + url "https://repo1.maven.org/maven2/" } maven { url "https://raw.github.com/whispersystems/maven/master/preferencefragment/releases/" diff --git a/src/org/thoughtcrime/securesms/GroupCreateActivity.java b/src/org/thoughtcrime/securesms/GroupCreateActivity.java index 651d61c5e0..eecde312b4 100644 --- a/src/org/thoughtcrime/securesms/GroupCreateActivity.java +++ b/src/org/thoughtcrime/securesms/GroupCreateActivity.java @@ -511,7 +511,7 @@ public class GroupCreateActivity extends PassphraseRequiredActionBarActivity { if (avatarUri != null) { try { avatarBmp = BitmapUtil.getScaledCircleCroppedBitmap(GroupCreateActivity.this, masterSecret, avatarUri, AVATAR_SIZE); - } catch (FileNotFoundException | BitmapDecodingException e) { + } catch (IOException | BitmapDecodingException e) { Log.w(TAG, e); return null; } diff --git a/src/org/thoughtcrime/securesms/database/MmsDatabase.java b/src/org/thoughtcrime/securesms/database/MmsDatabase.java index b41d99750c..9e6a4f9731 100644 --- a/src/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/src/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -21,6 +21,7 @@ import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; +import android.graphics.Bitmap; import android.net.Uri; import android.telephony.TelephonyManager; import android.text.TextUtils; @@ -52,7 +53,6 @@ import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.LRUCache; import org.thoughtcrime.securesms.util.ListenableFutureTask; import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.thoughtcrime.securesms.util.Trimmer; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.jobqueue.JobManager; import org.whispersystems.libaxolotl.InvalidMessageException; @@ -713,14 +713,14 @@ public class MmsDatabase extends Database implements MmsSmsColumns { if (Types.isSymmetricEncryption(contentValues.getAsLong(MESSAGE_BOX))) { String messageText = PartParser.getMessageText(body); - body = PartParser.getNonTextParts(body); + body = PartParser.getSupportedMediaParts(body); if (!TextUtils.isEmpty(messageText)) { contentValues.put(BODY, new MasterCipher(masterSecret).encryptBody(messageText)); } } - contentValues.put(PART_COUNT, PartParser.getDisplayablePartCount(body)); + contentValues.put(PART_COUNT, PartParser.getSupportedMediaPartCount(body)); long messageId = db.insert(TABLE_NAME, null, contentValues); diff --git a/src/org/thoughtcrime/securesms/database/PartDatabase.java b/src/org/thoughtcrime/securesms/database/PartDatabase.java index 4f2b6753b2..624f0ccbc4 100644 --- a/src/org/thoughtcrime/securesms/database/PartDatabase.java +++ b/src/org/thoughtcrime/securesms/database/PartDatabase.java @@ -22,17 +22,20 @@ import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; +import android.graphics.Bitmap; import android.text.TextUtils; import android.util.Log; import android.util.Pair; -import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.crypto.DecryptingPartInputStream; import org.thoughtcrime.securesms.crypto.EncryptingPartOutputStream; import org.thoughtcrime.securesms.crypto.MasterSecret; -import org.thoughtcrime.securesms.jobs.ThumbnailGenerateJob; import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.BitmapDecodingException; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.MediaUtil.ThumbnailData; import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.VisibleForTesting; import java.io.ByteArrayInputStream; import java.io.File; @@ -42,6 +45,9 @@ import java.io.InputStream; import java.io.OutputStream; import java.util.LinkedList; import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; import ws.com.google.android.mms.ContentType; import ws.com.google.android.mms.MmsException; @@ -85,6 +91,8 @@ public class PartDatabase extends Database { "CREATE INDEX IF NOT EXISTS pending_push_index ON " + TABLE_NAME + " (" + PENDING_PUSH_ATTACHMENT + ");", }; + private final ExecutorService thumbnailExecutor = Util.newSingleThreadedLifoExecutor(); + public PartDatabase(Context context, SQLiteOpenHelper databaseHelper) { super(context, databaseHelper); } @@ -95,12 +103,6 @@ public class PartDatabase extends Database { return getDataStream(masterSecret, partId, DATA); } - public InputStream getThumbnailStream(MasterSecret masterSecret, long partId) - throws FileNotFoundException - { - return getDataStream(masterSecret, partId, THUMBNAIL); - } - public void updateFailedDownloadedPart(long messageId, long partId, PduPart part) throws MmsException { @@ -199,12 +201,16 @@ public class PartDatabase extends Database { void insertParts(MasterSecret masterSecret, long mmsId, PduBody body) throws MmsException { for (int i=0;i partData = null; @@ -410,7 +426,13 @@ public class PartDatabase extends Database { long partId = database.insert(TABLE_NAME, null, contentValues); - ApplicationContext.getInstance(context).getJobManager().add(new ThumbnailGenerateJob(context, partId)); + if (thumbnail != null) { + Log.w(TAG, "inserting pre-generated thumbnail"); + ThumbnailData data = new ThumbnailData(thumbnail); + updatePartThumbnail(masterSecret, partId, part, data.toDataStream(), data.getAspectRatio()); + } else if (!part.isPendingPush()) { + thumbnailExecutor.submit(new ThumbnailFetchCallable(masterSecret, partId)); + } return partId; } @@ -432,9 +454,9 @@ public class PartDatabase extends Database { values.put(SIZE, partData.second); } - database.update(TABLE_NAME, values, ID_WHERE, new String[] {partId+""}); + database.update(TABLE_NAME, values, ID_WHERE, new String[]{partId+""}); - ApplicationContext.getInstance(context).getJobManager().add(new ThumbnailGenerateJob(context, partId)); + thumbnailExecutor.submit(new ThumbnailFetchCallable(masterSecret, partId)); notifyConversationListeners(DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(messageId)); } @@ -452,6 +474,36 @@ public class PartDatabase extends Database { values.put(THUMBNAIL, thumbnailFile.first.getAbsolutePath()); values.put(ASPECT_RATIO, aspectRatio); - database.update(TABLE_NAME, values, ID_WHERE, new String[] {partId + ""}); + database.update(TABLE_NAME, values, ID_WHERE, new String[]{partId+""}); + } + + @VisibleForTesting class ThumbnailFetchCallable implements Callable { + private final MasterSecret masterSecret; + private final long partId; + + public ThumbnailFetchCallable(MasterSecret masterSecret, long partId) { + this.masterSecret = masterSecret; + this.partId = partId; + } + + @Override + public InputStream call() throws Exception { + final InputStream stream = getDataStream(masterSecret, partId, THUMBNAIL); + if (stream != null) { + return stream; + } + + try { + PduPart part = getPart(partId); + ThumbnailData data = MediaUtil.generateThumbnail(context, masterSecret, part.getDataUri(), Util.toIsoString(part.getContentType())); + if (data == null) { + return null; + } + updatePartThumbnail(masterSecret, partId, part, data.toDataStream(), data.getAspectRatio()); + } catch (BitmapDecodingException bde) { + throw new IOException(bde); + } + return getDataStream(masterSecret, partId, THUMBNAIL); + } } } diff --git a/src/org/thoughtcrime/securesms/jobs/ThumbnailGenerateJob.java b/src/org/thoughtcrime/securesms/jobs/ThumbnailGenerateJob.java deleted file mode 100644 index 0224d5871c..0000000000 --- a/src/org/thoughtcrime/securesms/jobs/ThumbnailGenerateJob.java +++ /dev/null @@ -1,126 +0,0 @@ -package org.thoughtcrime.securesms.jobs; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Bitmap.CompressFormat; -import android.util.Log; -import android.util.Pair; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.crypto.AsymmetricMasterCipher; -import org.thoughtcrime.securesms.crypto.AsymmetricMasterSecret; -import org.thoughtcrime.securesms.crypto.MasterSecret; -import org.thoughtcrime.securesms.crypto.MasterSecretUtil; -import org.thoughtcrime.securesms.crypto.SecurityEvent; -import org.thoughtcrime.securesms.crypto.SmsCipher; -import org.thoughtcrime.securesms.crypto.storage.TextSecureAxolotlStore; -import org.thoughtcrime.securesms.database.DatabaseFactory; -import org.thoughtcrime.securesms.database.EncryptingSmsDatabase; -import org.thoughtcrime.securesms.database.NoSuchMessageException; -import org.thoughtcrime.securesms.database.PartDatabase; -import org.thoughtcrime.securesms.database.model.SmsMessageRecord; -import org.thoughtcrime.securesms.jobs.requirements.MasterSecretRequirement; -import org.thoughtcrime.securesms.notifications.MessageNotifier; -import org.thoughtcrime.securesms.service.KeyCachingService; -import org.thoughtcrime.securesms.sms.IncomingEncryptedMessage; -import org.thoughtcrime.securesms.sms.IncomingEndSessionMessage; -import org.thoughtcrime.securesms.sms.IncomingKeyExchangeMessage; -import org.thoughtcrime.securesms.sms.IncomingPreKeyBundleMessage; -import org.thoughtcrime.securesms.sms.IncomingTextMessage; -import org.thoughtcrime.securesms.sms.MessageSender; -import org.thoughtcrime.securesms.sms.OutgoingKeyExchangeMessage; -import org.thoughtcrime.securesms.util.BitmapDecodingException; -import org.thoughtcrime.securesms.util.BitmapUtil; -import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.thoughtcrime.securesms.util.Util; -import org.whispersystems.jobqueue.JobParameters; -import org.whispersystems.libaxolotl.DuplicateMessageException; -import org.whispersystems.libaxolotl.InvalidMessageException; -import org.whispersystems.libaxolotl.InvalidVersionException; -import org.whispersystems.libaxolotl.LegacyMessageException; -import org.whispersystems.libaxolotl.NoSessionException; -import org.whispersystems.libaxolotl.StaleKeyExchangeException; -import org.whispersystems.libaxolotl.UntrustedIdentityException; -import org.whispersystems.libaxolotl.util.guava.Optional; -import org.whispersystems.textsecure.api.messages.TextSecureGroup; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; - -import ws.com.google.android.mms.ContentType; -import ws.com.google.android.mms.MmsException; -import ws.com.google.android.mms.pdu.PduPart; - -public class ThumbnailGenerateJob extends MasterSecretJob { - - private static final String TAG = ThumbnailGenerateJob.class.getSimpleName(); - - private final long partId; - - public ThumbnailGenerateJob(Context context, long partId) { - super(context, JobParameters.newBuilder() - .withRequirement(new MasterSecretRequirement(context)) - .create()); - - this.partId = partId; - } - - @Override - public void onAdded() { } - - @Override - public void onRun(MasterSecret masterSecret) throws MmsException { - PartDatabase database = DatabaseFactory.getPartDatabase(context); - PduPart part = database.getPart(partId); - - if (part.getThumbnailUri() != null) { - return; - } - - long startMillis = System.currentTimeMillis(); - Bitmap thumbnail = generateThumbnailForPart(masterSecret, part); - - if (thumbnail != null) { - ByteArrayOutputStream thumbnailBytes = new ByteArrayOutputStream(); - thumbnail.compress(CompressFormat.JPEG, 85, thumbnailBytes); - - float aspectRatio = (float)thumbnail.getWidth() / (float)thumbnail.getHeight(); - Log.w(TAG, String.format("generated thumbnail for part #%d, %dx%d (%.3f:1) in %dms", - partId, - thumbnail.getWidth(), - thumbnail.getHeight(), - aspectRatio, System.currentTimeMillis() - startMillis)); - database.updatePartThumbnail(masterSecret, partId, part, new ByteArrayInputStream(thumbnailBytes.toByteArray()), aspectRatio); - } else { - Log.w(TAG, "thumbnail not generated"); - } - } - - private Bitmap generateThumbnailForPart(MasterSecret masterSecret, PduPart part) { - String contentType = Util.toIsoString(part.getContentType()); - - if (ContentType.isImageType(contentType)) return generateImageThumbnail(masterSecret, part); - else return null; - } - - private Bitmap generateImageThumbnail(MasterSecret masterSecret, PduPart part) { - try { - int maxSize = context.getResources().getDimensionPixelSize(R.dimen.thumbnail_max_size); - return BitmapUtil.createScaledBitmap(context, masterSecret, part.getDataUri(), maxSize, maxSize); - } catch (FileNotFoundException | BitmapDecodingException | OutOfMemoryError e) { - Log.w(TAG, e); - return null; - } - } - - @Override - public boolean onShouldRetryThrowable(Exception exception) { - return false; - } - - @Override - public void onCanceled() { } -} diff --git a/src/org/thoughtcrime/securesms/mms/ImageSlide.java b/src/org/thoughtcrime/securesms/mms/ImageSlide.java index a2ee5f88e9..66160c3135 100644 --- a/src/org/thoughtcrime/securesms/mms/ImageSlide.java +++ b/src/org/thoughtcrime/securesms/mms/ImageSlide.java @@ -31,11 +31,14 @@ import android.util.Log; import android.widget.ImageView; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.util.BitmapDecodingException; -import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.LRUCache; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.MediaUtil.ThumbnailData; import org.thoughtcrime.securesms.util.SmilUtil; +import org.thoughtcrime.securesms.util.Util; import org.w3c.dom.smil.SMILDocument; import org.w3c.dom.smil.SMILMediaElement; import org.w3c.dom.smil.SMILRegionElement; @@ -81,9 +84,19 @@ public class ImageSlide extends Slide { try { Bitmap thumbnailBitmap; long startDecode = System.currentTimeMillis(); - Log.w(TAG, (part.getThumbnailUri() == null ? "generating" : "fetching pre-generated") + " thumbnail"); - if (part.getThumbnailUri() != null) thumbnailBitmap = BitmapFactory.decodeStream(PartAuthority.getPartStream(context, masterSecret, part.getThumbnailUri())); - else thumbnailBitmap = BitmapUtil.createScaledBitmap(context, masterSecret, getUri(), maxWidth, maxHeight); + + if (part.getDataUri() != null && part.getId() > -1) { + thumbnailBitmap = BitmapFactory.decodeStream(DatabaseFactory.getPartDatabase(context) + .getThumbnailStream(masterSecret, part.getId())); + } else if (part.getDataUri() != null) { + Log.w(TAG, "generating thumbnail for new part"); + ThumbnailData thumbnailData = MediaUtil.generateThumbnail(context, masterSecret, + part.getDataUri(), Util.toIsoString(part.getContentType())); + thumbnailBitmap = thumbnailData.getBitmap(); + part.setThumbnail(thumbnailBitmap); + } else { + throw new FileNotFoundException("no data location specified"); + } Log.w(TAG, "thumbnail decode/generate time: " + (System.currentTimeMillis() - startDecode) + "ms"); @@ -91,11 +104,8 @@ public class ImageSlide extends Slide { thumbnailCache.put(part.getDataUri(), new SoftReference<>(thumbnail)); return thumbnail; - } catch (FileNotFoundException e) { - Log.w("ImageSlide", e); - return context.getResources().getDrawable(R.drawable.ic_missing_thumbnail_picture); - } catch (BitmapDecodingException e) { - Log.w("ImageSlide", e); + } catch (IOException | BitmapDecodingException e) { + Log.w(TAG, e); return context.getResources().getDrawable(R.drawable.ic_missing_thumbnail_picture); } } diff --git a/src/org/thoughtcrime/securesms/mms/PartAuthority.java b/src/org/thoughtcrime/securesms/mms/PartAuthority.java index de8c02092f..2cc6883a5e 100644 --- a/src/org/thoughtcrime/securesms/mms/PartAuthority.java +++ b/src/org/thoughtcrime/securesms/mms/PartAuthority.java @@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.database.PartDatabase; import org.thoughtcrime.securesms.providers.PartProvider; import java.io.FileNotFoundException; +import java.io.IOException; import java.io.InputStream; public class PartAuthority { @@ -33,7 +34,7 @@ public class PartAuthority { } public static InputStream getPartStream(Context context, MasterSecret masterSecret, Uri uri) - throws FileNotFoundException + throws IOException { PartDatabase partDatabase = DatabaseFactory.getPartDatabase(context); int match = uriMatcher.match(uri); diff --git a/src/org/thoughtcrime/securesms/mms/PartParser.java b/src/org/thoughtcrime/securesms/mms/PartParser.java index 74986c437a..8d3fbc4f1f 100644 --- a/src/org/thoughtcrime/securesms/mms/PartParser.java +++ b/src/org/thoughtcrime/securesms/mms/PartParser.java @@ -37,11 +37,11 @@ public class PartParser { return bodyText; } - public static PduBody getNonTextParts(PduBody body) { + public static PduBody getSupportedMediaParts(PduBody body) { PduBody stripped = new PduBody(); for (int i=0;i