Signal-Android/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java

593 lines
26 KiB
Java

/*
* Copyright (C) 2011 Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.sms;
import android.content.Context;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.greenrobot.eventbus.EventBus;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.Database;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.MessageDatabase.SyncMessageId;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobs.AttachmentCompressionJob;
import org.thoughtcrime.securesms.jobs.AttachmentCopyJob;
import org.thoughtcrime.securesms.jobs.AttachmentMarkUploadedJob;
import org.thoughtcrime.securesms.jobs.AttachmentUploadJob;
import org.thoughtcrime.securesms.jobs.MmsSendJob;
import org.thoughtcrime.securesms.jobs.ProfileKeySendJob;
import org.thoughtcrime.securesms.jobs.PushGroupSendJob;
import org.thoughtcrime.securesms.jobs.PushMediaSendJob;
import org.thoughtcrime.securesms.jobs.PushTextSendJob;
import org.thoughtcrime.securesms.jobs.ReactionSendJob;
import org.thoughtcrime.securesms.jobs.RemoteDeleteSendJob;
import org.thoughtcrime.securesms.jobs.ResumableUploadSpecJob;
import org.thoughtcrime.securesms.jobs.SmsSendJob;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.util.ParcelUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.libsignal.util.guava.Preconditions;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
public class MessageSender {
private static final String TAG = MessageSender.class.getSimpleName();
/**
* Suitable for a 1:1 conversation or a GV1 group only.
*/
@WorkerThread
public static void sendProfileKey(final Context context, final long threadId) {
ApplicationDependencies.getJobManager().add(ProfileKeySendJob.create(context, threadId));
}
public static long send(final Context context,
final OutgoingTextMessage message,
final long threadId,
final boolean forceSms,
final SmsDatabase.InsertListener insertListener)
{
MessageDatabase database = DatabaseFactory.getSmsDatabase(context);
Recipient recipient = message.getRecipient();
boolean keyExchange = message.isKeyExchange();
long allocatedThreadId = DatabaseFactory.getThreadDatabase(context).getOrCreateValidThreadId(recipient, threadId);
long messageId = database.insertMessageOutbox(allocatedThreadId, message, forceSms, System.currentTimeMillis(), insertListener);
sendTextMessage(context, recipient, forceSms, keyExchange, messageId);
onMessageSent();
return allocatedThreadId;
}
public static long send(final Context context,
final OutgoingMediaMessage message,
final long threadId,
final boolean forceSms,
final SmsDatabase.InsertListener insertListener)
{
try {
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
MessageDatabase database = DatabaseFactory.getMmsDatabase(context);
long allocatedThreadId = threadDatabase.getOrCreateValidThreadId(message.getRecipient(), threadId, message.getDistributionType());
Recipient recipient = message.getRecipient();
long messageId = database.insertMessageOutbox(message, allocatedThreadId, forceSms, insertListener);
sendMediaMessage(context, recipient, forceSms, messageId, Collections.emptyList());
onMessageSent();
return allocatedThreadId;
} catch (MmsException e) {
Log.w(TAG, e);
return threadId;
}
}
public static long sendPushWithPreUploadedMedia(final Context context,
final OutgoingMediaMessage message,
final Collection<PreUploadResult> preUploadResults,
final long threadId,
final SmsDatabase.InsertListener insertListener)
{
Preconditions.checkArgument(message.getAttachments().isEmpty(), "If the media is pre-uploaded, there should be no attachments on the message.");
try {
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
long allocatedThreadId;
if (threadId == -1) {
allocatedThreadId = threadDatabase.getThreadIdFor(message.getRecipient(), message.getDistributionType());
} else {
allocatedThreadId = threadId;
}
Recipient recipient = message.getRecipient();
long messageId = mmsDatabase.insertMessageOutbox(message, allocatedThreadId, false, insertListener);
List<AttachmentId> attachmentIds = Stream.of(preUploadResults).map(PreUploadResult::getAttachmentId).toList();
List<String> jobIds = Stream.of(preUploadResults).map(PreUploadResult::getJobIds).flatMap(Stream::of).toList();
attachmentDatabase.updateMessageId(attachmentIds, messageId);
sendMediaMessage(context, recipient, false, messageId, jobIds);
onMessageSent();
return allocatedThreadId;
} catch (MmsException e) {
Log.w(TAG, e);
return threadId;
}
}
public static void sendMediaBroadcast(@NonNull Context context, @NonNull List<OutgoingSecureMediaMessage> messages, @NonNull Collection<PreUploadResult> preUploadResults) {
Preconditions.checkArgument(messages.size() > 0, "No messages!");
Preconditions.checkArgument(Stream.of(messages).allMatch(m -> m.getAttachments().isEmpty()), "Messages can't have attachments! They should be pre-uploaded.");
JobManager jobManager = ApplicationDependencies.getJobManager();
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
List<AttachmentId> preUploadAttachmentIds = Stream.of(preUploadResults).map(PreUploadResult::getAttachmentId).toList();
List<String> preUploadJobIds = Stream.of(preUploadResults).map(PreUploadResult::getJobIds).flatMap(Stream::of).toList();
List<Long> messageIds = new ArrayList<>(messages.size());
List<String> messageDependsOnIds = new ArrayList<>(preUploadJobIds);
mmsDatabase.beginTransaction();
try {
OutgoingSecureMediaMessage primaryMessage = messages.get(0);
long primaryThreadId = threadDatabase.getThreadIdFor(primaryMessage.getRecipient(), primaryMessage.getDistributionType());
long primaryMessageId = mmsDatabase.insertMessageOutbox(primaryMessage, primaryThreadId, false, null);
attachmentDatabase.updateMessageId(preUploadAttachmentIds, primaryMessageId);
messageIds.add(primaryMessageId);
if (messages.size() > 0) {
List<OutgoingSecureMediaMessage> secondaryMessages = messages.subList(1, messages.size());
List<List<AttachmentId>> attachmentCopies = new ArrayList<>();
List<DatabaseAttachment> preUploadAttachments = Stream.of(preUploadAttachmentIds)
.map(attachmentDatabase::getAttachment)
.toList();
for (int i = 0; i < preUploadAttachmentIds.size(); i++) {
attachmentCopies.add(new ArrayList<>(messages.size()));
}
for (OutgoingSecureMediaMessage secondaryMessage : secondaryMessages) {
long allocatedThreadId = threadDatabase.getThreadIdFor(secondaryMessage.getRecipient(), secondaryMessage.getDistributionType());
long messageId = mmsDatabase.insertMessageOutbox(secondaryMessage, allocatedThreadId, false, null);
List<AttachmentId> attachmentIds = new ArrayList<>(preUploadAttachmentIds.size());
for (int i = 0; i < preUploadAttachments.size(); i++) {
AttachmentId attachmentId = attachmentDatabase.insertAttachmentForPreUpload(preUploadAttachments.get(i)).getAttachmentId();
attachmentCopies.get(i).add(attachmentId);
attachmentIds.add(attachmentId);
}
attachmentDatabase.updateMessageId(attachmentIds, messageId);
messageIds.add(messageId);
}
for (int i = 0; i < attachmentCopies.size(); i++) {
Job copyJob = new AttachmentCopyJob(preUploadAttachmentIds.get(i), attachmentCopies.get(i));
jobManager.add(copyJob, preUploadJobIds);
messageDependsOnIds.add(copyJob.getId());
}
}
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)) {
jobManager.add(new PushGroupSendJob(messageId, recipient.getId(), null, true), messageDependsOnIds, recipient.getId().toQueueKey());
} else {
jobManager.add(new PushMediaSendJob(messageId, recipient), messageDependsOnIds, recipient.getId().toQueueKey());
}
}
onMessageSent();
mmsDatabase.setTransactionSuccessful();
} catch (MmsException e) {
Log.w(TAG, "Failed to send messages.", e);
} finally {
mmsDatabase.endTransaction();
}
}
/**
* @return A result if the attachment was enqueued, or null if it failed to enqueue or shouldn't
* be enqueued (like in the case of a local self-send).
*/
public static @Nullable PreUploadResult preUploadPushAttachment(@NonNull Context context, @NonNull Attachment attachment, @Nullable Recipient recipient) {
if (recipient != null && isLocalSelfSend(context, recipient, false)) {
return null;
}
try {
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
DatabaseAttachment databaseAttachment = attachmentDatabase.insertAttachmentForPreUpload(attachment);
Job compressionJob = AttachmentCompressionJob.fromAttachment(databaseAttachment, false, -1);
Job resumableUploadSpecJob = new ResumableUploadSpecJob();
Job uploadJob = new AttachmentUploadJob(databaseAttachment.getAttachmentId());
ApplicationDependencies.getJobManager()
.startChain(compressionJob)
.then(resumableUploadSpecJob)
.then(uploadJob)
.enqueue();
return new PreUploadResult(databaseAttachment.getAttachmentId(), Arrays.asList(compressionJob.getId(), resumableUploadSpecJob.getId(), uploadJob.getId()));
} catch (MmsException e) {
Log.w(TAG, "preUploadPushAttachment() - Failed to upload!", e);
return null;
}
}
public static void sendNewReaction(@NonNull Context context, long messageId, boolean isMms, @NonNull String emoji) {
MessageDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context);
ReactionRecord reaction = new ReactionRecord(emoji, Recipient.self().getId(), System.currentTimeMillis(), System.currentTimeMillis());
db.addReaction(messageId, reaction);
try {
ApplicationDependencies.getJobManager().add(ReactionSendJob.create(context, messageId, isMms, reaction, false));
onMessageSent();
} catch (NoSuchMessageException e) {
Log.w(TAG, "[sendNewReaction] Could not find message! Ignoring.");
}
}
public static void sendReactionRemoval(@NonNull Context context, long messageId, boolean isMms, @NonNull ReactionRecord reaction) {
MessageDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context);
db.deleteReaction(messageId, reaction.getAuthor());
try {
ApplicationDependencies.getJobManager().add(ReactionSendJob.create(context, messageId, isMms, reaction, true));
onMessageSent();
} catch (NoSuchMessageException e) {
Log.w(TAG, "[sendReactionRemoval] Could not find message! Ignoring.");
}
}
public static void sendRemoteDelete(@NonNull Context context, long messageId, boolean isMms) {
MessageDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context);
db.markAsRemoteDelete(messageId);
db.markAsSending(messageId);
try {
ApplicationDependencies.getJobManager().add(RemoteDeleteSendJob.create(context, messageId, isMms));
onMessageSent();
} catch (NoSuchMessageException e) {
Log.w(TAG, "[sendRemoteDelete] Could not find message! Ignoring.");
}
}
public static void resendGroupMessage(Context context, MessageRecord messageRecord, RecipientId filterRecipientId) {
if (!messageRecord.isMms()) throw new AssertionError("Not Group");
sendGroupPush(context, messageRecord.getRecipient(), messageRecord.getId(), filterRecipientId, Collections.emptyList());
onMessageSent();
}
public static void resend(Context context, MessageRecord messageRecord) {
long messageId = messageRecord.getId();
boolean forceSms = messageRecord.isForcedSms();
boolean keyExchange = messageRecord.isKeyExchange();
Recipient recipient = messageRecord.getRecipient();
if (messageRecord.isMms()) {
sendMediaMessage(context, recipient, forceSms, messageId, Collections.emptyList());
} else {
sendTextMessage(context, recipient, forceSms, keyExchange, messageId);
}
onMessageSent();
}
public static void onMessageSent() {
EventBus.getDefault().postSticky(MessageSentEvent.INSTANCE);
}
private static void sendMediaMessage(Context context, Recipient recipient, boolean forceSms, long messageId, @NonNull Collection<String> uploadJobIds)
{
if (isLocalSelfSend(context, recipient, forceSms)) {
sendLocalMediaSelf(context, messageId);
} else if (isGroupPushSend(recipient)) {
sendGroupPush(context, recipient, messageId, null, uploadJobIds);
} else if (!forceSms && isPushMediaSend(context, recipient)) {
sendMediaPush(context, recipient, messageId, uploadJobIds);
} else {
sendMms(context, messageId);
}
}
private static void sendTextMessage(Context context, Recipient recipient,
boolean forceSms, boolean keyExchange,
long messageId)
{
if (isLocalSelfSend(context, recipient, forceSms)) {
sendLocalTextSelf(context, messageId);
} else if (!forceSms && isPushTextSend(context, recipient, keyExchange)) {
sendTextPush(recipient, messageId);
} else {
sendSms(recipient, messageId);
}
}
private static void sendTextPush(Recipient recipient, long messageId) {
JobManager jobManager = ApplicationDependencies.getJobManager();
jobManager.add(new PushTextSendJob(messageId, recipient));
}
private static void sendMediaPush(Context context, Recipient recipient, long messageId, @NonNull Collection<String> uploadJobIds) {
JobManager jobManager = ApplicationDependencies.getJobManager();
if (uploadJobIds.size() > 0) {
Job mediaSend = new PushMediaSendJob(messageId, recipient);
jobManager.add(mediaSend, uploadJobIds);
} else {
PushMediaSendJob.enqueue(context, jobManager, messageId, recipient);
}
}
private static void sendGroupPush(Context context, Recipient recipient, long messageId, RecipientId filterRecipientId, @NonNull Collection<String> uploadJobIds) {
JobManager jobManager = ApplicationDependencies.getJobManager();
if (uploadJobIds.size() > 0) {
Job groupSend = new PushGroupSendJob(messageId, recipient.getId(), filterRecipientId, !uploadJobIds.isEmpty());
jobManager.add(groupSend, uploadJobIds, uploadJobIds.isEmpty() ? null : recipient.getId().toQueueKey());
} else {
PushGroupSendJob.enqueue(context, jobManager, messageId, recipient.getId(), filterRecipientId);
}
}
private static void sendSms(Recipient recipient, long messageId) {
JobManager jobManager = ApplicationDependencies.getJobManager();
jobManager.add(new SmsSendJob(messageId, recipient));
}
private static void sendMms(Context context, long messageId) {
JobManager jobManager = ApplicationDependencies.getJobManager();
MmsSendJob.enqueue(context, jobManager, messageId);
}
private static boolean isPushTextSend(Context context, Recipient recipient, boolean keyExchange) {
if (!TextSecurePreferences.isPushRegistered(context)) {
return false;
}
if (keyExchange) {
return false;
}
return isPushDestination(context, recipient);
}
private static boolean isPushMediaSend(Context context, Recipient recipient) {
if (!TextSecurePreferences.isPushRegistered(context)) {
return false;
}
if (recipient.isGroup()) {
return false;
}
return isPushDestination(context, recipient);
}
private static boolean isGroupPushSend(Recipient recipient) {
return recipient.isGroup() && !recipient.isMmsGroup();
}
private static boolean isPushDestination(Context context, Recipient destination) {
if (destination.resolve().getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) {
return true;
} else if (destination.resolve().getRegistered() == RecipientDatabase.RegisteredState.NOT_REGISTERED) {
return false;
} else {
try {
RecipientDatabase.RegisteredState state = DirectoryHelper.refreshDirectoryFor(context, destination, false);
return state == RecipientDatabase.RegisteredState.REGISTERED;
} catch (IOException e1) {
Log.w(TAG, e1);
return false;
}
}
}
public static boolean isLocalSelfSend(@NonNull Context context, @Nullable Recipient recipient, boolean forceSms) {
return recipient != null &&
recipient.isLocalNumber() &&
!forceSms &&
TextSecurePreferences.isPushRegistered(context) &&
!TextSecurePreferences.isMultiDevice(context);
}
private static void sendLocalMediaSelf(Context context, long messageId) {
try {
ExpiringMessageManager expirationManager = ApplicationContext.getInstance(context).getExpiringMessageManager();
MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context);
OutgoingMediaMessage message = mmsDatabase.getOutgoingMessage(messageId);
SyncMessageId syncId = new SyncMessageId(Recipient.self().getId(), message.getSentTimeMillis());
List<Attachment> attachments = new LinkedList<>();
attachments.addAll(message.getAttachments());
attachments.addAll(Stream.of(message.getLinkPreviews())
.map(LinkPreview::getThumbnail)
.filter(Optional::isPresent)
.map(Optional::get)
.toList());
attachments.addAll(Stream.of(message.getSharedContacts())
.map(Contact::getAvatar).withoutNulls()
.map(Contact.Avatar::getAttachment).withoutNulls()
.toList());
List<AttachmentCompressionJob> compressionJobs = Stream.of(attachments)
.map(a -> AttachmentCompressionJob.fromAttachment((DatabaseAttachment) a, false, -1))
.toList();
List<AttachmentMarkUploadedJob> fakeUploadJobs = Stream.of(attachments)
.map(a -> new AttachmentMarkUploadedJob(messageId, ((DatabaseAttachment) a).getAttachmentId()))
.toList();
ApplicationDependencies.getJobManager().startChain(compressionJobs)
.then(fakeUploadJobs)
.enqueue();
mmsDatabase.markAsSent(messageId, true);
mmsDatabase.markUnidentified(messageId, true);
mmsSmsDatabase.incrementDeliveryReceiptCount(syncId, System.currentTimeMillis());
mmsSmsDatabase.incrementReadReceiptCount(syncId, System.currentTimeMillis());
if (message.getExpiresIn() > 0 && !message.isExpirationUpdate()) {
mmsDatabase.markExpireStarted(messageId);
expirationManager.scheduleDeletion(messageId, true, message.getExpiresIn());
}
} catch (NoSuchMessageException | MmsException e) {
Log.w(TAG, "Failed to update self-sent message.", e);
}
}
private static void sendLocalTextSelf(Context context, long messageId) {
try {
ExpiringMessageManager expirationManager = ApplicationContext.getInstance(context).getExpiringMessageManager();
MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context);
MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context);
SmsMessageRecord message = smsDatabase.getSmsMessage(messageId);
SyncMessageId syncId = new SyncMessageId(Recipient.self().getId(), message.getDateSent());
smsDatabase.markAsSent(messageId, true);
smsDatabase.markUnidentified(messageId, true);
mmsSmsDatabase.incrementDeliveryReceiptCount(syncId, System.currentTimeMillis());
mmsSmsDatabase.incrementReadReceiptCount(syncId, System.currentTimeMillis());
if (message.getExpiresIn() > 0) {
smsDatabase.markExpireStarted(messageId);
expirationManager.scheduleDeletion(message.getId(), message.isMms(), message.getExpiresIn());
}
} catch (NoSuchMessageException e) {
Log.w(TAG, "Failed to update self-sent message.", e);
}
}
public static class PreUploadResult implements Parcelable {
private final AttachmentId attachmentId;
private final Collection<String> jobIds;
PreUploadResult(@NonNull AttachmentId attachmentId, @NonNull Collection<String> jobIds) {
this.attachmentId = attachmentId;
this.jobIds = jobIds;
}
private PreUploadResult(Parcel in) {
this.attachmentId = in.readParcelable(AttachmentId.class.getClassLoader());
this.jobIds = ParcelUtil.readStringCollection(in);
}
public @NonNull AttachmentId getAttachmentId() {
return attachmentId;
}
public @NonNull Collection<String> getJobIds() {
return jobIds;
}
public static final Creator<PreUploadResult> CREATOR = new Creator<PreUploadResult>() {
@Override
public PreUploadResult createFromParcel(Parcel in) {
return new PreUploadResult(in);
}
@Override
public PreUploadResult[] newArray(int size) {
return new PreUploadResult[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeParcelable(attachmentId, flags);
ParcelUtil.writeStringCollection(dest, jobIds);
}
}
public enum MessageSentEvent {
INSTANCE
}
}