Implement camera-first capture flow.

This allows you to take a photo, then choose the recipients after. This
also makes it so we only upload the attachment once.
This commit is contained in:
Greyson Parrelli 2019-07-03 15:07:00 -04:00
parent 4fbb87b5b7
commit beaa86389d
44 changed files with 1672 additions and 125 deletions

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple
xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/transparent_white_40">
<item android:id="@+id/mask">
<shape android:shape="oval">
<solid android:color="@color/transparent_black" />
</shape>
</item>
<item>
<shape android:shape="oval" >
<solid android:color="@color/signal_primary"/>
</shape>
</item>
</ripple>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple
xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/transparent_white_40">
<item android:id="@+id/mask">
<shape android:shape="oval">
<solid android:color="@color/transparent_black" />
</shape>
</item>
<item>
<shape android:shape="oval" >
<solid android:color="@color/signal_primary"/>
</shape>
</item>
</ripple>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval" >
<solid android:color="@color/signal_primary"/>
</shape>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M2,11.2h17.8c-0.6,-0.4 -1,-0.7 -1,-0.7L13.2,5l1.1,-1.1l7.5,7.5c0.3,0.3 0.3,0.8 0,1.1l-7.5,7.5L13.2,19l5.6,-5.6c0,0 0.4,-0.3 1,-0.7H2V11.2z"/>
</vector>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval" >
<solid android:color="@color/signal_primary"/>
</shape>

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/camera_contacts_horizontal_margin"
android:paddingEnd="@dimen/camera_contacts_horizontal_margin"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:gravity="center_vertical"
android:background="?selectableItemBackground">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/camera_contact_item_avatar"
android:layout_width="40dp"
android:layout_height="40dp" />
<org.thoughtcrime.securesms.components.FromTextView
android:id="@+id/camera_contact_item_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
android:maxLines="2"
android:ellipsize="end"
style="@style/Signal.Text.Body"/>
<CheckBox
android:id="@+id/camera_contact_item_checkbox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:clickable="false"/>
</LinearLayout>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/camera_contact_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/camera_contacts_horizontal_margin"
android:layout_marginEnd="@dimen/camera_contacts_horizontal_margin"
android:layout_marginTop="16dp"
android:layout_marginBottom="24dp"
style="@style/Signal.Text.Preview"
android:fontFamily="sans-serif-medium"
tools:text="@string/CameraContacts_recent_contacts"/>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="@dimen/camera_contacts_horizontal_margin"
android:paddingEnd="@dimen/camera_contacts_horizontal_margin"
android:paddingTop="30dp"
android:paddingBottom="10dp"
android:gravity="center"
android:background="?selectableItemBackground">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Signal.Text.Preview"
android:text="@string/CameraContacts_cant_find_who_youre_looking_for"/>
<Button
android:id="@+id/camera_contact_invite"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
style="@style/Button.Borderless"
android:text="@string/CameraContacts_invite_a_contact_to_join_signal" />
</LinearLayout>

View file

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:animateLayoutChanges="true"
android:background="?android:windowBackground">
<androidx.appcompat.widget.Toolbar
android:id="@+id/camera_contacts_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/conversation_list_toolbar_background"
android:elevation="8dp"
android:theme="?actionBarStyle"
app:title="@string/CameraContacts_select_signal_recipients"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:id="@+id/camera_contacts_empty"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="60dp"
android:layout_marginStart="50dp"
android:layout_marginEnd="50dp"
android:orientation="vertical"
android:gravity="center_horizontal"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/camera_contacts_toolbar">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/no_contacts"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
style="@style/Signal.Text.Body"
android:text="@string/CameraContacts_no_signal_contacts"
android:fontFamily="sans-serif-medium"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_horizontal"
style="@style/Signal.Text.Body"
android:text="@string/CameraContacts_you_can_only_use_the_camera_button"/>
<Button
android:id="@+id/camera_contacts_invite_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_gravity="center"
style="@style/Button.Borderless"
android:text="@string/CameraContacts_invite_a_contact_to_join_signal" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/camera_contacts_list"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="@id/camera_contacts_footer_barrier"
app:layout_constraintTop_toBottomOf="@id/camera_contacts_toolbar" />
<View
android:id="@+id/view"
android:layout_width="match_parent"
android:layout_height="2dp"
android:background="@drawable/compose_divider_background"
app:layout_constraintBottom_toTopOf="@id/camera_contacts_footer_barrier" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/camera_contacts_footer_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
app:constraint_referenced_ids="camera_contacts_selected_list,camera_contacts_send_button" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/camera_contacts_selected_list"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:paddingTop="22dp"
android:paddingBottom="22dp"
android:paddingStart="16dp"
android:clipToPadding="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/camera_contacts_send_button"
app:layout_constraintStart_toStartOf="parent"/>
<ImageView
android:id="@+id/camera_contacts_send_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="6dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="6dp"
android:src="@drawable/ic_send_push_white_24dp"
android:background="@drawable/camera_send_button_background"
android:padding="8dp"
app:layout_constraintBottom_toBottomOf="@id/camera_contacts_selected_list"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/camera_contacts_selected_list" />
<androidx.constraintlayout.widget.Group
android:id="@+id/camera_contacts_footer_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="camera_contacts_send_button,camera_contacts_selected_list,view" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.FromTextView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingEnd="5sp"
style="@style/Signal.Text.Preview"/>

View file

@ -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"

View file

@ -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"

View file

@ -66,6 +66,17 @@
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/camera_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="16dp"
android:layout_marginBottom="82dp"
android:src="@drawable/ic_camera_alt_white_24dp"
android:tint="@color/core_grey_60"
android:focusable="true"
app:backgroundTint="@color/core_white"/>
<org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton
android:id="@+id/fab"

View file

@ -133,6 +133,19 @@
</FrameLayout>
<ImageView
android:id="@+id/mediasend_continue_button"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="12dp"
android:layout_gravity="bottom|end"
android:padding="6dp"
android:background="@drawable/media_continue_button_background"
android:visibility="gone"
app:srcCompat="@drawable/ic_continue_24"
tools:visibility="visible"/>
</LinearLayout>
<include

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:title=""
android:id="@+id/menu_search"
android:icon="@drawable/ic_search_white_24dp"
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="collapseActionView|always"/>
</menu>

View file

@ -26,6 +26,8 @@
<dimen name="album_5_cell_size_big">104dp</dimen>
<dimen name="album_5_cell_size_small">69dp</dimen>
<dimen name="camera_contacts_horizontal_margin">16dp</dimen>
<dimen name="message_corner_radius">16dp</dimen>
<dimen name="message_corner_collapse_radius">4dp</dimen>
<dimen name="message_bubble_corner_radius">2dp</dimen>

View file

@ -91,6 +91,17 @@
<!-- CameraActivity -->
<string name="CameraActivity_image_save_failure">Failed to save image.</string>
<!-- CameraContacts -->
<string name="CameraContacts_recent_contacts">Recent contacts</string>
<string name="CameraContacts_signal_contacts">Signal contacts</string>
<string name="CameraContacts_signal_groups">Signal groups</string>
<string name="CameraContacts_you_can_share_with_a_maximum_of_n_conversations">You can share with a maximum of %d conversations.</string>
<string name="CameraContacts_select_signal_recipients">Select Signal recipients</string>
<string name="CameraContacts_no_signal_contacts">No Signal contacts</string>
<string name="CameraContacts_you_can_only_use_the_camera_button">You can only use the camera button to send photos to Signal contacts. </string>
<string name="CameraContacts_cant_find_who_youre_looking_for">Can\'t find who you\'re looking for?</string>
<string name="CameraContacts_invite_a_contact_to_join_signal">Invite a contact to join Signal</string>
<!-- ClearProfileActivity -->
<string name="ClearProfileActivity_remove">Remove</string>
<string name="ClearProfileActivity_remove_profile_photo">Remove profile photo?</string>
@ -480,6 +491,8 @@
<string name="MediaSendActivity_an_item_was_removed_because_it_exceeded_the_size_limit">An item was removed because it exceeded the size limit</string>
<string name="MediaSendActivity_camera_unavailable">Camera unavailable.</string>
<string name="MediaSendActivity_message_to_s">Message to %s</string>
<string name="MediaSendActivity_message">Message</string>
<string name="MediaSendActivity_select_recipients">Select recipients</string>
<plurals name="MediaSendActivity_cant_share_more_than_n_items">
<item quantity="one">You can\'t share more than %d item.</item>
<item quantity="other">You can\'t share more than %d items.</item>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -182,7 +182,7 @@ public class JobManager implements ConstraintObserver.Notifier {
return then(Collections.singletonList(job));
}
public Chain then(@NonNull List<Job> jobs) {
public Chain then(@NonNull List<? extends Job> jobs) {
if (!jobs.isEmpty()) {
this.jobs.add(new ArrayList<>(jobs));
}

View file

@ -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<AttachmentId> destinationIds;
public AttachmentCopyJob(@NonNull AttachmentId sourceId, @NonNull List<AttachmentId> destinationIds) {
this(new Job.Parameters.Builder()
.setQueue("AttachmentCopyJob")
.setMaxAttempts(3)
.build(),
sourceId,
destinationIds);
}
private AttachmentCopyJob(@NonNull Parameters parameters,
@NonNull AttachmentId sourceId,
@NonNull List<AttachmentId> 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<AttachmentCopyJob> {
@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<AttachmentId> 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);
}
}
}
}

View file

@ -23,6 +23,7 @@ public final class JobManagerFactories {
public static Map<String, Job.Factory> getJobFactories(@NonNull Application application) {
return new HashMap<String, Job.Factory>() {{
put(AttachmentCopyJob.KEY, new AttachmentCopyJob.Factory());
put(AttachmentDownloadJob.KEY, new AttachmentDownloadJob.Factory());
put(AttachmentUploadJob.KEY, new AttachmentUploadJob.Factory());
put(AvatarDownloadJob.KEY, new AvatarDownloadJob.Factory());

View file

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

View file

@ -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<String, CameraContactAdapter.ContactSection> {
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<Recipient> selected;
private final CameraContactListener cameraContactListener;
private final List<ContactSection> sections = new ArrayList<ContactSection>(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<ContactSection> 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<Recipient> 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<String> {
private final String tag;
private final int titleResId;
private final List<Recipient> recipients;
public ContactSection(@NonNull String tag, @StringRes int titleResId, @NonNull List<Recipient> 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<String> 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<Recipient> 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();
}
}

View file

@ -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<CameraContactSelectionAdapter.RecipientViewHolder> {
private final List<Recipient> recipients = new ArrayList<>();
private final StableIdGenerator<String> 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<Recipient> 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 : ",");
}
}
}

View file

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

View file

@ -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<ContactState> contacts;
private final SingleLiveEvent<Error> error;
private final Set<Recipient> 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<ContactState> getContacts() {
return contacts;
}
LiveData<Error> 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<Recipient> selected;
ContactState(CameraContacts contacts, List<Recipient> selected) {
this.contacts = contacts;
this.selected = selected;
}
public CameraContacts getContacts() {
return contacts;
}
public List<Recipient> 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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new CameraContactSelectionViewModel(repository));
}
}
}

View file

@ -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<Recipient> recents;
private final List<Recipient> contacts;
private final List<Recipient> groups;
public CameraContacts(@NonNull List<Recipient> recents, @NonNull List<Recipient> contacts, @NonNull List<Recipient> groups) {
this.recents = recents;
this.contacts = contacts;
this.groups = groups;
}
public @NonNull List<Recipient> getRecents() {
return recents;
}
public @NonNull List<Recipient> getContacts() {
return contacts;
}
public @NonNull List<Recipient> getGroups() {
return groups;
}
public boolean isEmpty() {
return recents.isEmpty() && contacts.isEmpty() && groups.isEmpty();
}
}

View file

@ -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<CameraContacts> callback) {
getCameraContacts("", callback);
}
void getCameraContacts(@NonNull String query, @NonNull Callback<CameraContacts> callback) {
SignalExecutors.BOUNDED.execute(() -> {
List<Recipient> recents = getRecents(query);
List<Recipient> contacts = getContacts(query);
List<Recipient> groups = getGroups(query);
callback.onComplete(new CameraContacts(recents, contacts, groups));
});
}
@WorkerThread
private @NonNull List<Recipient> getRecents(@NonNull String query) {
if (!TextUtils.isEmpty(query)) {
return Collections.emptyList();
}
List<Recipient> 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<Recipient> getContacts(@NonNull String query) {
List<Recipient> 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<Recipient> getGroups(@NonNull String query) {
List<Recipient> 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<E> {
void onComplete(E result);
}
}

View file

@ -23,6 +23,6 @@ public interface CameraFragment {
void onImageCaptured(@NonNull byte[] data, int width, int height);
void onGalleryClicked();
int getDisplayRotation();
void onContinueClicked();
void onCameraCountButtonClicked();
}
}

View file

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

View file

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

View file

@ -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<Recipient> 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> media, @NonNull String message, @NonNull TransportOption transport) {
viewModel.onSendClicked();
ArrayList<Media> 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<Media> mediaList, @NonNull Map<Uri, Object> savedState) {
private void processMedia(@NonNull List<Media> mediaList, @NonNull Map<Uri, Object> savedState, @NonNull OnProcessComplete callback) {
Map<Media, EditorModel> modelsToRender = new HashMap<>();
for (Media media : mediaList) {
@ -784,7 +827,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
@Override
protected void onPostExecute(List<Media> 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> media, @NonNull String message, @NonNull TransportOption transport) {
viewModel.onSendClicked();
ArrayList<Media> 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<Recipient> recipients, @NonNull List<Media> media, @NonNull String body, @NonNull TransportOption transport) {
SimpleTask.run(() -> {
List<OutgoingSecureMediaMessage> 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<Media> 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> media);
}
}

View file

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

View file

@ -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<Media> 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<Media> 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 {

View file

@ -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<OutgoingSecureMediaMessage> 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<List<AttachmentId>> attachmentIds = new ArrayList<>(messages.get(0).getAttachments().size());
List<Long> 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<DatabaseAttachment> 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<AttachmentUploadJob> uploadJobs = new ArrayList<>(attachmentIds.size());
List<AttachmentCopyJob> copyJobs = new ArrayList<>(attachmentIds.size());
List<Job> messageJobs = new ArrayList<>(attachmentIds.get(0).size());
for (List<AttachmentId> idList : attachmentIds) {
uploadJobs.add(new AttachmentUploadJob(idList.get(0)));
if (idList.size() > 1) {
AttachmentId sourceId = idList.get(0);
List<AttachmentId> 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<OutgoingSecureMediaMessage> 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;
}
}

View file

@ -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<String
}
@Override
public void bindViewHolder(@NonNull StickerSection section, @NonNull RecyclerView.ViewHolder viewHolder, int position) {
section.bindViewHolder(viewHolder, position, glideRequests, eventListener);
public void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, @NonNull StickerSection section, int localPosition) {
section.bindViewHolder(viewHolder, localPosition, glideRequests, eventListener);
}
@Override
@ -166,12 +164,10 @@ final class StickerManagementAdapter extends SectionedRecyclerViewAdapter<String
}
void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder,
int globalPosition,
int localPosition,
@NonNull GlideRequests glideRequests,
@NonNull EventListener eventListener)
{
int localPosition = getLocalPosition(globalPosition);
if (localPosition == 0) {
((HeaderViewHolder) viewHolder).bind(titleResId);
} else if (records.isEmpty()) {

View file

@ -185,6 +185,8 @@ public class TextSecurePreferences {
private static final String REVEALABLE_MESSAGE_DEFAULT = "pref_revealable_message_default";
private static final String SEEN_CAMERA_FIRST_TOOLTIP = "pref_seen_camera_first_tooltip";
public static boolean isScreenLockEnabled(@NonNull Context context) {
return getBooleanPreference(context, SCREEN_LOCK, false);
}
@ -1108,6 +1110,14 @@ public class TextSecurePreferences {
return getBooleanPreference(context, REVEALABLE_MESSAGE_DEFAULT, false);
}
public static void setHasSeenCameraFirstTooltip(Context context, boolean value) {
setBooleanPreference(context, SEEN_CAMERA_FIRST_TOOLTIP, value);
}
public static boolean hasSeendCameraFirstTooltip(Context context) {
return getBooleanPreference(context, SEEN_CAMERA_FIRST_TOOLTIP, false);
}
public static void setBooleanPreference(Context context, String key, boolean value) {
PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply();
}

View file

@ -542,6 +542,14 @@ public class Util {
return new DecimalFormat("#,##0.#").format(sizeBytes/Math.pow(1024, digitGroups)) + " " + units[digitGroups];
}
public static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
throw new AssertionError(e);
}
}
private static Handler getHandler() {
if (handler == null) {
synchronized (Util.class) {

View file

@ -34,7 +34,7 @@ public abstract class SectionedRecyclerViewAdapter<IdType, SectionImpl extends S
protected @NonNull abstract RecyclerView.ViewHolder createHeaderViewHolder(@NonNull ViewGroup parent);
protected @NonNull abstract RecyclerView.ViewHolder createContentViewHolder(@NonNull ViewGroup parent);
protected @Nullable abstract RecyclerView.ViewHolder createEmptyViewHolder(@NonNull ViewGroup viewGroup);
protected abstract void bindViewHolder(@NonNull SectionImpl section, @NonNull RecyclerView.ViewHolder holder, int position);
protected abstract void bindViewHolder(@NonNull RecyclerView.ViewHolder holder, @NonNull SectionImpl section, int localPosition);
@Override
public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
@ -55,30 +55,30 @@ public abstract class SectionedRecyclerViewAdapter<IdType, SectionImpl extends S
}
@Override
public long getItemId(int position) {
public long getItemId(int globalPosition) {
for (SectionImpl section: getSections()) {
if (section.handles(position)) {
return section.getItemId(stableIdGenerator, position);
if (section.handles(globalPosition)) {
return section.getItemId(stableIdGenerator, globalPosition);
}
}
throw new NoSectionException();
}
@Override
public int getItemViewType(int position) {
public int getItemViewType(int globalPosition) {
for (SectionImpl section : getSections()) {
if (section.handles(position)) {
return section.getViewType(position);
if (section.handles(globalPosition)) {
return section.getViewType(globalPosition);
}
}
throw new NoSectionException();
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int globalPosition) {
for (SectionImpl section : getSections()) {
if (section.handles(position)) {
bindViewHolder(section, holder, position);
if (section.handles(globalPosition)) {
bindViewHolder(holder, section, section.getLocalPosition(globalPosition));
return;
}
}
@ -106,12 +106,12 @@ public abstract class SectionedRecyclerViewAdapter<IdType, SectionImpl extends S
public abstract int getContentSize();
public abstract long getItemId(@NonNull StableIdGenerator<E> 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<IdType, SectionImpl extends S
}
}
public boolean handles(int globalPosition) {
int localPosition = globalPosition - offset;
final boolean handles(int globalPosition) {
int localPosition = getLocalPosition(globalPosition);
return localPosition >= 0 && localPosition < size();
}
public int size() {
public final int size() {
if (getContentSize() == 0 && hasEmptyState()) {
return 2;
} else if (getContentSize() == 0) {