diff --git a/res/drawable-v21/camera_send_button_background.xml b/res/drawable-v21/camera_send_button_background.xml
new file mode 100644
index 0000000000..6ef9e5e0cf
--- /dev/null
+++ b/res/drawable-v21/camera_send_button_background.xml
@@ -0,0 +1,17 @@
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
diff --git a/res/drawable-v21/media_continue_button_background.xml b/res/drawable-v21/media_continue_button_background.xml
new file mode 100644
index 0000000000..6ef9e5e0cf
--- /dev/null
+++ b/res/drawable-v21/media_continue_button_background.xml
@@ -0,0 +1,17 @@
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
diff --git a/res/drawable/camera_send_button_background.xml b/res/drawable/camera_send_button_background.xml
new file mode 100644
index 0000000000..f612f8c4f3
--- /dev/null
+++ b/res/drawable/camera_send_button_background.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/res/drawable/ic_continue_24.xml b/res/drawable/ic_continue_24.xml
new file mode 100644
index 0000000000..7c3cdcb7ef
--- /dev/null
+++ b/res/drawable/ic_continue_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/res/drawable/media_continue_button_background.xml b/res/drawable/media_continue_button_background.xml
new file mode 100644
index 0000000000..f612f8c4f3
--- /dev/null
+++ b/res/drawable/media_continue_button_background.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/camera_contact_contact_item.xml b/res/layout/camera_contact_contact_item.xml
new file mode 100644
index 0000000000..b881a74b0e
--- /dev/null
+++ b/res/layout/camera_contact_contact_item.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/camera_contact_header_item.xml b/res/layout/camera_contact_header_item.xml
new file mode 100644
index 0000000000..1d48ce16b7
--- /dev/null
+++ b/res/layout/camera_contact_header_item.xml
@@ -0,0 +1,14 @@
+
+
diff --git a/res/layout/camera_contact_invite_item.xml b/res/layout/camera_contact_invite_item.xml
new file mode 100644
index 0000000000..a944b00f14
--- /dev/null
+++ b/res/layout/camera_contact_invite_item.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/camera_contact_selection_fragment.xml b/res/layout/camera_contact_selection_fragment.xml
new file mode 100644
index 0000000000..eb2416c1ef
--- /dev/null
+++ b/res/layout/camera_contact_selection_fragment.xml
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/layout/camera_contact_selection_item.xml b/res/layout/camera_contact_selection_item.xml
new file mode 100644
index 0000000000..edb428e4d8
--- /dev/null
+++ b/res/layout/camera_contact_selection_item.xml
@@ -0,0 +1,7 @@
+
+
diff --git a/res/layout/camera_controls_landscape.xml b/res/layout/camera_controls_landscape.xml
index 71fb9022d2..b0783f7c1d 100644
--- a/res/layout/camera_controls_landscape.xml
+++ b/res/layout/camera_controls_landscape.xml
@@ -35,6 +35,7 @@
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginBottom="42dp"
+ android:scaleType="centerCrop"
app:layout_constraintBottom_toTopOf="@id/camera_capture_button"
app:layout_constraintStart_toStartOf="@id/camera_capture_button"
app:layout_constraintEnd_toEndOf="@id/camera_capture_button"
diff --git a/res/layout/camera_controls_portrait.xml b/res/layout/camera_controls_portrait.xml
index 5b43bfae47..69b607372f 100644
--- a/res/layout/camera_controls_portrait.xml
+++ b/res/layout/camera_controls_portrait.xml
@@ -35,6 +35,7 @@
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginStart="32dp"
+ android:scaleType="centerCrop"
app:layout_constraintTop_toTopOf="@id/camera_capture_button"
app:layout_constraintBottom_toBottomOf="@id/camera_capture_button"
app:layout_constraintStart_toStartOf="parent"
diff --git a/res/layout/conversation_list_fragment.xml b/res/layout/conversation_list_fragment.xml
index fcb0ef7765..234097a7ab 100644
--- a/res/layout/conversation_list_fragment.xml
+++ b/res/layout/conversation_list_fragment.xml
@@ -66,6 +66,17 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index 50d8486a32..8eb8b7535a 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -26,6 +26,8 @@
104dp
69dp
+ 16dp
+
16dp
4dp
2dp
diff --git a/res/values/strings.xml b/res/values/strings.xml
index fd9a2b082f..20f916e495 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -91,6 +91,17 @@
Failed to save image.
+
+ Recent contacts
+ Signal contacts
+ Signal groups
+ You can share with a maximum of %d conversations.
+ Select Signal recipients
+ No Signal contacts
+ You can only use the camera button to send photos to Signal contacts.
+ Can\'t find who you\'re looking for?
+ Invite a contact to join Signal
+
Remove
Remove profile photo?
@@ -480,6 +491,8 @@
An item was removed because it exceeded the size limit
Camera unavailable.
Message to %s
+ Message
+ Select recipients
- You can\'t share more than %d item.
- You can\'t share more than %d items.
diff --git a/src/org/thoughtcrime/securesms/ConversationListFragment.java b/src/org/thoughtcrime/securesms/ConversationListFragment.java
index 6853ebfb01..19c9cfefa4 100644
--- a/src/org/thoughtcrime/securesms/ConversationListFragment.java
+++ b/src/org/thoughtcrime/securesms/ConversationListFragment.java
@@ -16,6 +16,7 @@
*/
package org.thoughtcrime.securesms;
+import android.Manifest;
import android.annotation.SuppressLint;
import android.app.ProgressDialog;
import android.content.Context;
@@ -32,6 +33,8 @@ import android.os.Build;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.snackbar.Snackbar;
import androidx.fragment.app.Fragment;
import androidx.loader.app.LoaderManager;
@@ -51,6 +54,7 @@ import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
+import android.widget.Toast;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
@@ -74,9 +78,11 @@ import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.database.loaders.ConversationListLoader;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
+import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
+import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -111,6 +117,7 @@ public class ConversationListFragment extends Fragment
private ImageView emptyImage;
private TextView emptySearch;
private PulsingFloatingActionButton fab;
+ private FloatingActionButton cameraFab;
private Locale locale;
private String queryFilter = "";
private boolean archive;
@@ -129,6 +136,7 @@ public class ConversationListFragment extends Fragment
reminderView = ViewUtil.findById(view, R.id.reminder);
list = ViewUtil.findById(view, R.id.list);
fab = ViewUtil.findById(view, R.id.fab);
+ cameraFab = ViewUtil.findById(view, R.id.camera_fab);
emptyState = ViewUtil.findById(view, R.id.empty_state);
emptyImage = ViewUtil.findById(view, R.id.empty);
emptySearch = ViewUtil.findById(view, R.id.empty_search);
@@ -153,6 +161,16 @@ public class ConversationListFragment extends Fragment
setHasOptionsMenu(true);
fab.setOnClickListener(v -> startActivity(new Intent(getActivity(), NewConversationActivity.class)));
+ cameraFab.setOnClickListener(v -> {
+ Permissions.with(requireActivity())
+ .request(Manifest.permission.CAMERA)
+ .ifNecessary()
+ .withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_photo_camera_white_48dp)
+ .withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video))
+ .onAllGranted(() -> startActivity(MediaSendActivity.buildCameraFirstIntent(requireActivity())))
+ .onAnyDenied(() -> Toast.makeText(requireContext(), R.string.ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video, Toast.LENGTH_LONG).show())
+ .execute();
+ });
initializeListAdapter();
initializeTypingObserver();
}
diff --git a/src/org/thoughtcrime/securesms/TransportOptions.java b/src/org/thoughtcrime/securesms/TransportOptions.java
index 4e5cb91d34..ab7c2166b3 100644
--- a/src/org/thoughtcrime/securesms/TransportOptions.java
+++ b/src/org/thoughtcrime/securesms/TransportOptions.java
@@ -106,6 +106,16 @@ public class TransportOptions {
throw new AssertionError("No options of default type!");
}
+ public static @NonNull TransportOption getPushTransportOption(@NonNull Context context) {
+ return new TransportOption(Type.TEXTSECURE,
+ R.drawable.ic_send_push_white_24dp,
+ context.getResources().getColor(R.color.textsecure_primary),
+ context.getString(R.string.ConversationActivity_transport_signal),
+ context.getString(R.string.conversation_activity__type_message_push),
+ new PushCharacterCalculator());
+
+ }
+
private @Nullable TransportOption findEnabledSmsTransportOption(Optional subscriptionId) {
if (subscriptionId.isPresent()) {
final int subId = subscriptionId.get();
@@ -157,11 +167,7 @@ public class TransportOptions {
new SmsCharacterCalculator()));
}
- results.add(new TransportOption(Type.TEXTSECURE, R.drawable.ic_send_push_white_24dp,
- context.getResources().getColor(R.color.textsecure_primary),
- context.getString(R.string.ConversationActivity_transport_signal),
- context.getString(R.string.conversation_activity__type_message_push),
- new PushCharacterCalculator()));
+ results.add(getPushTransportOption(context));
return results;
}
diff --git a/src/org/thoughtcrime/securesms/components/FromTextView.java b/src/org/thoughtcrime/securesms/components/FromTextView.java
index 06acfcec67..69647ce668 100644
--- a/src/org/thoughtcrime/securesms/components/FromTextView.java
+++ b/src/org/thoughtcrime/securesms/components/FromTextView.java
@@ -2,6 +2,9 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.Typeface;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import android.text.Spannable;
import android.text.SpannableString;
@@ -36,6 +39,10 @@ public class FromTextView extends EmojiTextView {
}
public void setText(Recipient recipient, boolean read) {
+ setText(recipient, read, null);
+ }
+
+ public void setText(Recipient recipient, boolean read, @Nullable String suffix) {
String fromString = recipient.toShortString();
int typeface;
@@ -72,6 +79,10 @@ public class FromTextView extends EmojiTextView {
builder.append(fromSpan);
}
+ if (suffix != null) {
+ builder.append(suffix);
+ }
+
setText(builder);
if (recipient.isBlocked()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_block_grey600_18dp, 0, 0, 0);
diff --git a/src/org/thoughtcrime/securesms/database/AttachmentDatabase.java b/src/org/thoughtcrime/securesms/database/AttachmentDatabase.java
index f3e6995f0e..098de8d9d5 100644
--- a/src/org/thoughtcrime/securesms/database/AttachmentDatabase.java
+++ b/src/org/thoughtcrime/securesms/database/AttachmentDatabase.java
@@ -232,7 +232,7 @@ public class AttachmentDatabase extends Database {
try {
cursor = database.query(TABLE_NAME, PROJECTION, MMS_ID + " = ?", new String[] {mmsId+""},
- null, null, null);
+ null, null, UNIQUE_ID + " ASC, " + ROW_ID + " ASC");
while (cursor != null && cursor.moveToNext()) {
results.addAll(getAttachment(cursor));
@@ -420,6 +420,44 @@ public class AttachmentDatabase extends Database {
thumbnailExecutor.submit(new ThumbnailFetchCallable(attachmentId));
}
+ public void copyAttachmentData(@NonNull AttachmentId sourceId, @NonNull AttachmentId destinationId)
+ throws MmsException, IOException
+ {
+ DatabaseAttachment sourceAttachment = getAttachment(sourceId);
+
+ if (sourceAttachment == null) {
+ throw new MmsException("Cannot find attachment for source!");
+ }
+
+ SQLiteDatabase database = databaseHelper.getWritableDatabase();
+ DataInfo copyToDataInfo = getAttachmentDataFileInfo(destinationId, DATA);
+
+ if (copyToDataInfo == null) {
+ throw new MmsException("No attachment data found for destination!");
+ }
+
+ copyToDataInfo = setAttachmentData(copyToDataInfo.file, getAttachmentStream(sourceId, 0));
+
+ ContentValues contentValues = new ContentValues();
+
+ contentValues.put(SIZE, copyToDataInfo.length);
+ contentValues.put(DATA_RANDOM, copyToDataInfo.random);
+
+ contentValues.put(TRANSFER_STATE, sourceAttachment.getTransferState());
+ contentValues.put(CONTENT_LOCATION, sourceAttachment.getLocation());
+ contentValues.put(DIGEST, sourceAttachment.getDigest());
+ contentValues.put(CONTENT_DISPOSITION, sourceAttachment.getKey());
+ contentValues.put(NAME, sourceAttachment.getRelay());
+ contentValues.put(SIZE, sourceAttachment.getSize());
+ contentValues.put(FAST_PREFLIGHT_ID, sourceAttachment.getFastPreflightId());
+ contentValues.put(WIDTH, sourceAttachment.getWidth());
+ contentValues.put(HEIGHT, sourceAttachment.getHeight());
+ contentValues.put(CONTENT_TYPE, sourceAttachment.getContentType());
+
+
+ database.update(TABLE_NAME, contentValues, PART_ID_WHERE, destinationId.toStrings());
+ }
+
public void updateAttachmentAfterUpload(@NonNull AttachmentId id, @NonNull Attachment attachment) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
ContentValues values = new ContentValues();
diff --git a/src/org/thoughtcrime/securesms/database/RecipientDatabase.java b/src/org/thoughtcrime/securesms/database/RecipientDatabase.java
index aca3158231..7cac063b36 100644
--- a/src/org/thoughtcrime/securesms/database/RecipientDatabase.java
+++ b/src/org/thoughtcrime/securesms/database/RecipientDatabase.java
@@ -43,7 +43,7 @@ public class RecipientDatabase extends Database {
private static final String SEEN_INVITE_REMINDER = "seen_invite_reminder";
private static final String DEFAULT_SUBSCRIPTION_ID = "default_subscription_id";
private static final String EXPIRE_MESSAGES = "expire_messages";
- private static final String REGISTERED = "registered";
+ static final String REGISTERED = "registered";
private static final String PROFILE_KEY = "profile_key";
private static final String SYSTEM_DISPLAY_NAME = "system_display_name";
private static final String SYSTEM_PHOTO_URI = "system_contact_photo";
diff --git a/src/org/thoughtcrime/securesms/database/ThreadDatabase.java b/src/org/thoughtcrime/securesms/database/ThreadDatabase.java
index 89ee2cf2d6..c378c6373c 100644
--- a/src/org/thoughtcrime/securesms/database/ThreadDatabase.java
+++ b/src/org/thoughtcrime/securesms/database/ThreadDatabase.java
@@ -377,6 +377,14 @@ public class ThreadDatabase extends Database {
return db.rawQuery(query, null);
}
+ public Cursor getRecentPushConversationList(int limit) {
+ SQLiteDatabase db = databaseHelper.getReadableDatabase();
+ String where = MESSAGE_COUNT + " != 0 AND (" + RecipientDatabase.REGISTERED + " = " + RecipientDatabase.RegisteredState.REGISTERED.getId() + " OR " + GroupDatabase.GROUP_ID + " NOT NULL)";
+ String query = createQuery(where, limit);
+
+ return db.rawQuery(query, null);
+ }
+
public Cursor getConversationList() {
return getConversationList("0");
}
diff --git a/src/org/thoughtcrime/securesms/jobmanager/JobManager.java b/src/org/thoughtcrime/securesms/jobmanager/JobManager.java
index 854fd265ae..327372552d 100644
--- a/src/org/thoughtcrime/securesms/jobmanager/JobManager.java
+++ b/src/org/thoughtcrime/securesms/jobmanager/JobManager.java
@@ -182,7 +182,7 @@ public class JobManager implements ConstraintObserver.Notifier {
return then(Collections.singletonList(job));
}
- public Chain then(@NonNull List jobs) {
+ public Chain then(@NonNull List extends Job> jobs) {
if (!jobs.isEmpty()) {
this.jobs.add(new ArrayList<>(jobs));
}
diff --git a/src/org/thoughtcrime/securesms/jobs/AttachmentCopyJob.java b/src/org/thoughtcrime/securesms/jobs/AttachmentCopyJob.java
new file mode 100644
index 0000000000..2f81d60e77
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/jobs/AttachmentCopyJob.java
@@ -0,0 +1,108 @@
+package org.thoughtcrime.securesms.jobs;
+
+import androidx.annotation.NonNull;
+
+import org.thoughtcrime.securesms.attachments.AttachmentId;
+import org.thoughtcrime.securesms.database.AttachmentDatabase;
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.jobmanager.Data;
+import org.thoughtcrime.securesms.jobmanager.Job;
+import org.thoughtcrime.securesms.util.JsonUtils;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Copies the data from one attachment to another. Useful when you only want to send an attachment
+ * once, and then copy the data from that upload to other messages.
+ */
+public class AttachmentCopyJob extends BaseJob {
+
+ public static final String KEY = "AttachmentCopyJob";
+
+ private static final String KEY_SOURCE_ID = "source_id";
+ private static final String KEY_DESTINATION_IDS = "destination_ids";
+
+ private final AttachmentId sourceId;
+ private final List destinationIds;
+
+ public AttachmentCopyJob(@NonNull AttachmentId sourceId, @NonNull List destinationIds) {
+ this(new Job.Parameters.Builder()
+ .setQueue("AttachmentCopyJob")
+ .setMaxAttempts(3)
+ .build(),
+ sourceId,
+ destinationIds);
+ }
+
+ private AttachmentCopyJob(@NonNull Parameters parameters,
+ @NonNull AttachmentId sourceId,
+ @NonNull List destinationIds)
+ {
+ super(parameters);
+ this.sourceId = sourceId;
+ this.destinationIds = destinationIds;
+ }
+
+ @Override
+ public @NonNull Data serialize() {
+ try {
+ String sourceIdString = JsonUtils.toJson(sourceId);
+ String[] destinationIdStrings = new String[destinationIds.size()];
+
+ for (int i = 0; i < destinationIds.size(); i++) {
+ destinationIdStrings[i] = JsonUtils.toJson(destinationIds.get(i));
+ }
+
+ return new Data.Builder().putString(KEY_SOURCE_ID, sourceIdString)
+ .putStringArray(KEY_DESTINATION_IDS, destinationIdStrings)
+ .build();
+ } catch (IOException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ @Override
+ public @NonNull String getFactoryKey() {
+ return KEY;
+ }
+
+ @Override
+ protected void onRun() throws Exception {
+ AttachmentDatabase database = DatabaseFactory.getAttachmentDatabase(context);
+
+ for (AttachmentId destinationId : destinationIds) {
+ database.copyAttachmentData(sourceId, destinationId);
+ }
+ }
+
+ @Override
+ protected boolean onShouldRetry(@NonNull Exception e) {
+ return true;
+ }
+
+ @Override
+ public void onCanceled() { }
+
+ public static final class Factory implements Job.Factory {
+ @Override
+ public @NonNull AttachmentCopyJob create(@NonNull Parameters parameters, @NonNull Data data) {
+ try {
+ String sourceIdStrings = data.getString(KEY_SOURCE_ID);
+ String[] destinationIdStrings = data.getStringArray(KEY_DESTINATION_IDS);
+
+ AttachmentId sourceId = JsonUtils.fromJson(sourceIdStrings, AttachmentId.class);
+ List destinationIds = new ArrayList<>(destinationIdStrings.length);
+
+ for (String idString : destinationIdStrings) {
+ destinationIds.add(JsonUtils.fromJson(idString, AttachmentId.class));
+ }
+
+ return new AttachmentCopyJob(parameters, sourceId, destinationIds);
+ } catch (IOException e) {
+ throw new AssertionError(e);
+ }
+ }
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
index 664676f3e2..f3af1c707e 100644
--- a/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
+++ b/src/org/thoughtcrime/securesms/jobs/JobManagerFactories.java
@@ -23,6 +23,7 @@ public final class JobManagerFactories {
public static Map getJobFactories(@NonNull Application application) {
return new HashMap() {{
+ put(AttachmentCopyJob.KEY, new AttachmentCopyJob.Factory());
put(AttachmentDownloadJob.KEY, new AttachmentDownloadJob.Factory());
put(AttachmentUploadJob.KEY, new AttachmentUploadJob.Factory());
put(AvatarDownloadJob.KEY, new AvatarDownloadJob.Factory());
diff --git a/src/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java b/src/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java
index 5201c8b783..9816f18a61 100644
--- a/src/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java
+++ b/src/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java
@@ -39,7 +39,6 @@ import com.bumptech.glide.request.transition.Transition;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.logging.Log;
-import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.util.ServiceUtil;
@@ -267,7 +266,7 @@ public class Camera1Fragment extends Fragment implements CameraFragment,
});
galleryButton.setOnClickListener(v -> controller.onGalleryClicked());
- countButton.setOnClickListener(v -> controller.onContinueClicked());
+ countButton.setOnClickListener(v -> controller.onCameraCountButtonClicked());
viewModel.onCameraControlsInitialized();
}
diff --git a/src/org/thoughtcrime/securesms/mediasend/CameraContactAdapter.java b/src/org/thoughtcrime/securesms/mediasend/CameraContactAdapter.java
new file mode 100644
index 0000000000..2d47ff9c98
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/mediasend/CameraContactAdapter.java
@@ -0,0 +1,254 @@
+package org.thoughtcrime.securesms.mediasend;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.CheckBox;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.components.AvatarImageView;
+import org.thoughtcrime.securesms.components.FromTextView;
+import org.thoughtcrime.securesms.mms.GlideRequests;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.adapter.SectionedRecyclerViewAdapter;
+import org.thoughtcrime.securesms.util.adapter.StableIdGenerator;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+class CameraContactAdapter extends SectionedRecyclerViewAdapter {
+
+ private static final int TYPE_INVITE = 1337;
+ private static final long ID_INVITE = Long.MAX_VALUE;
+
+ private static final String TAG_RECENT = "recent";
+ private static final String TAG_ALL = "all";
+ private static final String TAG_GROUPS = "groups";
+
+ private final GlideRequests glideRequests;
+ private final Set selected;
+ private final CameraContactListener cameraContactListener;
+
+
+ private final List sections = new ArrayList(3) {{
+ ContactSection recentContacts = new ContactSection(TAG_RECENT, R.string.CameraContacts_recent_contacts, Collections.emptyList(), 0);
+ ContactSection allContacts = new ContactSection(TAG_ALL, R.string.CameraContacts_signal_contacts, Collections.emptyList(), recentContacts.size());
+ ContactSection groups = new ContactSection(TAG_GROUPS, R.string.CameraContacts_signal_groups, Collections.emptyList(), recentContacts.size() + allContacts.size());
+
+ add(recentContacts);
+ add(allContacts);
+ add(groups);
+ }};
+
+ CameraContactAdapter(@NonNull GlideRequests glideRequests, @NonNull CameraContactListener listener) {
+ this.glideRequests = glideRequests;
+ this.selected = new HashSet<>();
+ this.cameraContactListener = listener;
+ }
+
+ @Override
+ protected @NonNull List getSections() {
+ return sections;
+ }
+
+ @Override
+ public long getItemId(int globalPosition) {
+ if (isInvitePosition(globalPosition)) {
+ return ID_INVITE;
+ } else {
+ return super.getItemId(globalPosition);
+ }
+ }
+
+ @Override
+ public int getItemViewType(int globalPosition) {
+ if (isInvitePosition(globalPosition)) {
+ return TYPE_INVITE;
+ } else {
+ return super.getItemViewType(globalPosition);
+ }
+ }
+
+ @Override
+ public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
+ if (viewType == TYPE_INVITE) {
+ return new InviteViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.camera_contact_invite_item, viewGroup, false));
+ } else {
+ return super.onCreateViewHolder(viewGroup, viewType);
+ }
+ }
+
+ @Override
+ protected @NonNull RecyclerView.ViewHolder createHeaderViewHolder(@NonNull ViewGroup parent) {
+ return new HeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.camera_contact_header_item, parent, false));
+ }
+
+ @Override
+ protected @NonNull RecyclerView.ViewHolder createContentViewHolder(@NonNull ViewGroup parent) {
+ return new ContactViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.camera_contact_contact_item, parent, false));
+ }
+
+ @Override
+ protected @Nullable RecyclerView.ViewHolder createEmptyViewHolder(@NonNull ViewGroup viewGroup) {
+ return null;
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int globalPosition) {
+ if (isInvitePosition(globalPosition)) {
+ ((InviteViewHolder) holder).bind(cameraContactListener);
+ } else {
+ super.onBindViewHolder(holder, globalPosition);
+ }
+ }
+
+ @Override
+ protected void bindViewHolder(@NonNull RecyclerView.ViewHolder holder, @NonNull ContactSection section, int localPosition) {
+ section.bind(holder, localPosition, selected, glideRequests, cameraContactListener);
+ }
+
+ @Override
+ public int getItemCount() {
+ int count = super.getItemCount();
+ return count > 0 ? count + 1 : 0;
+ }
+
+ public void setContacts(@NonNull CameraContacts contacts, @NonNull Collection selected) {
+ ContactSection recentContacts = new ContactSection(TAG_RECENT, R.string.CameraContacts_recent_contacts, contacts.getRecents(), 0);
+ ContactSection allContacts = new ContactSection(TAG_ALL, R.string.CameraContacts_signal_contacts, contacts.getContacts(), recentContacts.size());
+ ContactSection groups = new ContactSection(TAG_GROUPS, R.string.CameraContacts_signal_groups, contacts.getGroups(), recentContacts.size() + allContacts.size());
+
+ sections.clear();
+ sections.add(recentContacts);
+ sections.add(allContacts);
+ sections.add(groups);
+
+ this.selected.clear();
+ this.selected.addAll(selected);
+
+ notifyDataSetChanged();
+ }
+
+ private boolean isInvitePosition(int globalPosition) {
+ int count = getItemCount();
+ return count > 0 && globalPosition == getItemCount() - 1;
+ }
+
+ public static class ContactSection extends SectionedRecyclerViewAdapter.Section {
+
+ private final String tag;
+ private final int titleResId;
+ private final List recipients;
+
+ public ContactSection(@NonNull String tag, @StringRes int titleResId, @NonNull List recipients, int offset) {
+ super(offset);
+ this.tag = tag;
+ this.titleResId = titleResId;
+ this.recipients = recipients;
+ }
+
+ @Override
+ public boolean hasEmptyState() {
+ return false;
+ }
+
+ @Override
+ public int getContentSize() {
+ return recipients.size();
+ }
+
+ @Override
+ public long getItemId(@NonNull StableIdGenerator idGenerator, int globalPosition) {
+ int localPosition = getLocalPosition(globalPosition);
+
+ if (localPosition == 0) {
+ return idGenerator.getId(tag);
+ } else {
+ return idGenerator.getId(recipients.get(localPosition - 1).getAddress().serialize());
+ }
+ }
+
+ void bind(@NonNull RecyclerView.ViewHolder viewHolder,
+ int localPosition,
+ @NonNull Set selected,
+ @NonNull GlideRequests glideRequests,
+ @NonNull CameraContactListener cameraContactListener)
+ {
+ if (localPosition == 0) {
+ ((HeaderViewHolder) viewHolder).bind(titleResId);
+ } else {
+ Recipient recipient = recipients.get(localPosition - 1);
+ ((ContactViewHolder) viewHolder).bind(recipient, selected.contains(recipient), glideRequests, cameraContactListener);
+ }
+ }
+ }
+
+ private static class HeaderViewHolder extends RecyclerView.ViewHolder {
+
+ private final TextView title;
+
+ HeaderViewHolder(@NonNull View itemView) {
+ super(itemView);
+ this.title = itemView.findViewById(R.id.camera_contact_header);
+ }
+
+ void bind(@StringRes int titleResId) {
+ this.title.setText(titleResId);
+ }
+ }
+
+ private static class ContactViewHolder extends RecyclerView.ViewHolder {
+
+ private final AvatarImageView avatar;
+ private final FromTextView name;
+ private final CheckBox checkbox;
+
+ ContactViewHolder(@NonNull View itemView) {
+ super(itemView);
+
+ this.avatar = itemView.findViewById(R.id.camera_contact_item_avatar);
+ this.name = itemView.findViewById(R.id.camera_contact_item_name);
+ this.checkbox = itemView.findViewById(R.id.camera_contact_item_checkbox);
+ }
+
+ void bind(@NonNull Recipient recipient,
+ boolean selected,
+ @NonNull GlideRequests glideRequests,
+ @NonNull CameraContactListener listener)
+ {
+ avatar.setAvatar(glideRequests, recipient, false);
+ name.setText(recipient);
+ itemView.setOnClickListener(v -> listener.onContactClicked(recipient));
+ checkbox.setChecked(selected);
+ }
+ }
+
+ private static class InviteViewHolder extends RecyclerView.ViewHolder {
+
+ private final View inviteButton;
+
+ public InviteViewHolder(@NonNull View itemView) {
+ super(itemView);
+ inviteButton = itemView.findViewById(R.id.camera_contact_invite);
+ }
+
+ void bind(@NonNull CameraContactListener listener) {
+ inviteButton.setOnClickListener(v -> listener.onInviteContactsClicked());
+ }
+ }
+
+ interface CameraContactListener {
+ void onContactClicked(@NonNull Recipient recipient);
+ void onInviteContactsClicked();
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/mediasend/CameraContactSelectionAdapter.java b/src/org/thoughtcrime/securesms/mediasend/CameraContactSelectionAdapter.java
new file mode 100644
index 0000000000..907df25673
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/mediasend/CameraContactSelectionAdapter.java
@@ -0,0 +1,67 @@
+package org.thoughtcrime.securesms.mediasend;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.thoughtcrime.securesms.MediaDocumentsAdapter;
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.components.FromTextView;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.adapter.StableIdGenerator;
+
+import java.util.ArrayList;
+import java.util.List;
+
+class CameraContactSelectionAdapter extends RecyclerView.Adapter {
+
+ private final List recipients = new ArrayList<>();
+ private final StableIdGenerator idGenerator = new StableIdGenerator<>();
+
+ CameraContactSelectionAdapter() {
+ setHasStableIds(true);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return idGenerator.getId(recipients.get(position).getAddress().serialize());
+ }
+
+ @Override
+ public @NonNull RecipientViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ return new RecipientViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.camera_contact_selection_item, parent, false));
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull RecipientViewHolder holder, int position) {
+ holder.bind(recipients.get(position), position == recipients.size() - 1);
+ }
+
+ @Override
+ public int getItemCount() {
+ return recipients.size();
+ }
+
+ void setRecipients(@NonNull List recipients) {
+ this.recipients.clear();
+ this.recipients.addAll(recipients);
+ notifyDataSetChanged();
+ }
+
+ static class RecipientViewHolder extends MediaDocumentsAdapter.ViewHolder {
+
+ private final FromTextView name;
+
+ RecipientViewHolder(View itemView) {
+ super(itemView);
+ name = (FromTextView) itemView;
+ }
+
+ void bind(@NonNull Recipient recipient, boolean isLast) {
+ name.setText(recipient, true, isLast ? null : ",");
+ }
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/mediasend/CameraContactSelectionFragment.java b/src/org/thoughtcrime/securesms/mediasend/CameraContactSelectionFragment.java
new file mode 100644
index 0000000000..cf0dd8265e
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/mediasend/CameraContactSelectionFragment.java
@@ -0,0 +1,194 @@
+package org.thoughtcrime.securesms.mediasend;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.widget.SearchView;
+import androidx.appcompat.widget.Toolbar;
+import androidx.constraintlayout.widget.Group;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProviders;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.thoughtcrime.securesms.InviteActivity;
+import org.thoughtcrime.securesms.R;
+import org.thoughtcrime.securesms.mms.GlideApp;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.TextSecurePreferences;
+import org.thoughtcrime.securesms.util.ThemeUtil;
+
+import java.util.List;
+
+/**
+ * Fragment that selects Signal contacts. Intended to be used in the camera-first capture flow.
+ */
+public class CameraContactSelectionFragment extends Fragment implements CameraContactAdapter.CameraContactListener {
+
+ private Controller controller;
+ private MediaSendViewModel mediaSendViewModel;
+ private CameraContactSelectionViewModel contactViewModel;
+ private RecyclerView contactList;
+ private CameraContactAdapter contactAdapter;
+ private RecyclerView selectionList;
+ private CameraContactSelectionAdapter selectionAdapter;
+ private Toolbar toolbar;
+ private View sendButton;
+ private Group selectionFooterGroup;
+ private ViewGroup cameraContactsEmpty;
+ private View inviteButton;
+
+ public static Fragment newInstance() {
+ return new CameraContactSelectionFragment();
+ }
+
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setHasOptionsMenu(true);
+
+ this.mediaSendViewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
+ this.contactViewModel = ViewModelProviders.of(requireActivity(), new CameraContactSelectionViewModel.Factory(new CameraContactsRepository(requireContext())))
+ .get(CameraContactSelectionViewModel.class);
+ }
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ if (!(getActivity() instanceof Controller)) {
+ throw new IllegalStateException("Parent activity must implement controller interface.");
+ }
+ controller = (Controller) getActivity();
+ }
+
+ @Override
+ public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ int theme = TextSecurePreferences.getTheme(inflater.getContext()).equals("light") ? R.style.TextSecure_LightTheme
+ : R.style.TextSecure_DarkTheme;
+ return ThemeUtil.getThemedInflater(inflater.getContext(), inflater, theme)
+ .inflate(R.layout.camera_contact_selection_fragment, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ this.contactList = view.findViewById(R.id.camera_contacts_list);
+ this.selectionList = view.findViewById(R.id.camera_contacts_selected_list);
+ this.toolbar = view.findViewById(R.id.camera_contacts_toolbar);
+ this.sendButton = view.findViewById(R.id.camera_contacts_send_button);
+ this.selectionFooterGroup = view.findViewById(R.id.camera_contacts_footer_group);
+ this.cameraContactsEmpty = view.findViewById(R.id.camera_contacts_empty);
+ this.inviteButton = view.findViewById(R.id.camera_contacts_invite_button);
+ this.contactAdapter = new CameraContactAdapter(GlideApp.with(this), this);
+ this.selectionAdapter = new CameraContactSelectionAdapter();
+
+ contactList.setLayoutManager(new LinearLayoutManager(requireContext()));
+ contactList.setAdapter(contactAdapter);
+
+ selectionList.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false));
+ selectionList.setAdapter(selectionAdapter);
+
+ ((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar);
+ ((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed());
+
+ inviteButton.setOnClickListener(v -> onInviteContactsClicked());
+
+ initViewModel();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ mediaSendViewModel.onContactSelectStarted();
+ }
+
+ @Override
+ public void onPrepareOptionsMenu(@NonNull Menu menu) {
+ requireActivity().getMenuInflater().inflate(R.menu.camera_contacts, menu);
+
+ MenuItem searchViewItem = menu.findItem(R.id.menu_search);
+ SearchView searchView = (SearchView) searchViewItem.getActionView();
+ SearchView.OnQueryTextListener queryListener = new SearchView.OnQueryTextListener() {
+ @Override
+ public boolean onQueryTextSubmit(String query) {
+ contactViewModel.onQueryUpdated(query);
+ return true;
+ }
+
+ @Override
+ public boolean onQueryTextChange(String query) {
+ contactViewModel.onQueryUpdated(query);
+ return true;
+ }
+ };
+
+ searchViewItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() {
+ @Override
+ public boolean onMenuItemActionExpand(MenuItem item) {
+ searchView.setOnQueryTextListener(queryListener);
+ return true;
+ }
+
+ @Override
+ public boolean onMenuItemActionCollapse(MenuItem item) {
+ searchView.setOnQueryTextListener(null);
+ contactViewModel.onSearchClosed();
+ return true;
+ }
+ });
+ }
+
+ @Override
+ public void onContactClicked(@NonNull Recipient recipient) {
+ contactViewModel.onContactClicked(recipient);
+ }
+
+ @Override
+ public void onInviteContactsClicked() {
+ startActivity(new Intent(requireContext(), InviteActivity.class));
+ }
+
+ private void initViewModel() {
+ contactViewModel.getContacts().observe(getViewLifecycleOwner(), contactState -> {
+ if (contactState == null) return;
+
+ if (contactState.getContacts().isEmpty()) {
+ cameraContactsEmpty.setVisibility(View.VISIBLE);
+ selectionFooterGroup.setVisibility(View.GONE);
+ } else {
+ cameraContactsEmpty.setVisibility(View.GONE);
+
+ sendButton.setOnClickListener(v -> controller.onCameraContactsSendClicked(contactState.getSelected()));
+
+ contactAdapter.setContacts(contactState.getContacts(), contactState.getSelected());
+ selectionAdapter.setRecipients(contactState.getSelected());
+
+ selectionFooterGroup.setVisibility(contactState.getSelected().isEmpty() ? View.GONE : View.VISIBLE);
+ }
+ });
+
+ contactViewModel.getError().observe(getViewLifecycleOwner(), error -> {
+ if (error == null) return;
+
+ if (error == CameraContactSelectionViewModel.Error.MAX_SELECTION) {
+ String message = getString(R.string.CameraContacts_you_can_share_with_a_maximum_of_n_conversations, CameraContactSelectionViewModel.MAX_SELECTION_COUNT);
+ Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show();
+ }
+ });
+ }
+
+ public interface Controller {
+ void onCameraContactsSendClicked(@NonNull List recipients);
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/mediasend/CameraContactSelectionViewModel.java b/src/org/thoughtcrime/securesms/mediasend/CameraContactSelectionViewModel.java
new file mode 100644
index 0000000000..66a46e768e
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/mediasend/CameraContactSelectionViewModel.java
@@ -0,0 +1,121 @@
+package org.thoughtcrime.securesms.mediasend;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.ViewModel;
+import androidx.lifecycle.ViewModelProvider;
+
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.SingleLiveEvent;
+import org.thoughtcrime.securesms.util.Util;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+class CameraContactSelectionViewModel extends ViewModel {
+
+ static final int MAX_SELECTION_COUNT = 16;
+
+ private final CameraContactsRepository repository;
+ private final MutableLiveData contacts;
+ private final SingleLiveEvent error;
+ private final Set selected;
+
+ private CameraContactSelectionViewModel(@NonNull CameraContactsRepository repository) {
+ this.repository = repository;
+ this.contacts = new MutableLiveData<>();
+ this.error = new SingleLiveEvent<>();
+ this.selected = new LinkedHashSet<>();
+
+ repository.getCameraContacts(cameraContacts -> {
+ Util.runOnMain(() -> {
+ contacts.postValue(new ContactState(cameraContacts, new ArrayList<>(selected)));
+ });
+ });
+ }
+
+ LiveData getContacts() {
+ return contacts;
+ }
+
+ LiveData getError() {
+ return error;
+ }
+
+ void onSearchClosed() {
+ onQueryUpdated("");
+ }
+
+ void onQueryUpdated(String query) {
+ repository.getCameraContacts(query, cameraContacts -> {
+ Util.runOnMain(() -> {
+ contacts.postValue(new ContactState(cameraContacts, new ArrayList<>(selected)));
+ });
+ });
+ }
+
+ void onRefresh() {
+ repository.getCameraContacts(cameraContacts -> {
+ Util.runOnMain(() -> {
+ contacts.postValue(new ContactState(cameraContacts, new ArrayList<>(selected)));
+ });
+ });
+ }
+
+ void onContactClicked(@NonNull Recipient recipient) {
+ if (selected.contains(recipient)) {
+ selected.remove(recipient);
+ } else if (selected.size() < MAX_SELECTION_COUNT) {
+ selected.add(recipient);
+ } else {
+ error.postValue(Error.MAX_SELECTION);
+ }
+
+ ContactState currentState = contacts.getValue();
+
+ if (currentState != null) {
+ contacts.setValue(new ContactState(currentState.getContacts(), new ArrayList<>(selected)));
+ }
+ }
+
+ static class ContactState {
+ private final CameraContacts contacts;
+ private final List selected;
+
+ ContactState(CameraContacts contacts, List selected) {
+ this.contacts = contacts;
+ this.selected = selected;
+ }
+
+ public CameraContacts getContacts() {
+ return contacts;
+ }
+
+ public List getSelected() {
+ return selected;
+ }
+ }
+
+ enum Error {
+ MAX_SELECTION
+ }
+
+ static class Factory extends ViewModelProvider.NewInstanceFactory {
+
+ private final CameraContactsRepository repository;
+
+ Factory(CameraContactsRepository repository) {
+ this.repository = repository;
+ }
+
+ @Override
+ public @NonNull T create(@NonNull Class modelClass) {
+ //noinspection ConstantConditions
+ return modelClass.cast(new CameraContactSelectionViewModel(repository));
+ }
+ }
+
+}
diff --git a/src/org/thoughtcrime/securesms/mediasend/CameraContacts.java b/src/org/thoughtcrime/securesms/mediasend/CameraContacts.java
new file mode 100644
index 0000000000..3e6c526a68
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/mediasend/CameraContacts.java
@@ -0,0 +1,39 @@
+package org.thoughtcrime.securesms.mediasend;
+
+import androidx.annotation.NonNull;
+
+import org.thoughtcrime.securesms.recipients.Recipient;
+
+import java.util.List;
+
+/**
+ * Represents the list of results to display in the {@link CameraContactSelectionFragment}.
+ */
+public class CameraContacts {
+
+ private final List recents;
+ private final List contacts;
+ private final List groups;
+
+ public CameraContacts(@NonNull List recents, @NonNull List contacts, @NonNull List groups) {
+ this.recents = recents;
+ this.contacts = contacts;
+ this.groups = groups;
+ }
+
+ public @NonNull List getRecents() {
+ return recents;
+ }
+
+ public @NonNull List getContacts() {
+ return contacts;
+ }
+
+ public @NonNull List getGroups() {
+ return groups;
+ }
+
+ public boolean isEmpty() {
+ return recents.isEmpty() && contacts.isEmpty() && groups.isEmpty();
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java b/src/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java
new file mode 100644
index 0000000000..76eec243fa
--- /dev/null
+++ b/src/org/thoughtcrime/securesms/mediasend/CameraContactsRepository.java
@@ -0,0 +1,107 @@
+package org.thoughtcrime.securesms.mediasend;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.WorkerThread;
+
+import org.thoughtcrime.securesms.contacts.ContactsDatabase;
+import org.thoughtcrime.securesms.database.Address;
+import org.thoughtcrime.securesms.database.DatabaseFactory;
+import org.thoughtcrime.securesms.database.GroupDatabase;
+import org.thoughtcrime.securesms.database.ThreadDatabase;
+import org.thoughtcrime.securesms.database.model.ThreadRecord;
+import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
+
+import java.net.CookieHandler;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Handles retrieving the data to be shown in {@link CameraContactSelectionFragment}.
+ */
+class CameraContactsRepository {
+
+ private static final int RECENT_MAX = 7;
+
+ private final Context context;
+ private final ThreadDatabase threadDatabase;
+ private final GroupDatabase groupDatabase;
+ private final ContactsDatabase contactsDatabase;
+
+ CameraContactsRepository(@NonNull Context context) {
+ this.context = context.getApplicationContext();
+ this.threadDatabase = DatabaseFactory.getThreadDatabase(context);
+ this.groupDatabase = DatabaseFactory.getGroupDatabase(context);
+ this.contactsDatabase = DatabaseFactory.getContactsDatabase(context);
+ }
+
+ void getCameraContacts(@NonNull Callback callback) {
+ getCameraContacts("", callback);
+ }
+
+ void getCameraContacts(@NonNull String query, @NonNull Callback callback) {
+ SignalExecutors.BOUNDED.execute(() -> {
+ List recents = getRecents(query);
+ List contacts = getContacts(query);
+ List groups = getGroups(query);
+
+ callback.onComplete(new CameraContacts(recents, contacts, groups));
+ });
+ }
+
+
+ @WorkerThread
+ private @NonNull List getRecents(@NonNull String query) {
+ if (!TextUtils.isEmpty(query)) {
+ return Collections.emptyList();
+ }
+
+ List recipients = new ArrayList<>(RECENT_MAX);
+
+ try (ThreadDatabase.Reader threadReader = threadDatabase.readerFor(threadDatabase.getRecentPushConversationList(RECENT_MAX))) {
+ ThreadRecord threadRecord;
+ while ((threadRecord = threadReader.getNext()) != null) {
+ recipients.add(threadRecord.getRecipient());
+ }
+ }
+
+ return recipients;
+ }
+
+ @WorkerThread
+ private @NonNull List getContacts(@NonNull String query) {
+ List recipients = new ArrayList<>();
+
+ try (Cursor cursor = contactsDatabase.queryTextSecureContacts(query)) {
+ while (cursor.moveToNext()) {
+ Address address = Address.fromExternal(context, cursor.getString(1));
+ recipients.add(Recipient.from(context, address, false));
+ }
+ }
+
+ return recipients;
+ }
+
+ @WorkerThread
+ private @NonNull List getGroups(@NonNull String query) {
+ List recipients = new ArrayList<>();
+
+ try (GroupDatabase.Reader reader = groupDatabase.getGroupsFilteredByTitle(query)) {
+ GroupDatabase.GroupRecord groupRecord;
+ while ((groupRecord = reader.getNext()) != null) {
+ recipients.add(Recipient.from(context, Address.fromSerialized(groupRecord.getEncodedId()), false));
+ }
+ }
+
+ return recipients;
+ }
+
+ interface Callback {
+ void onComplete(E result);
+ }
+}
diff --git a/src/org/thoughtcrime/securesms/mediasend/CameraFragment.java b/src/org/thoughtcrime/securesms/mediasend/CameraFragment.java
index e7c2d9240b..7ae9b28f71 100644
--- a/src/org/thoughtcrime/securesms/mediasend/CameraFragment.java
+++ b/src/org/thoughtcrime/securesms/mediasend/CameraFragment.java
@@ -23,6 +23,6 @@ public interface CameraFragment {
void onImageCaptured(@NonNull byte[] data, int width, int height);
void onGalleryClicked();
int getDisplayRotation();
- void onContinueClicked();
+ void onCameraCountButtonClicked();
}
}
diff --git a/src/org/thoughtcrime/securesms/mediasend/CameraXFragment.java b/src/org/thoughtcrime/securesms/mediasend/CameraXFragment.java
index da607679c1..169da06bf3 100644
--- a/src/org/thoughtcrime/securesms/mediasend/CameraXFragment.java
+++ b/src/org/thoughtcrime/securesms/mediasend/CameraXFragment.java
@@ -216,7 +216,7 @@ public class CameraXFragment extends Fragment implements CameraFragment {
}
galleryButton.setOnClickListener(v -> controller.onGalleryClicked());
- countButton.setOnClickListener(v -> controller.onContinueClicked());
+ countButton.setOnClickListener(v -> controller.onCameraCountButtonClicked());
viewModel.onCameraControlsInitialized();
}
diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java b/src/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java
index 3f448bdf74..909fd6cc84 100644
--- a/src/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java
+++ b/src/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java
@@ -17,7 +17,6 @@ import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
-import android.view.WindowManager;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideApp;
@@ -29,20 +28,27 @@ import org.whispersystems.libsignal.util.guava.Optional;
*/
public class MediaPickerFolderFragment extends Fragment implements MediaPickerFolderAdapter.EventListener {
- private static final String KEY_RECIPIENT_NAME = "recipient_name";
+ private static final String KEY_TOOLBAR_TITLE = "toolbar_title";
- private String recipientName;
+ private String toolbarTitle;
private MediaSendViewModel viewModel;
private Controller controller;
private GridLayoutManager layoutManager;
- public static @NonNull MediaPickerFolderFragment newInstance(@NonNull Recipient recipient) {
- String name = Optional.fromNullable(recipient.getName())
- .or(Optional.fromNullable(recipient.getProfileName()))
- .or(recipient.toShortString());
+ public static @NonNull MediaPickerFolderFragment newInstance(@NonNull Context context, @Nullable Recipient recipient) {
+ String toolbarTitle;
+
+ if (recipient != null) {
+ String name = Optional.fromNullable(recipient.getName())
+ .or(Optional.fromNullable(recipient.getProfileName()))
+ .or(recipient.toShortString());
+ toolbarTitle = context.getString(R.string.MediaPickerActivity_send_to, name);
+ } else {
+ toolbarTitle = "";
+ }
Bundle args = new Bundle();
- args.putString(KEY_RECIPIENT_NAME, name);
+ args.putString(KEY_TOOLBAR_TITLE, toolbarTitle);
MediaPickerFolderFragment fragment = new MediaPickerFolderFragment();
fragment.setArguments(args);
@@ -55,8 +61,8 @@ public class MediaPickerFolderFragment extends Fragment implements MediaPickerFo
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
- recipientName = getArguments().getString(KEY_RECIPIENT_NAME);
- viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
+ toolbarTitle = getArguments().getString(KEY_TOOLBAR_TITLE);
+ viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(requireActivity().getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
}
@Override
@@ -123,7 +129,7 @@ public class MediaPickerFolderFragment extends Fragment implements MediaPickerFo
private void initToolbar(Toolbar toolbar) {
((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar);
((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true);
- ((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(getString(R.string.MediaPickerActivity_send_to, recipientName));
+ ((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(toolbarTitle);
toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed());
}
diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java
index 5fbe15f241..0ada31118f 100644
--- a/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java
+++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java
@@ -35,26 +35,35 @@ import android.widget.Toast;
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.TransportOption;
+import org.thoughtcrime.securesms.TransportOptions;
import org.thoughtcrime.securesms.components.ComposeText;
import org.thoughtcrime.securesms.components.InputAwareLayout;
import org.thoughtcrime.securesms.components.SendButton;
+import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.emoji.EmojiEditText;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
import org.thoughtcrime.securesms.database.Address;
+import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
import org.thoughtcrime.securesms.mediasend.MediaSendViewModel.RevealState;
+import org.thoughtcrime.securesms.mms.GifSlide;
import org.thoughtcrime.securesms.mms.GlideApp;
+import org.thoughtcrime.securesms.mms.ImageSlide;
+import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
+import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage;
+import org.thoughtcrime.securesms.mms.SlideDeck;
+import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment;
+import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
-import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -67,6 +76,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
@@ -83,6 +93,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
MediaPickerItemFragment.Controller,
ImageEditorFragment.Controller,
CameraFragment.Controller,
+ CameraContactSelectionFragment.Controller,
ViewTreeObserver.OnGlobalLayoutListener,
MediaRailAdapter.RailItemListener,
InputAwareLayout.OnKeyboardShownListener,
@@ -106,10 +117,10 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
private static final String TAG_ITEM_PICKER = "item_picker";
private static final String TAG_SEND = "send";
private static final String TAG_CAMERA = "camera";
+ private static final String TAG_CONTACTS = "contacts";
- private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
+ private @Nullable Recipient recipient;
- private Recipient recipient;
private TransportOption transport;
private MediaSendViewModel viewModel;
@@ -122,6 +133,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
private ViewGroup composeContainer;
private ViewGroup countButton;
private TextView countButtonText;
+ private View continueButton;
private ImageView revealButton;
private EmojiEditText captionText;
private EmojiToggle emojiToggle;
@@ -139,12 +151,20 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
*/
public static Intent buildGalleryIntent(@NonNull Context context, @NonNull Recipient recipient, @NonNull String body, @NonNull TransportOption transport) {
Intent intent = new Intent(context, MediaSendActivity.class);
- intent.putExtra(KEY_ADDRESS, recipient.getAddress().serialize());
+ intent.putExtra(KEY_ADDRESS, recipient.getAddress());
intent.putExtra(KEY_TRANSPORT, transport);
intent.putExtra(KEY_BODY, body);
return intent;
}
+ public static Intent buildCameraFirstIntent(@NonNull Context context) {
+ Intent intent = new Intent(context, MediaSendActivity.class);
+ intent.putExtra(KEY_TRANSPORT, TransportOptions.getPushTransportOption(context));
+ intent.putExtra(KEY_BODY, "");
+ intent.putExtra(KEY_IS_CAMERA, true);
+ return intent;
+ }
+
/**
* Get an intent to launch the media send flow starting with the picker.
*/
@@ -169,11 +189,6 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
return intent;
}
- @Override
- protected void onPreCreate() {
- dynamicLanguage.onCreate(this);
- }
-
@Override
protected void onCreate(Bundle savedInstanceState, boolean ready) {
setContentView(R.layout.mediasend_activity);
@@ -192,6 +207,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
composeContainer = findViewById(R.id.mediasend_compose_container);
countButton = findViewById(R.id.mediasend_count_button);
countButtonText = findViewById(R.id.mediasend_count_button_text);
+ continueButton = findViewById(R.id.mediasend_continue_button);
revealButton = findViewById(R.id.mediasend_reveal_toggle);
captionText = findViewById(R.id.mediasend_caption);
emojiToggle = findViewById(R.id.mediasend_emoji_toggle);
@@ -199,8 +215,12 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
mediaRail = findViewById(R.id.mediasend_media_rail);
emojiDrawer = new Stub<>(findViewById(R.id.mediasend_emoji_drawer_stub));
+ Address address = getIntent().getParcelableExtra(KEY_ADDRESS);
+ if (address != null) {
+ recipient = Recipient.from(this, address, true);
+ }
+
viewModel = ViewModelProviders.of(this, new MediaSendViewModel.Factory(getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
- recipient = Recipient.from(this, Address.fromSerialized(getIntent().getStringExtra(KEY_ADDRESS)), true);
transport = getIntent().getParcelableExtra(KEY_TRANSPORT);
viewModel.setTransport(transport);
@@ -219,12 +239,12 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
} else if (!Util.isEmpty(media)) {
viewModel.onSelectedMediaChanged(this, media);
- Fragment fragment = MediaSendFragment.newInstance(recipient, transport, dynamicLanguage.getCurrentLocale());
+ Fragment fragment = MediaSendFragment.newInstance(Locale.getDefault());
getSupportFragmentManager().beginTransaction()
.replace(R.id.mediasend_fragment_container, fragment, TAG_SEND)
.commit();
} else {
- MediaPickerFolderFragment fragment = MediaPickerFolderFragment.newInstance(recipient);
+ MediaPickerFolderFragment fragment = MediaPickerFolderFragment.newInstance(this, recipient);
getSupportFragmentManager().beginTransaction()
.replace(R.id.mediasend_fragment_container, fragment, TAG_FOLDER_PICKER)
.commit();
@@ -238,9 +258,11 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
MediaSendFragment fragment = getMediaSendFragment();
if (fragment != null) {
- processMedia(fragment.getAllMedia(), fragment.getSavedState());
+ processMedia(fragment.getAllMedia(), fragment.getSavedState(), processedMedia -> {
+ setActivityResultAndFinish(processedMedia, composeText.getTextTrimmed(), transport);
+ });
} else {
- throw new AssertionError("No send fragment available!");
+ throw new AssertionError("No editor fragment available!");
}
});
@@ -279,11 +301,13 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
sendButton.setTransport(transport);
sendButton.disableTransport(transport.getType() == TransportOption.Type.SMS ? TransportOption.Type.TEXTSECURE : TransportOption.Type.SMS);
- countButton.setOnClickListener(v -> navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale()));
+ countButton.setOnClickListener(v -> navigateToMediaSend(Locale.getDefault()));
composeText.append(viewModel.getBody());
- if (recipient.isLocalNumber()) {
+ if (recipient == null) {
+ composeText.setHint(R.string.MediaSendActivity_message);
+ } else if (recipient.isLocalNumber()) {
composeText.setHint(getString(R.string.note_to_self), null);
} else {
String displayName = Optional.fromNullable(recipient.getName())
@@ -307,12 +331,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
initViewModel();
revealButton.setOnClickListener(v -> viewModel.onRevealButtonToggled());
- }
-
- @Override
- protected void onResume() {
- super.onResume();
- dynamicLanguage.onResume(this);
+ continueButton.setOnClickListener(v -> navigateToContactSelect());
}
@Override
@@ -345,7 +364,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
@Override
public void onMediaSelected(@NonNull Media media) {
viewModel.onSingleMediaSelected(this, media);
- navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale());
+ navigateToMediaSend(Locale.getDefault());
}
@Override
@@ -393,7 +412,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
Log.i(TAG, "Camera capture stored: " + media.getUri().toString());
viewModel.onImageCaptured(media);
- navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale());
+ navigateToMediaSend(Locale.getDefault());
});
}
@@ -403,13 +422,13 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
}
@Override
- public void onContinueClicked() {
- navigateToMediaSend(recipient, transport, dynamicLanguage.getCurrentLocale());
+ public void onCameraCountButtonClicked() {
+ navigateToMediaSend(Locale.getDefault());
}
@Override
public void onGalleryClicked() {
- MediaPickerFolderFragment folderFragment = MediaPickerFolderFragment.newInstance(recipient);
+ MediaPickerFolderFragment folderFragment = MediaPickerFolderFragment.newInstance(this, recipient);
getSupportFragmentManager().beginTransaction()
.replace(R.id.mediasend_fragment_container, folderFragment, TAG_FOLDER_PICKER)
@@ -467,9 +486,22 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
navigateToCamera();
}
+ @Override
+ public void onCameraContactsSendClicked(@NonNull List recipients) {
+ MediaSendFragment fragment = getMediaSendFragment();
+
+ if (fragment != null) {
+ processMedia(fragment.getAllMedia(), fragment.getSavedState(), processedMedia -> {
+ sendMessages(recipients, processedMedia, composeText.getTextTrimmed(), transport);
+ });
+ } else {
+ throw new AssertionError("No editor fragment available!");
+ }
+ }
+
public void onAddMediaClicked(@NonNull String bucketId) {
// TODO: Get actual folder title somehow
- MediaPickerFolderFragment folderFragment = MediaPickerFolderFragment.newInstance(recipient);
+ MediaPickerFolderFragment folderFragment = MediaPickerFolderFragment.newInstance(this, recipient);
MediaPickerItemFragment itemFragment = MediaPickerItemFragment.newInstance(bucketId, "", viewModel.getMaxSelection());
getSupportFragmentManager().beginTransaction()
@@ -483,35 +515,11 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
.commit();
}
- public void onSendClicked(@NonNull List media, @NonNull String message, @NonNull TransportOption transport) {
- viewModel.onSendClicked();
-
- ArrayList mediaList = new ArrayList<>(media);
-
- if (mediaList.size() > 0) {
- Intent intent = new Intent();
-
- intent.putParcelableArrayListExtra(EXTRA_MEDIA, mediaList);
- intent.putExtra(EXTRA_MESSAGE, viewModel.getRevealDuration() == 0 ? message : "");
- intent.putExtra(EXTRA_TRANSPORT, transport);
- intent.putExtra(EXTRA_REVEAL_DURATION, viewModel.getRevealDuration());
-
- setResult(RESULT_OK, intent);
- } else {
- setResult(RESULT_CANCELED);
- }
-
- finish();
-
- overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom);
- }
-
public void onNoMediaAvailable() {
setResult(RESULT_CANCELED);
finish();
}
-
private void initViewModel() {
viewModel.getHudState().observe(this, state -> {
if (state == null) return;
@@ -535,13 +543,27 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
switch (state.getButtonState()) {
case SEND:
sendButtonContainer.setVisibility(View.VISIBLE);
+ continueButton.setVisibility(View.GONE);
countButton.setVisibility(View.GONE);
break;
case COUNT:
sendButtonContainer.setVisibility(View.GONE);
+ continueButton.setVisibility(View.GONE);
countButton.setVisibility(View.VISIBLE);
countButtonText.setText(String.valueOf(state.getSelectionCount()));
break;
+ case CONTINUE:
+ sendButtonContainer.setVisibility(View.GONE);
+ countButton.setVisibility(View.GONE);
+ continueButton.setVisibility(View.VISIBLE);
+
+ if (!TextSecurePreferences.hasSeendCameraFirstTooltip(this)) {
+ TooltipPopup.forTarget(continueButton)
+ .setText(R.string.MediaSendActivity_select_recipients)
+ .show(TooltipPopup.POSITION_ABOVE);
+ TextSecurePreferences.setHasSeenCameraFirstTooltip(this, true);
+ }
+ break;
case GONE:
sendButtonContainer.setVisibility(View.GONE);
countButton.setVisibility(View.GONE);
@@ -624,8 +646,8 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
});
}
- private void navigateToMediaSend(@NonNull Recipient recipient, @NonNull TransportOption transport, @NonNull Locale locale) {
- MediaSendFragment fragment = MediaSendFragment.newInstance(recipient, transport, locale);
+ private void navigateToMediaSend(@NonNull Locale locale) {
+ MediaSendFragment fragment = MediaSendFragment.newInstance(locale);
String backstackTag = null;
if (getSupportFragmentManager().findFragmentByTag(TAG_SEND) != null) {
@@ -658,6 +680,27 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
.execute();
}
+ private void navigateToContactSelect() {
+ if (hud.isInputOpen()) {
+ hud.hideCurrentInput(composeText);
+ }
+
+ Fragment contactFragment = CameraContactSelectionFragment.newInstance();
+ Fragment editorFragment = getSupportFragmentManager().findFragmentByTag(TAG_SEND);
+
+ if (editorFragment == null) {
+ throw new AssertionError("No editor fragment available!");
+ }
+
+
+ getSupportFragmentManager().beginTransaction()
+ .setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
+ .add(R.id.mediasend_fragment_container, contactFragment, TAG_CONTACTS)
+ .hide(editorFragment)
+ .addToBackStack(null)
+ .commit();
+ }
+
private Fragment getOrCreateCameraFragment() {
Fragment fragment = getSupportFragmentManager().findFragmentByTag(TAG_CAMERA);
return fragment != null ? fragment : CameraFragment.newInstance();
@@ -675,7 +718,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
CharacterState characterState = transportOption.calculateCharacters(messageBody);
if (characterState.charactersRemaining <= 15 || characterState.messagesSpent > 1) {
- charactersLeft.setText(String.format(dynamicLanguage.getCurrentLocale(),
+ charactersLeft.setText(String.format(Locale.getDefault(),
"%d/%d (%d)",
characterState.charactersRemaining,
characterState.maxTotalMessageSize,
@@ -711,7 +754,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
}
@SuppressLint("StaticFieldLeak")
- private void processMedia(@NonNull List mediaList, @NonNull Map savedState) {
+ private void processMedia(@NonNull List mediaList, @NonNull Map savedState, @NonNull OnProcessComplete callback) {
Map modelsToRender = new HashMap<>();
for (Media media : mediaList) {
@@ -784,7 +827,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
@Override
protected void onPostExecute(List media) {
- onSendClicked(media, composeText.getTextTrimmed(), sendButton.getSelectedTransport());
+ callback.onComplete(media);
Util.cancelRunnableOnMain(progressTimer);
if (dialog != null) {
dialog.dismiss();
@@ -798,6 +841,81 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
return (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND);
}
+ private void setActivityResultAndFinish(@NonNull List media, @NonNull String message, @NonNull TransportOption transport) {
+ viewModel.onSendClicked();
+
+ ArrayList mediaList = new ArrayList<>(media);
+
+ if (mediaList.size() > 0) {
+ Intent intent = new Intent();
+
+ intent.putParcelableArrayListExtra(EXTRA_MEDIA, mediaList);
+ intent.putExtra(EXTRA_MESSAGE, viewModel.getRevealDuration() == 0 ? message : "");
+ intent.putExtra(EXTRA_TRANSPORT, transport);
+ intent.putExtra(EXTRA_REVEAL_DURATION, viewModel.getRevealDuration());
+
+ setResult(RESULT_OK, intent);
+ } else {
+ setResult(RESULT_CANCELED);
+ }
+
+ finish();
+
+ overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom);
+ }
+
+ private void sendMessages(@NonNull List recipients, @NonNull List media, @NonNull String body, @NonNull TransportOption transport) {
+ SimpleTask.run(() -> {
+ List messages = new ArrayList<>(recipients.size());
+
+ for (Recipient recipient : recipients) {
+ SlideDeck slideDeck = buildSlideDeck(media);
+ OutgoingMediaMessage message = new OutgoingMediaMessage(recipient,
+ body,
+ slideDeck.asAttachments(),
+ System.currentTimeMillis(),
+ -1,
+ recipient.getExpireMessages() * 1000,
+ viewModel.getRevealDuration(),
+ ThreadDatabase.DistributionTypes.DEFAULT,
+ null,
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptyList());
+
+ messages.add(new OutgoingSecureMediaMessage(message));
+
+ // XXX We must do this to avoid sending out messages to the same recipient with the same
+ // sentTimestamp. If we do this, they'll be considered dupes by the receiver.
+ Util.sleep(5);
+ }
+
+ MessageSender.sendMediaBroadcast(this, messages);
+ return null;
+ }, (nothing) -> {
+ finish();
+ });
+ }
+
+ private @NonNull SlideDeck buildSlideDeck(@NonNull List mediaList) {
+ SlideDeck slideDeck = new SlideDeck();
+
+ for (Media mediaItem : mediaList) {
+ if (MediaUtil.isVideoType(mediaItem.getMimeType())) {
+ slideDeck.addSlide(new VideoSlide(this, mediaItem.getUri(), 0, mediaItem.getCaption().orNull()));
+ } else if (MediaUtil.isGif(mediaItem.getMimeType())) {
+ slideDeck.addSlide(new GifSlide(this, mediaItem.getUri(), 0, mediaItem.getWidth(), mediaItem.getHeight(), mediaItem.getCaption().orNull()));
+ } else if (MediaUtil.isImageType(mediaItem.getMimeType())) {
+ slideDeck.addSlide(new ImageSlide(this, mediaItem.getUri(), 0, mediaItem.getWidth(), mediaItem.getHeight(), mediaItem.getCaption().orNull()));
+ } else {
+ Log.w(TAG, "Asked to send an unexpected mimeType: '" + mediaItem.getMimeType() + "'. Skipping.");
+ }
+ }
+
+ return slideDeck;
+ }
+
private class ComposeKeyPressedListener implements View.OnKeyListener, View.OnClickListener, TextWatcher, View.OnFocusChangeListener {
int beforeLength;
@@ -838,4 +956,8 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
@Override
public void onFocusChange(View v, boolean hasFocus) {}
}
+
+ private interface OnProcessComplete {
+ void onComplete(@NonNull List media);
+ }
}
diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java
index 2d65e37279..622442d006 100644
--- a/src/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java
+++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java
@@ -12,10 +12,7 @@ import android.view.View;
import android.view.ViewGroup;
import org.thoughtcrime.securesms.R;
-import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.components.ControllableViewPager;
-import org.thoughtcrime.securesms.recipients.Recipient;
-import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import java.util.List;
@@ -29,8 +26,6 @@ public class MediaSendFragment extends Fragment {
private static final String TAG = MediaSendFragment.class.getSimpleName();
- private static final String KEY_ADDRESS = "address";
- private static final String KEY_TRANSPORT = "transport";
private static final String KEY_LOCALE = "locale";
private ViewGroup playbackControlsContainer;
@@ -40,10 +35,8 @@ public class MediaSendFragment extends Fragment {
private MediaSendViewModel viewModel;
- public static MediaSendFragment newInstance(@NonNull Recipient recipient, @NonNull TransportOption transport, @NonNull Locale locale) {
+ public static MediaSendFragment newInstance(@NonNull Locale locale) {
Bundle args = new Bundle();
- args.putParcelable(KEY_ADDRESS, recipient.getAddress());
- args.putParcelable(KEY_TRANSPORT, transport);
args.putSerializable(KEY_LOCALE, locale);
MediaSendFragment fragment = new MediaSendFragment();
@@ -53,19 +46,17 @@ public class MediaSendFragment extends Fragment {
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
- return ThemeUtil.getThemedInflater(requireActivity(), inflater, R.style.TextSecure_DarkTheme)
- .inflate(R.layout.mediasend_fragment, container, false);
+ return inflater.inflate(R.layout.mediasend_fragment, container, false);
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
-
- initViewModel();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ initViewModel();
fragmentPager = view.findViewById(R.id.mediasend_pager);
playbackControlsContainer = view.findViewById(R.id.mediasend_playback_controls_container);
@@ -89,6 +80,9 @@ public class MediaSendFragment extends Fragment {
@Override
public void onHiddenChanged(boolean hidden) {
super.onHiddenChanged(hidden);
+ if (!hidden) {
+ viewModel.onImageEditorStarted();
+ }
}
@Override
diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java
index 747cd04b7e..0e37e85794 100644
--- a/src/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java
+++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java
@@ -1,6 +1,8 @@
package org.thoughtcrime.securesms.mediasend;
import android.app.Application;
+
+import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
@@ -17,7 +19,6 @@ import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
-import org.thoughtcrime.securesms.revealable.RevealableUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -58,7 +59,7 @@ class MediaSendViewModel extends ViewModel {
private int maxSelection;
private Page page;
private boolean isSms;
- private boolean isNoteToSelf;
+ private Recipient recipient;
private Optional lastCameraCapture;
private boolean hudVisible;
@@ -103,8 +104,8 @@ class MediaSendViewModel extends ViewModel {
}
}
- void setRecipient(@NonNull Recipient recipient) {
- isNoteToSelf = recipient.isLocalNumber();
+ void setRecipient(@Nullable Recipient recipient) {
+ this.recipient = recipient;
}
void onSelectedMediaChanged(@NonNull Context context, @NonNull List newMedia) {
@@ -183,7 +184,7 @@ class MediaSendViewModel extends ViewModel {
hudVisible = true;
composeVisible = revealState != RevealState.ENABLED;
captionVisible = getSelectedMediaOrDefault().size() > 1 || (getSelectedMediaOrDefault().size() > 0 && getSelectedMediaOrDefault().get(0).getCaption().isPresent());
- buttonState = ButtonState.SEND;
+ buttonState = (recipient != null) ? ButtonState.SEND : ButtonState.CONTINUE;
if (revealState == RevealState.GONE && revealSupported()) {
revealState = TextSecurePreferences.isRevealableMessageEnabled(application) ? RevealState.ENABLED : RevealState.DISABLED;
@@ -244,6 +245,12 @@ class MediaSendViewModel extends ViewModel {
hudState.setValue(buildHudState());
}
+ void onContactSelectStarted() {
+ hudVisible = false;
+
+ hudState.setValue(buildHudState());
+ }
+
void onRevealButtonToggled() {
hudVisible = true;
revealState = revealState == RevealState.ENABLED ? RevealState.DISABLED : RevealState.ENABLED;
@@ -266,7 +273,7 @@ class MediaSendViewModel extends ViewModel {
if (page != Page.EDITOR) return;
composeVisible = (revealState != RevealState.ENABLED);
- buttonState = ButtonState.SEND;
+ buttonState = (recipient != null) ? ButtonState.SEND : ButtonState.CONTINUE;
if (isSms) {
railState = RailState.GONE;
@@ -289,7 +296,7 @@ class MediaSendViewModel extends ViewModel {
railState = RailState.GONE;
composeVisible = (revealState == RevealState.GONE);
captionVisible = false;
- buttonState = ButtonState.SEND;
+ buttonState = (recipient != null) ? ButtonState.SEND : ButtonState.CONTINUE;
} else {
if (isCaptionFocused) {
railState = revealState != RevealState.ENABLED ? RailState.INTERACTIVE : RailState.GONE;
@@ -300,7 +307,7 @@ class MediaSendViewModel extends ViewModel {
railState = revealState != RevealState.ENABLED ? RailState.INTERACTIVE : RailState.GONE;
composeVisible = (revealState != RevealState.ENABLED);
captionVisible = false;
- buttonState = ButtonState.SEND;
+ buttonState = (recipient != null) ? ButtonState.SEND : ButtonState.CONTINUE;
}
}
@@ -500,11 +507,11 @@ class MediaSendViewModel extends ViewModel {
}
enum Page {
- CAMERA, ITEM_PICKER, FOLDER_PICKER, EDITOR, UNKNOWN
+ CAMERA, ITEM_PICKER, FOLDER_PICKER, EDITOR, CONTACT_SELECT, UNKNOWN
}
enum ButtonState {
- COUNT, SEND, GONE
+ COUNT, SEND, CONTINUE, GONE
}
enum RailState {
diff --git a/src/org/thoughtcrime/securesms/sms/MessageSender.java b/src/org/thoughtcrime/securesms/sms/MessageSender.java
index 7fc8d2130b..5b4b4da094 100644
--- a/src/org/thoughtcrime/securesms/sms/MessageSender.java
+++ b/src/org/thoughtcrime/securesms/sms/MessageSender.java
@@ -16,14 +16,20 @@
*/
package org.thoughtcrime.securesms.sms;
+import android.app.Application;
import android.content.Context;
import androidx.annotation.NonNull;
+import org.thoughtcrime.securesms.attachments.AttachmentId;
+import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
+import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
+import org.thoughtcrime.securesms.jobs.AttachmentCopyJob;
+import org.thoughtcrime.securesms.jobs.AttachmentUploadJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.ApplicationContext;
@@ -43,6 +49,7 @@ import org.thoughtcrime.securesms.jobs.PushTextSendJob;
import org.thoughtcrime.securesms.jobs.SmsSendJob;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
+import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage;
import org.thoughtcrime.securesms.push.AccountManagerFactory;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
@@ -52,6 +59,9 @@ import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.push.ContactTokenDetails;
import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
public class MessageSender {
@@ -112,6 +122,100 @@ public class MessageSender {
}
}
+ public static void sendMediaBroadcast(@NonNull Context context, @NonNull List messages) {
+ if (messages.isEmpty()) {
+ Log.w(TAG, "sendMediaBroadcast() - No messages!");
+ return;
+ }
+
+ if (!isValidBroadcastList(messages)) {
+ Log.w(TAG, "sendMediaBroadcast() - Invalid message list!");
+ return;
+ }
+
+ ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
+ MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
+ AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
+ List> attachmentIds = new ArrayList<>(messages.get(0).getAttachments().size());
+ List messageIds = new ArrayList<>(messages.size());
+
+ for (int i = 0; i < messages.get(0).getAttachments().size(); i++) {
+ attachmentIds.add(new ArrayList<>(messages.size()));
+ }
+
+ try {
+ try {
+ mmsDatabase.beginTransaction();
+
+ for (OutgoingSecureMediaMessage message : messages) {
+ long allocatedThreadId = threadDatabase.getThreadIdFor(message.getRecipient(), message.getDistributionType());
+ long messageId = mmsDatabase.insertMessageOutbox(message, allocatedThreadId, false, null);
+ List attachments = attachmentDatabase.getAttachmentsForMessage(messageId);
+
+ if (attachments.size() != attachmentIds.size()) {
+ Log.w(TAG, "Got back an attachment list that was a different size than expected. Expected: " + attachmentIds.size() + " Actual: "+ attachments.size());
+ return;
+ }
+
+ for (int i = 0; i < attachments.size(); i++) {
+ attachmentIds.get(i).add(attachments.get(i).getAttachmentId());
+ }
+
+ messageIds.add(messageId);
+ }
+
+ mmsDatabase.setTransactionSuccessful();
+ } finally {
+ mmsDatabase.endTransaction();
+ }
+
+ List uploadJobs = new ArrayList<>(attachmentIds.size());
+ List copyJobs = new ArrayList<>(attachmentIds.size());
+ List messageJobs = new ArrayList<>(attachmentIds.get(0).size());
+
+ for (List idList : attachmentIds) {
+ uploadJobs.add(new AttachmentUploadJob(idList.get(0)));
+
+ if (idList.size() > 1) {
+ AttachmentId sourceId = idList.get(0);
+ List destinationIds = idList.subList(1, idList.size());
+
+ copyJobs.add(new AttachmentCopyJob(sourceId, destinationIds));
+ }
+ }
+
+ for (int i = 0; i < messageIds.size(); i++) {
+ long messageId = messageIds.get(i);
+ OutgoingSecureMediaMessage message = messages.get(i);
+ Recipient recipient = message.getRecipient();
+
+ if (isLocalSelfSend(context, recipient, false)) {
+ sendLocalMediaSelf(context, messageId);
+ } else if (isGroupPushSend(recipient)) {
+ messageJobs.add(new PushGroupSendJob(messageId, recipient.getAddress(), null));
+ } else {
+ messageJobs.add(new PushMediaSendJob(messageId, recipient.getAddress()));
+ }
+ }
+
+ Log.i(TAG, String.format(Locale.ENGLISH, "sendMediaBroadcast() - Uploading %d attachment(s), copying %d of them, then sending %d messages.",
+ uploadJobs.size(),
+ copyJobs.size(),
+ messageJobs.size()));
+
+ JobManager.Chain chain = ApplicationContext.getInstance(context).getJobManager().startChain(uploadJobs);
+
+ if (copyJobs.size() > 0) {
+ chain = chain.then(copyJobs);
+ }
+
+ chain = chain.then(messageJobs);
+ chain.enqueue();
+ } catch (MmsException e) {
+ Log.w(TAG, "sendMediaBroadcast() - Failed to send messages!", e);
+ }
+ }
+
public static void resendGroupMessage(Context context, MessageRecord messageRecord, Address filterAddress) {
if (!messageRecord.isMms()) throw new AssertionError("Not Group");
sendGroupPush(context, messageRecord.getRecipient(), messageRecord.getId(), filterAddress);
@@ -292,4 +396,20 @@ public class MessageSender {
Log.w("Failed to update self-sent message.", e);
}
}
+
+ private static boolean isValidBroadcastList(@NonNull List messages) {
+ if (messages.isEmpty()) {
+ return false;
+ }
+
+ int attachmentSize = messages.get(0).getAttachments().size();
+
+ for (OutgoingSecureMediaMessage message : messages) {
+ if (message.getAttachments().size() != attachmentSize) {
+ return false;
+ }
+ }
+
+ return true;
+ }
}
diff --git a/src/org/thoughtcrime/securesms/stickers/StickerManagementAdapter.java b/src/org/thoughtcrime/securesms/stickers/StickerManagementAdapter.java
index 5dd217003e..295f04661d 100644
--- a/src/org/thoughtcrime/securesms/stickers/StickerManagementAdapter.java
+++ b/src/org/thoughtcrime/securesms/stickers/StickerManagementAdapter.java
@@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.stickers;
import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.recyclerview.widget.RecyclerView;
@@ -11,7 +10,6 @@ import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
-import com.annimon.stream.Stream;
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
import org.thoughtcrime.securesms.R;
@@ -75,8 +73,8 @@ final class StickerManagementAdapter extends SectionedRecyclerViewAdapter idGenerator, int globalPosition);
- protected int getLocalPosition(int globalPosition) {
+ protected final int getLocalPosition(int globalPosition) {
return globalPosition - offset;
}
- public int getViewType(int globalPosition) {
- int localPosition = globalPosition - offset;
+ final int getViewType(int globalPosition) {
+ int localPosition = getLocalPosition(globalPosition);
if (localPosition == 0) {
return TYPE_HEADER;
@@ -122,12 +122,12 @@ public abstract class SectionedRecyclerViewAdapter= 0 && localPosition < size();
}
- public int size() {
+ public final int size() {
if (getContentSize() == 0 && hasEmptyState()) {
return 2;
} else if (getContentSize() == 0) {