From 9bac88697b25f0ca4ae1104129bc5f1649579eb0 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 5 Feb 2020 16:34:54 -0500 Subject: [PATCH] Support sharing multiple photos/videos into Signal. --- app/src/main/AndroidManifest.xml | 14 +- .../securesms/MediaPreviewActivity.java | 1 + .../conversation/ConversationActivity.java | 4 +- .../conversation/ConversationFragment.java | 2 +- .../mediasend/MediaSendActivity.java | 4 +- .../mediasend/MediaSendConstants.java | 6 + .../mediasend/MediaSendViewModel.java | 11 +- .../securesms/service/DirectShareService.java | 3 +- .../{ => sharing}/ShareActivity.java | 311 ++++++++---------- .../securesms/sharing/ShareData.java | 66 ++++ .../securesms/sharing/ShareRepository.java | 209 ++++++++++++ .../securesms/sharing/ShareViewModel.java | 80 +++++ .../stickers/StickerManagementActivity.java | 3 +- .../stickers/StickerPackPreviewActivity.java | 12 +- .../securesms/util/MediaUtil.java | 1 + app/src/main/res/layout/share_activity.xml | 7 - app/src/main/res/values/strings.xml | 1 + 17 files changed, 517 insertions(+), 218 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendConstants.java rename app/src/main/java/org/thoughtcrime/securesms/{ => sharing}/ShareActivity.java (55%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/ShareData.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/ShareRepository.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/ShareViewModel.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c054a91e59..eb9b4f23d5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -148,7 +148,7 @@ - - @@ -169,8 +168,15 @@ - + + + + + + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index 52bd08efae..825b0e11fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -64,6 +64,7 @@ import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sharing.ShareActivity; import org.thoughtcrime.securesms.util.AttachmentUtil; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.SaveAttachmentTask; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 7d5360dc06..4facf9f40a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -1322,11 +1322,13 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity final StickerLocator stickerLocator = getIntent().getParcelableExtra(STICKER_EXTRA); if (stickerLocator != null && draftMedia != null) { + Log.d(TAG, "Handling shared sticker."); sendSticker(stickerLocator, draftMedia, 0, true); return new SettableFuture<>(false); } if (!Util.isEmpty(mediaList)) { + Log.d(TAG, "Handling shared Media."); Intent sendIntent = MediaSendActivity.buildEditorIntent(this, mediaList, recipient.get(), draftText, sendButton.getSelectedTransport()); startActivityForResult(sendIntent, MEDIA_SENDER); return new SettableFuture<>(false); @@ -1339,6 +1341,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } if (draftMedia != null && draftMediaType != null) { + Log.d(TAG, "Handling shared Data."); return setMedia(draftMedia, draftMediaType); } @@ -2633,7 +2636,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity slideDeck.addSlide(stickerSlide); sendMediaMessage(transport.isSms(), "", slideDeck, null, Collections.emptyList(), Collections.emptyList(), expiresIn, false, subscriptionId, initiating, clearCompose); - } private void silentlySetComposeText(String text) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index d25a1de479..ed1f002ee0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -63,7 +63,7 @@ import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.MessageDetailsActivity; import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.ShareActivity; +import org.thoughtcrime.securesms.sharing.ShareActivity; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.components.ConversationTypingView; import org.thoughtcrime.securesms.components.TooltipPopup; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java index 1cd6c73b21..29dd638270 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -142,11 +142,11 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple /** * Get an intent to launch the media send flow starting with the picker. */ - public static Intent buildGalleryIntent(@NonNull Context context, @NonNull Recipient recipient, @NonNull String body, @NonNull TransportOption transport) { + public static Intent buildGalleryIntent(@NonNull Context context, @NonNull Recipient recipient, @Nullable String body, @NonNull TransportOption transport) { Intent intent = new Intent(context, MediaSendActivity.class); intent.putExtra(KEY_RECIPIENT, recipient.getId()); intent.putExtra(KEY_TRANSPORT, transport); - intent.putExtra(KEY_BODY, body); + intent.putExtra(KEY_BODY, body == null ? "" : body); return intent; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendConstants.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendConstants.java new file mode 100644 index 0000000000..411fd9c8bd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendConstants.java @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.mediasend; + +public class MediaSendConstants { + public static final int MAX_PUSH = 32; + public static final int MAX_SMS = 1; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java index e9630b4f77..9c1ec7585c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java @@ -51,9 +51,6 @@ class MediaSendViewModel extends ViewModel { private static final String TAG = MediaSendViewModel.class.getSimpleName(); - private static final int MAX_PUSH = 32; - private static final int MAX_SMS = 1; - private final Application application; private final MediaRepository repository; private final MediaUploadRepository uploadRepository; @@ -122,11 +119,11 @@ class MediaSendViewModel extends ViewModel { if (transport.isSms()) { isSms = true; - maxSelection = MAX_SMS; + maxSelection = MediaSendConstants.MAX_SMS; mediaConstraints = MediaConstraints.getMmsMediaConstraints(transport.getSimSubscriptionId().or(-1)); } else { isSms = false; - maxSelection = MAX_PUSH; + maxSelection = MediaSendConstants.MAX_PUSH; mediaConstraints = MediaConstraints.getPushMediaConstraints(); } @@ -151,7 +148,9 @@ class MediaSendViewModel extends ViewModel { if (filteredMedia.size() != newMedia.size()) { error.setValue(Error.ITEM_TOO_LARGE); - } else if (filteredMedia.size() > maxSelection) { + } + + if (filteredMedia.size() > maxSelection) { filteredMedia = filteredMedia.subList(0, maxSelection); error.setValue(Error.TOO_MANY_ITEMS); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java b/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java index 054351d4d9..5d92537886 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java @@ -9,7 +9,6 @@ import android.graphics.Bitmap; import android.graphics.drawable.Icon; import android.os.Build; import android.os.Bundle; -import android.os.Parcel; import android.service.chooser.ChooserTarget; import android.service.chooser.ChooserTargetService; import androidx.annotation.NonNull; @@ -17,7 +16,7 @@ import androidx.annotation.RequiresApi; import androidx.appcompat.view.ContextThemeWrapper; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.ShareActivity; +import org.thoughtcrime.securesms.sharing.ShareActivity; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.ThreadRecord; diff --git a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java similarity index 55% rename from app/src/main/java/org/thoughtcrime/securesms/ShareActivity.java rename to app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java index 7e11ba2d59..d9230d587d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ShareActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java @@ -15,26 +15,28 @@ * along with this program. If not, see . */ -package org.thoughtcrime.securesms; +package org.thoughtcrime.securesms.sharing; -import android.annotation.SuppressLint; -import android.content.Context; import android.content.Intent; -import android.database.Cursor; import android.net.Uri; -import android.os.AsyncTask; import android.os.Bundle; -import android.os.Process; -import android.provider.OpenableColumns; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.ViewModelProviders; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import androidx.appcompat.app.ActionBar; import androidx.appcompat.widget.Toolbar; import android.view.MenuItem; import android.view.View; import android.widget.ImageView; +import android.widget.Toast; +import org.thoughtcrime.securesms.ContactSelectionListFragment; +import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; +import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.SearchToolbar; import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode; import org.thoughtcrime.securesms.conversation.ConversationActivity; @@ -42,31 +44,28 @@ import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mediasend.Media; -import org.thoughtcrime.securesms.mms.PartAuthority; -import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.stickers.StickerLocator; import org.thoughtcrime.securesms.util.DynamicLanguage; import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; import org.thoughtcrime.securesms.util.DynamicTheme; -import org.thoughtcrime.securesms.util.FileUtils; -import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; /** - * An activity to quickly share content with contacts + * Entry point for sharing content into the app. * - * @author Jake McGinty + * Handles contact selection when necessary, but also serves as an entry point for when the contact + * is known (such as choosing someone in a direct share). */ public class ShareActivity extends PassphraseRequiredActionBarActivity implements ContactSelectionListFragment.OnContactSelectedListener, SwipeRefreshLayout.OnRefreshListener @@ -83,10 +82,8 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity private ContactSelectionListFragment contactsFragment; private SearchToolbar searchToolbar; private ImageView searchAction; - private View progressWheel; - private Uri resolvedExtra; - private String mimeType; - private boolean isPassingAlongMedia; + + private ShareViewModel viewModel; @Override protected void onPreCreate() { @@ -114,15 +111,10 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity initializeToolbar(); initializeResources(); initializeSearch(); + initializeViewModel(); initializeMedia(); - } - @Override - protected void onNewIntent(Intent intent) { - Log.i(TAG, "onNewIntent()"); - super.onNewIntent(intent); - setIntent(intent); - initializeMedia(); + handleDestination(); } @Override @@ -134,25 +126,22 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity } @Override - public void onPause() { - super.onPause(); - if (!isPassingAlongMedia && resolvedExtra != null) { - BlobProvider.getInstance().delete(this, resolvedExtra); + public void onStop() { + super.onStop(); - if (!isFinishing()) { - finish(); - } + if (!isFinishing()) { + finish(); } } @Override public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - onBackPressed(); - return true; + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } else { + return super.onOptionsItemSelected(item); } - return super.onOptionsItemSelected(item); } @Override @@ -161,6 +150,30 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity else super.onBackPressed(); } + @Override + public void onContactSelected(Optional recipientId, String number) { + SimpleTask.run(this.getLifecycle(), () -> { + Recipient recipient; + if (recipientId.isPresent()) { + recipient = Recipient.resolved(recipientId.get()); + } else { + Log.i(TAG, "[onContactSelected] Maybe creating a new recipient."); + recipient = Recipient.external(this, number); + } + + long existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient); + return new Pair<>(existingThread, recipient); + }, result -> onDestinationChosen(result.first(), result.second().getId())); + } + + @Override + public void onContactDeselected(@NonNull Optional recipientId, String number) { + } + + @Override + public void onRefresh() { + } + private void initializeToolbar() { Toolbar toolbar = findViewById(R.id.toolbar); setSupportActionBar(toolbar); @@ -173,15 +186,24 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity } private void initializeResources() { - progressWheel = findViewById(R.id.progress_wheel); searchToolbar = findViewById(R.id.search_toolbar); searchAction = findViewById(R.id.search_action); contactsFragment = (ContactSelectionListFragment) getSupportFragmentManager().findFragmentById(R.id.contact_selection_list_fragment); + + if (contactsFragment == null) { + throw new IllegalStateException("Could not find contacts fragment!"); + } + contactsFragment.setOnContactSelectedListener(this); contactsFragment.setOnRefreshListener(this); } + private void initializeViewModel() { + this.viewModel = ViewModelProviders.of(this, new ShareViewModel.Factory()).get(ShareViewModel.class); + } + private void initializeSearch() { + //noinspection IntegerDivisionInFloatingPointContext searchAction.setOnClickListener(v -> searchToolbar.display(searchAction.getX() + (searchAction.getWidth() / 2), searchAction.getY() + (searchAction.getHeight() / 2))); @@ -203,24 +225,25 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity } private void initializeMedia() { - final Context context = this; - isPassingAlongMedia = false; + if (Intent.ACTION_SEND_MULTIPLE.equals(getIntent().getAction())) { + Log.i(TAG, "Multiple media share."); + List uris = getIntent().getParcelableArrayListExtra(Intent.EXTRA_STREAM); - Uri streamExtra = getIntent().getParcelableExtra(Intent.EXTRA_STREAM); - mimeType = getMimeType(streamExtra); + viewModel.onMultipleMediaShared(uris); + } else if (Intent.ACTION_SEND.equals(getIntent().getAction()) || getIntent().hasExtra(Intent.EXTRA_STREAM)) { + Log.i(TAG, "Single media share."); + Uri uri = getIntent().getParcelableExtra(Intent.EXTRA_STREAM); + String type = getIntent().getType(); - if (streamExtra != null && PartAuthority.isLocalUri(streamExtra)) { - isPassingAlongMedia = true; - resolvedExtra = streamExtra; - handleResolvedMedia(getIntent(), false); + viewModel.onSingleMediaShared(uri, type); } else { - contactsFragment.getView().setVisibility(View.GONE); - progressWheel.setVisibility(View.VISIBLE); - new ResolveMediaTask(context).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, streamExtra); + Log.i(TAG, "Internal media share."); + viewModel.onNonExternalShare(); } } - private void handleResolvedMedia(Intent intent, boolean animate) { + private void handleDestination() { + Intent intent = getIntent(); long threadId = intent.getLongExtra(EXTRA_THREAD_ID, -1); int distributionType = intent.getIntExtra(EXTRA_DISTRIBUTION_TYPE, -1); RecipientId recipientId = null; @@ -229,151 +252,75 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity recipientId = RecipientId.from(intent.getStringExtra(EXTRA_RECIPIENT_ID)); } - boolean hasResolvedDestination = threadId != -1 && recipientId != null && distributionType != -1; + boolean hasPreexistingDestination = threadId != -1 && recipientId != null && distributionType != -1; - if (hasResolvedDestination) { - createConversation(threadId, recipientId, distributionType); - } else if (animate) { - ViewUtil.fadeIn(contactsFragment.requireView(), 300); - ViewUtil.fadeOut(progressWheel, 300); - } else { - contactsFragment.requireView().setVisibility(View.VISIBLE); - progressWheel.setVisibility(View.GONE); + if (hasPreexistingDestination) { + if (contactsFragment.getView() != null) { + contactsFragment.getView().setVisibility(View.GONE); + } + onDestinationChosen(threadId, recipientId); } } - private void createConversation(long threadId, @NonNull RecipientId recipientId, int distributionType) { - final Intent intent = getBaseShareIntent(ConversationActivity.class); - intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipientId); - intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId); - intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, distributionType); + private void onDestinationChosen(long threadId, @NonNull RecipientId recipientId) { + if (!viewModel.isExternalShare()) { + openConversation(threadId, recipientId, null); + return; + } - isPassingAlongMedia = true; - startActivity(intent); + AtomicReference progressWheel = new AtomicReference<>(); + + if (viewModel.getShareData().getValue() == null) { + progressWheel.set(SimpleProgressDialog.show(this)); + } + + viewModel.getShareData().observe(this, (data) -> { + if (data == null) return; + + if (progressWheel.get() != null) { + progressWheel.get().dismiss(); + progressWheel.set(null); + } + + if (!data.isPresent()) { + Log.w(TAG, "No data to share!"); + Toast.makeText(this, R.string.ShareActivity_multiple_attachments_are_only_supported, Toast.LENGTH_LONG).show(); + finish(); + return; + } + + openConversation(threadId, recipientId, data.get()); + }); } - private Intent getBaseShareIntent(final @NonNull Class target) { - final Intent intent = new Intent(this, target); - final String textExtra = getIntent().getStringExtra(Intent.EXTRA_TEXT); - final ArrayList mediaExtra = getIntent().getParcelableArrayListExtra(ConversationActivity.MEDIA_EXTRA); - final StickerLocator stickerExtra = getIntent().getParcelableExtra(ConversationActivity.STICKER_EXTRA); + private void openConversation(long threadId, @NonNull RecipientId recipientId, @Nullable ShareData shareData) { + Intent intent = new Intent(this, ConversationActivity.class); + String textExtra = getIntent().getStringExtra(Intent.EXTRA_TEXT); + ArrayList mediaExtra = getIntent().getParcelableArrayListExtra(ConversationActivity.MEDIA_EXTRA); + StickerLocator stickerExtra = getIntent().getParcelableExtra(ConversationActivity.STICKER_EXTRA); intent.putExtra(ConversationActivity.TEXT_EXTRA, textExtra); intent.putExtra(ConversationActivity.MEDIA_EXTRA, mediaExtra); intent.putExtra(ConversationActivity.STICKER_EXTRA, stickerExtra); - if (resolvedExtra != null) intent.setDataAndType(resolvedExtra, mimeType); - - return intent; - } - - private String getMimeType(@Nullable Uri uri) { - if (uri != null) { - final String mimeType = MediaUtil.getMimeType(getApplicationContext(), uri); - if (mimeType != null) return mimeType; - } - return MediaUtil.getCorrectedMimeType(getIntent().getType()); - } - - @Override - public void onContactSelected(Optional recipientId, String number) { - SimpleTask.run(this.getLifecycle(), () -> { - Recipient recipient; - if (recipientId.isPresent()) { - recipient = Recipient.resolved(recipientId.get()); - } else { - Log.i(TAG, "[onContactSelected] Maybe creating a new recipient."); - recipient = Recipient.external(this, number); - } - - long existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient); - return new Pair<>(existingThread, recipient); - }, result -> { - createConversation(result.first(), result.second().getId(), ThreadDatabase.DistributionTypes.DEFAULT); - }); - } - - @Override - public void onContactDeselected(@NonNull Optional recipientId, String number) { - - } - - @Override - public void onRefresh() { - - } - - @SuppressLint("StaticFieldLeak") - private class ResolveMediaTask extends AsyncTask { - private final Context context; - - ResolveMediaTask(Context context) { - this.context = context; + if (shareData != null && shareData.isForIntent()) { + Log.i(TAG, "Shared data is a single file."); + intent.setDataAndType(shareData.getUri(), shareData.getMimeType()); + } else if (shareData != null && shareData.isForMedia()) { + Log.i(TAG, "Shared data is set of media."); + intent.putExtra(ConversationActivity.MEDIA_EXTRA, shareData.getMedia()); + } else if (shareData != null && shareData.isForPrimitive()) { + Log.i(TAG, "Shared data is a primitive type."); + } else { + Log.i(TAG, "Shared data was not external."); } - @Override - protected Uri doInBackground(Uri... uris) { - try { - if (uris.length != 1 || uris[0] == null) { - return null; - } + intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipientId); + intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId); + intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, ThreadDatabase.DistributionTypes.DEFAULT); - InputStream inputStream; + viewModel.onSuccessulShare(); - if ("file".equals(uris[0].getScheme())) { - inputStream = openFileUri(uris[0]); - } else { - inputStream = context.getContentResolver().openInputStream(uris[0]); - } - - if (inputStream == null) { - return null; - } - - Cursor cursor = getContentResolver().query(uris[0], new String[] {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}, null, null, null); - String fileName = null; - Long fileSize = null; - - try { - if (cursor != null && cursor.moveToFirst()) { - try { - fileName = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)); - fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); - } catch (IllegalArgumentException e) { - Log.w(TAG, e); - } - } - } finally { - if (cursor != null) cursor.close(); - } - - return BlobProvider.getInstance() - .forData(inputStream, fileSize == null ? 0 : fileSize) - .withMimeType(mimeType) - .withFileName(fileName) - .createForMultipleSessionsOnDisk(context); - } catch (IOException ioe) { - Log.w(TAG, ioe); - return null; - } - } - - @Override - protected void onPostExecute(Uri uri) { - resolvedExtra = uri; - handleResolvedMedia(getIntent(), true); - } - - private InputStream openFileUri(Uri uri) throws IOException { - FileInputStream fin = new FileInputStream(uri.getPath()); - int owner = FileUtils.getFileDescriptorOwner(fin.getFD()); - - if (owner == -1 || owner == Process.myUid()) { - fin.close(); - throw new IOException("File owned by application"); - } - - return fin; - } + startActivity(intent); } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareData.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareData.java new file mode 100644 index 0000000000..483896c1ab --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareData.java @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.sharing; + +import android.net.Uri; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.mediasend.Media; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.ArrayList; +import java.util.List; + +class ShareData { + + private final Optional uri; + private final Optional mimeType; + private final Optional> media; + private final boolean external; + + static ShareData forIntentData(@NonNull Uri uri, @NonNull String mimeType, boolean external) { + return new ShareData(Optional.of(uri), Optional.of(mimeType), Optional.absent(), external); + } + + static ShareData forPrimitiveTypes() { + return new ShareData(Optional.absent(), Optional.absent(), Optional.absent(), true); + } + + static ShareData forMedia(@NonNull List media) { + return new ShareData(Optional.absent(), Optional.absent(), Optional.of(new ArrayList<>(media)), true); + } + + private ShareData(Optional uri, Optional mimeType, Optional> media, boolean external) { + this.uri = uri; + this.mimeType = mimeType; + this.media = media; + this.external = external; + } + + boolean isForIntent() { + return uri.isPresent(); + } + + boolean isForPrimitive() { + return !uri.isPresent() && !media.isPresent(); + } + + boolean isForMedia() { + return media.isPresent(); + } + + public @NonNull Uri getUri() { + return uri.get(); + } + + public @NonNull String getMimeType() { + return mimeType.get(); + } + + public @NonNull ArrayList getMedia() { + return media.get(); + } + + public boolean isExternal() { + return external; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareRepository.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareRepository.java new file mode 100644 index 0000000000..053d0a8e07 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareRepository.java @@ -0,0 +1,209 @@ +package org.thoughtcrime.securesms.sharing; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mediasend.MediaSendConstants; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +class ShareRepository { + + private static final String TAG = Log.tag(ShareRepository.class); + + /** + * Handles a single URI that may be local or external. + */ + void getResolved(@NonNull Uri uri, @Nullable String mimeType, @NonNull Callback> callback) { + SignalExecutors.BOUNDED.execute(() -> { + try { + callback.onResult(Optional.of(getResolvedInternal(uri, mimeType))); + } catch (IOException e) { + Log.w(TAG, "Failed to resolve!", e); + callback.onResult(Optional.absent()); + } + }); + } + + /** + * Handles multiple URIs that are all assumed to be external images/videos. + */ + void getResolved(@NonNull List uris, @NonNull Callback> callback) { + SignalExecutors.BOUNDED.execute(() -> { + try { + callback.onResult(Optional.fromNullable(getResolvedInternal(uris))); + } catch (IOException e) { + Log.w(TAG, "Failed to resolve!", e); + callback.onResult(Optional.absent()); + } + }); + } + + + + @WorkerThread + private @NonNull ShareData getResolvedInternal(@Nullable Uri uri, @Nullable String mimeType) throws IOException { + Context context = ApplicationDependencies.getApplication(); + + if (uri == null) { + return ShareData.forPrimitiveTypes(); + } + + if (mimeType == null) { + mimeType = context.getContentResolver().getType(uri); + } + + if (PartAuthority.isLocalUri(uri) && mimeType != null) { + return ShareData.forIntentData(uri, mimeType, false); + } else { + InputStream stream = context.getContentResolver().openInputStream(uri); + + if (stream == null) { + throw new IOException("Failed to open stream!"); + } + + long size = getSize(context, uri); + String fileName = getFileName(context, uri); + String fillMimeType = Optional.fromNullable(mimeType).or(MediaUtil.UNKNOWN); + + Uri blobUri; + + if (MediaUtil.isImageType(fillMimeType) || MediaUtil.isVideoType(fillMimeType)) { + blobUri = BlobProvider.getInstance() + .forData(stream, size) + .withMimeType(fillMimeType) + .withFileName(fileName) + .createForSingleSessionOnDisk(context); + } else { + blobUri = BlobProvider.getInstance() + .forData(stream, size) + .withMimeType(fillMimeType) + .withFileName(fileName) + .createForMultipleSessionsOnDisk(context); + } + + return ShareData.forIntentData(blobUri, fillMimeType, true); + } + } + + @WorkerThread + private @Nullable + ShareData getResolvedInternal(@NonNull List uris) throws IOException { + Context context = ApplicationDependencies.getApplication(); + ContentResolver resolver = context.getContentResolver(); + + Map mimeTypes = Stream.of(uris) + .map(uri -> new Pair<>(uri, Optional.fromNullable(resolver.getType(uri)).or(MediaUtil.UNKNOWN))) + .filter(p -> MediaUtil.isImageType(p.second) || MediaUtil.isVideoType(p.second)) + .collect(Collectors.toMap(p -> p.first, p -> p.second)); + + if (mimeTypes.isEmpty()) { + return null; + } + + List media = new ArrayList<>(mimeTypes.size()); + + for (Map.Entry entry : mimeTypes.entrySet()) { + Uri uri = entry.getKey(); + String mimeType = entry.getValue(); + + InputStream stream; + try { + stream = context.getContentResolver().openInputStream(uri); + if (stream == null) { + throw new IOException("Failed to open stream!"); + } + } catch (IOException e) { + Log.w(TAG, "Failed to open: " + uri); + continue; + } + + long size = getSize(context, uri); + Pair dimens = MediaUtil.getDimensions(context, mimeType, uri); + long duration = getDuration(context, uri); + Uri blobUri = BlobProvider.getInstance() + .forData(stream, size) + .withMimeType(mimeType) + .createForSingleSessionOnDisk(context); + + media.add(new Media(blobUri, + mimeType, + System.currentTimeMillis(), + dimens.first, + dimens.second, + size, + duration, + Optional.of(Media.ALL_MEDIA_BUCKET_ID), + Optional.absent(), + Optional.absent())); + + if (media.size() >= MediaSendConstants.MAX_PUSH) { + Log.w(TAG, "Exceeded the attachment limit! Skipping the rest."); + break; + } + } + + if (media.size() > 0) { + return ShareData.forMedia(media); + } else { + return null; + } + } + + private static long getSize(@NonNull Context context, @NonNull Uri uri) throws IOException { + long size = 0; + + try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.SIZE) >= 0) { + size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); + } + } + + if (size <= 0) { + size = MediaUtil.getMediaSize(context, uri); + } + + return size; + } + + private static @NonNull String getFileName(@NonNull Context context, @NonNull Uri uri) { + try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) >= 0) { + return cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)); + } + } + + return ""; + } + + private static long getDuration(@NonNull Context context, @NonNull Uri uri) { + return 0; + } + + interface Callback { + void onResult(@NonNull E result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareViewModel.java new file mode 100644 index 0000000000..7aa4d9e960 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareViewModel.java @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.sharing; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.providers.BlobProvider; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.List; + +public class ShareViewModel extends ViewModel { + + private static final String TAG = Log.tag(ShareViewModel.class); + + private final Context context; + private final ShareRepository shareRepository; + private final MutableLiveData> shareData; + + private boolean mediaUsed; + private boolean externalShare; + + private ShareViewModel() { + this.context = ApplicationDependencies.getApplication(); + this.shareRepository = new ShareRepository(); + this.shareData = new MutableLiveData<>(); + } + + void onSingleMediaShared(@NonNull Uri uri, @Nullable String mimeType) { + externalShare = true; + shareRepository.getResolved(uri, mimeType, shareData::postValue); + } + + void onMultipleMediaShared(@NonNull List uris) { + externalShare = true; + shareRepository.getResolved(uris, shareData::postValue); + } + + void onNonExternalShare() { + externalShare = false; + } + + void onSuccessulShare() { + mediaUsed = true; + } + + @NonNull LiveData> getShareData() { + return shareData; + } + + boolean isExternalShare() { + return externalShare; + } + + @Override + protected void onCleared() { + ShareData data = shareData.getValue() != null ? shareData.getValue().orNull() : null; + + if (data != null && data.isExternal() && !mediaUsed) { + Log.i(TAG, "Clearing out unused data."); + BlobProvider.getInstance().delete(context, data.getUri()); + } + } + + public static class Factory extends ViewModelProvider.NewInstanceFactory { + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new ShareViewModel()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.java index df524556aa..30a8b3cbd4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.java @@ -12,8 +12,7 @@ import android.view.MenuItem; import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.ShareActivity; -import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.sharing.ShareActivity; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.util.DynamicTheme; diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java index bee54d7cfd..f7ee2a9b1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java @@ -5,8 +5,6 @@ import androidx.lifecycle.ViewModelProviders; import android.content.Intent; import android.content.res.Configuration; import android.graphics.Point; -import android.net.Uri; -import android.os.Build; import android.os.Bundle; import androidx.annotation.NonNull; import androidx.recyclerview.widget.GridLayoutManager; @@ -19,23 +17,15 @@ import android.widget.Toast; import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; -import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.ShareActivity; -import org.thoughtcrime.securesms.database.model.StickerRecord; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob; +import org.thoughtcrime.securesms.sharing.ShareActivity; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; import org.thoughtcrime.securesms.mms.GlideApp; -import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.stickers.StickerManifest.Sticker; import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; import org.thoughtcrime.securesms.util.DynamicTheme; -import org.thoughtcrime.securesms.util.FeatureFlags; -import org.thoughtcrime.securesms.util.ThemeUtil; -import org.thoughtcrime.securesms.util.concurrent.SimpleTask; import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java index 3304025cba..49562ccf01 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java @@ -58,6 +58,7 @@ public class MediaUtil { public static final String VCARD = "text/x-vcard"; public static final String LONG_TEXT = "text/x-signal-plain"; public static final String VIEW_ONCE = "application/x-signal-view-once"; + public static final String UNKNOWN = "*/*"; public static SlideType getSlideTypeFromContentType(@NonNull String contentType) { if (isGif(contentType)) { diff --git a/app/src/main/res/layout/share_activity.xml b/app/src/main/res/layout/share_activity.xml index a0236224d3..8dad942c7a 100644 --- a/app/src/main/res/layout/share_activity.xml +++ b/app/src/main/res/layout/share_activity.xml @@ -56,11 +56,4 @@ android:visibility="invisible" tools:visibility="invisible"/> - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 25ef4276e7..52155fbb6a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -371,6 +371,7 @@ Share with + Multiple attachments are only supported for images and videos Welcome to Signal.