Support sharing multiple photos/videos into Signal.
This commit is contained in:
parent
7ab240643e
commit
9bac88697b
17 changed files with 517 additions and 218 deletions
|
@ -148,7 +148,7 @@
|
|||
<activity android:name=".preferences.MmsPreferencesActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".ShareActivity"
|
||||
<activity android:name=".sharing.ShareActivity"
|
||||
android:theme="@style/TextSecure.LightNoActionBar"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTask"
|
||||
|
@ -156,7 +156,6 @@
|
|||
android:noHistory="true"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
|
@ -169,6 +168,13 @@
|
|||
<data android:mimeType="*/*"/>
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.service.chooser.chooser_target_service"
|
||||
android:value=".service.DirectShareService" />
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -15,26 +15,28 @@
|
|||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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,26 +126,23 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home:
|
||||
if (item.getItemId() == android.R.id.home) {
|
||||
onBackPressed();
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
|
@ -161,6 +150,30 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity
|
|||
else super.onBackPressed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactSelected(Optional<RecipientId> 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> 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<Uri> 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);
|
||||
|
||||
isPassingAlongMedia = true;
|
||||
startActivity(intent);
|
||||
private void onDestinationChosen(long threadId, @NonNull RecipientId recipientId) {
|
||||
if (!viewModel.isExternalShare()) {
|
||||
openConversation(threadId, recipientId, null);
|
||||
return;
|
||||
}
|
||||
|
||||
private Intent getBaseShareIntent(final @NonNull Class<?> target) {
|
||||
final Intent intent = new Intent(this, target);
|
||||
final String textExtra = getIntent().getStringExtra(Intent.EXTRA_TEXT);
|
||||
final ArrayList<Media> mediaExtra = getIntent().getParcelableArrayListExtra(ConversationActivity.MEDIA_EXTRA);
|
||||
final StickerLocator stickerExtra = getIntent().getParcelableExtra(ConversationActivity.STICKER_EXTRA);
|
||||
AtomicReference<AlertDialog> 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 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<Media> 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> recipientId, String number) {
|
||||
SimpleTask.run(this.getLifecycle(), () -> {
|
||||
Recipient recipient;
|
||||
if (recipientId.isPresent()) {
|
||||
recipient = Recipient.resolved(recipientId.get());
|
||||
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, "[onContactSelected] Maybe creating a new recipient.");
|
||||
recipient = Recipient.external(this, number);
|
||||
Log.i(TAG, "Shared data was not external.");
|
||||
}
|
||||
|
||||
long existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient);
|
||||
return new Pair<>(existingThread, recipient);
|
||||
}, result -> {
|
||||
createConversation(result.first(), result.second().getId(), ThreadDatabase.DistributionTypes.DEFAULT);
|
||||
});
|
||||
}
|
||||
intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipientId);
|
||||
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
|
||||
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, ThreadDatabase.DistributionTypes.DEFAULT);
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
|
||||
viewModel.onSuccessulShare();
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRefresh() {
|
||||
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private class ResolveMediaTask extends AsyncTask<Uri, Void, Uri> {
|
||||
private final Context context;
|
||||
|
||||
ResolveMediaTask(Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Uri doInBackground(Uri... uris) {
|
||||
try {
|
||||
if (uris.length != 1 || uris[0] == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
InputStream inputStream;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -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> uri;
|
||||
private final Optional<String> mimeType;
|
||||
private final Optional<ArrayList<Media>> 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> media) {
|
||||
return new ShareData(Optional.absent(), Optional.absent(), Optional.of(new ArrayList<>(media)), true);
|
||||
}
|
||||
|
||||
private ShareData(Optional<Uri> uri, Optional<String> mimeType, Optional<ArrayList<Media>> 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<Media> getMedia() {
|
||||
return media.get();
|
||||
}
|
||||
|
||||
public boolean isExternal() {
|
||||
return external;
|
||||
}
|
||||
}
|
|
@ -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<Optional<ShareData>> 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<Uri> uris, @NonNull Callback<Optional<ShareData>> 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<Uri> uris) throws IOException {
|
||||
Context context = ApplicationDependencies.getApplication();
|
||||
ContentResolver resolver = context.getContentResolver();
|
||||
|
||||
Map<Uri, String> 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> media = new ArrayList<>(mimeTypes.size());
|
||||
|
||||
for (Map.Entry<Uri, String> 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<Integer, Integer> 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<E> {
|
||||
void onResult(@NonNull E result);
|
||||
}
|
||||
}
|
|
@ -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<Optional<ShareData>> 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<Uri> uris) {
|
||||
externalShare = true;
|
||||
shareRepository.getResolved(uris, shareData::postValue);
|
||||
}
|
||||
|
||||
void onNonExternalShare() {
|
||||
externalShare = false;
|
||||
}
|
||||
|
||||
void onSuccessulShare() {
|
||||
mediaUsed = true;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Optional<ShareData>> 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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection ConstantConditions
|
||||
return modelClass.cast(new ShareViewModel());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -56,11 +56,4 @@
|
|||
android:visibility="invisible"
|
||||
tools:visibility="invisible"/>
|
||||
|
||||
<com.pnikosis.materialishprogress.ProgressWheel android:id="@+id/progress_wheel"
|
||||
android:layout_width="70dp"
|
||||
android:layout_height="70dp"
|
||||
android:layout_centerInParent="true"
|
||||
wheel:matProg_barColor="?title_text_color_primary"
|
||||
wheel:matProg_progressIndeterminate="true" />
|
||||
|
||||
</RelativeLayout>
|
||||
|
|
|
@ -371,6 +371,7 @@
|
|||
|
||||
<!-- ShareActivity -->
|
||||
<string name="ShareActivity_share_with">Share with</string>
|
||||
<string name="ShareActivity_multiple_attachments_are_only_supported">Multiple attachments are only supported for images and videos</string>
|
||||
|
||||
<!-- ExperienceUpgradeActivity -->
|
||||
<string name="ExperienceUpgradeActivity_welcome_to_signal_dgaf">Welcome to Signal.</string>
|
||||
|
|
Loading…
Add table
Reference in a new issue