Support sharing multiple photos/videos into Signal.

This commit is contained in:
Greyson Parrelli
2020-02-05 16:34:54 -05:00
parent 7ab240643e
commit 9bac88697b
17 changed files with 517 additions and 218 deletions

View File

@@ -0,0 +1,326 @@
/*
* Copyright (C) 2014-2017 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.sharing;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
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;
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.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.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.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
/**
* Entry point for sharing content into the app.
*
* 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
{
private static final String TAG = ShareActivity.class.getSimpleName();
public static final String EXTRA_THREAD_ID = "thread_id";
public static final String EXTRA_RECIPIENT_ID = "recipient_id";
public static final String EXTRA_DISTRIBUTION_TYPE = "distribution_type";
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
private ContactSelectionListFragment contactsFragment;
private SearchToolbar searchToolbar;
private ImageView searchAction;
private ShareViewModel viewModel;
@Override
protected void onPreCreate() {
dynamicTheme.onCreate(this);
dynamicLanguage.onCreate(this);
}
@Override
protected void onCreate(Bundle icicle, boolean ready) {
if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) {
int mode = DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS;
if (TextSecurePreferences.isSmsEnabled(this)) {
mode |= DisplayMode.FLAG_SMS;
}
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, mode);
}
getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false);
getIntent().putExtra(ContactSelectionListFragment.RECENTS, true);
setContentView(R.layout.share_activity);
initializeToolbar();
initializeResources();
initializeSearch();
initializeViewModel();
initializeMedia();
handleDestination();
}
@Override
public void onResume() {
Log.i(TAG, "onResume()");
super.onResume();
dynamicTheme.onResume(this);
dynamicLanguage.onResume(this);
}
@Override
public void onStop() {
super.onStop();
if (!isFinishing()) {
finish();
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == android.R.id.home) {
onBackPressed();
return true;
} else {
return super.onOptionsItemSelected(item);
}
}
@Override
public void onBackPressed() {
if (searchToolbar.isVisible()) searchToolbar.collapse();
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);
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true);
}
}
private void initializeResources() {
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)));
searchToolbar.setListener(new SearchToolbar.SearchListener() {
@Override
public void onSearchTextChange(String text) {
if (contactsFragment != null) {
contactsFragment.setQueryFilter(text);
}
}
@Override
public void onSearchClosed() {
if (contactsFragment != null) {
contactsFragment.resetQueryFilter();
}
}
});
}
private void initializeMedia() {
if (Intent.ACTION_SEND_MULTIPLE.equals(getIntent().getAction())) {
Log.i(TAG, "Multiple media share.");
List<Uri> uris = getIntent().getParcelableArrayListExtra(Intent.EXTRA_STREAM);
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();
viewModel.onSingleMediaShared(uri, type);
} else {
Log.i(TAG, "Internal media share.");
viewModel.onNonExternalShare();
}
}
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;
if (intent.hasExtra(EXTRA_RECIPIENT_ID)) {
recipientId = RecipientId.from(intent.getStringExtra(EXTRA_RECIPIENT_ID));
}
boolean hasPreexistingDestination = threadId != -1 && recipientId != null && distributionType != -1;
if (hasPreexistingDestination) {
if (contactsFragment.getView() != null) {
contactsFragment.getView().setVisibility(View.GONE);
}
onDestinationChosen(threadId, recipientId);
}
}
private void onDestinationChosen(long threadId, @NonNull RecipientId recipientId) {
if (!viewModel.isExternalShare()) {
openConversation(threadId, recipientId, null);
return;
}
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 (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.");
}
intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipientId);
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, ThreadDatabase.DistributionTypes.DEFAULT);
viewModel.onSuccessulShare();
startActivity(intent);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}
}