/* _____ _
* |_ _| |_ _ _ ___ ___ _ __ __ _
* | | | ' \| '_/ -_) -_) ' \/ _` |_
* |_| |_||_|_| \___\___|_|_|_\__,_(_)
*
* Threema for Android
* Copyright (c) 2014-2022 Threema GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
package ch.threema.app.messagereceiver;
import android.content.Intent;
import android.graphics.Bitmap;
import com.neilalexander.jnacl.NaCl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.SecureRandom;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import ch.threema.app.ThreemaApplication;
import ch.threema.app.collections.Functional;
import ch.threema.app.collections.IPredicateNonNull;
import ch.threema.app.services.ApiService;
import ch.threema.app.services.ContactService;
import ch.threema.app.services.GroupMessagingService;
import ch.threema.app.services.GroupService;
import ch.threema.app.services.MessageService;
import ch.threema.app.utils.GroupUtil;
import ch.threema.app.utils.NameUtil;
import ch.threema.app.utils.TestUtil;
import ch.threema.base.ThreemaException;
import ch.threema.base.utils.Utils;
import ch.threema.domain.models.MessageId;
import ch.threema.domain.protocol.ThreemaFeature;
import ch.threema.domain.protocol.blob.BlobUploader;
import ch.threema.domain.protocol.csp.ProtocolDefines;
import ch.threema.domain.protocol.csp.messages.AbstractGroupMessage;
import ch.threema.domain.protocol.csp.messages.GroupLocationMessage;
import ch.threema.domain.protocol.csp.messages.GroupTextMessage;
import ch.threema.domain.protocol.csp.messages.ballot.BallotData;
import ch.threema.domain.protocol.csp.messages.ballot.BallotId;
import ch.threema.domain.protocol.csp.messages.ballot.BallotVote;
import ch.threema.domain.protocol.csp.messages.ballot.GroupBallotCreateMessage;
import ch.threema.domain.protocol.csp.messages.ballot.GroupBallotVoteMessage;
import ch.threema.domain.protocol.csp.messages.file.FileData;
import ch.threema.domain.protocol.csp.messages.file.GroupFileMessage;
import ch.threema.storage.DatabaseServiceNew;
import ch.threema.storage.models.AbstractMessageModel;
import ch.threema.storage.models.ContactModel;
import ch.threema.storage.models.GroupMemberModel;
import ch.threema.storage.models.GroupMessageModel;
import ch.threema.storage.models.GroupMessagePendingMessageIdModel;
import ch.threema.storage.models.GroupModel;
import ch.threema.storage.models.MessageState;
import ch.threema.storage.models.MessageType;
import ch.threema.storage.models.access.GroupAccessModel;
import ch.threema.storage.models.ballot.BallotModel;
import ch.threema.storage.models.data.LocationDataModel;
import ch.threema.storage.models.data.MessageContentsType;
import ch.threema.storage.models.data.media.FileDataModel;
public class GroupMessageReceiver implements MessageReceiver {
private static final Logger logger = LoggerFactory.getLogger(GroupMessageReceiver.class);
private final GroupModel group;
private final GroupService groupService;
private Bitmap avatar = null;
private final DatabaseServiceNew databaseServiceNew;
private final GroupMessagingService groupMessagingService;
private ContactService contactService;
private ApiService apiService;
public GroupMessageReceiver(GroupModel group,
GroupService groupService,
DatabaseServiceNew databaseServiceNew,
GroupMessagingService groupMessagingService,
ContactService contactService,
ApiService apiService) {
this.group = group;
this.groupService = groupService;
this.databaseServiceNew = databaseServiceNew;
this.groupMessagingService = groupMessagingService;
this.contactService = contactService;
this.apiService = apiService;
}
@Override
public GroupMessageModel createLocalModel(MessageType type, @MessageContentsType int messageContentsType, Date postedAt) {
GroupMessageModel m = new GroupMessageModel();
m.setType(type);
m.setMessageContentsType(messageContentsType);
m.setGroupId(this.group.getId());
m.setPostedAt(postedAt);
m.setCreatedAt(new Date());
m.setSaved(false);
m.setUid(UUID.randomUUID().toString());
return m;
}
@Override
@Deprecated
public GroupMessageModel createAndSaveStatusModel(String statusBody, Date postedAt) {
GroupMessageModel m = new GroupMessageModel(true);
m.setType(MessageType.TEXT);
m.setGroupId(this.group.getId());
m.setPostedAt(postedAt);
m.setCreatedAt(new Date());
m.setSaved(true);
m.setUid(UUID.randomUUID().toString());
m.setBody(statusBody);
this.saveLocalModel(m);
return m;
}
@Override
public void saveLocalModel(GroupMessageModel save) {
this.databaseServiceNew.getGroupMessageModelFactory().createOrUpdate(save);
}
@Override
public boolean createBoxedTextMessage(final String text, final GroupMessageModel messageModel) throws ThreemaException {
return this.sendMessage(messageId -> {
GroupTextMessage boxedTextMessage = new GroupTextMessage();
boxedTextMessage.setMessageId(messageId);
boxedTextMessage.setText(text);
if (messageId != null) {
messageModel.setApiMessageId(messageId.toString());
}
return boxedTextMessage;
}, messageModel);
}
@Override
public boolean createBoxedLocationMessage(GroupMessageModel messageModel) throws ThreemaException {
return this.sendMessage(messageId -> {
final LocationDataModel locationDataModel = messageModel.getLocationData();
final GroupLocationMessage msg = new GroupLocationMessage();
msg.setMessageId(messageId);
msg.setLatitude(locationDataModel.getLatitude());
msg.setLongitude(locationDataModel.getLongitude());
msg.setAccuracy(locationDataModel.getAccuracy());
msg.setPoiName(locationDataModel.getPoi());
msg.setPoiAddress(locationDataModel.getAddress());
if (messageId != null) {
messageModel.setApiMessageId(messageId.toString());
}
return msg;
}, messageModel);
}
@Override
public boolean createBoxedFileMessage(final byte[] thumbnailBlobId,
final byte[] fileBlobId, final EncryptResult fileResult,
final GroupMessageModel messageModel) throws ThreemaException {
List supportedContacts = contactService.getByIdentities(this.groupService.getGroupIdentities(group));
String[] identities = new String[supportedContacts.size()];
for(int n = 0; n < supportedContacts.size(); n++) {
identities[n] = supportedContacts.get(n).getIdentity();
}
final FileDataModel modelFileData = messageModel.getFileData();
return this.sendMessage(messageId -> {
final GroupFileMessage fileMessage = new GroupFileMessage();
fileMessage.setMessageId(messageId);
final FileData fileData = new FileData();
fileData
.setFileBlobId(fileBlobId)
.setThumbnailBlobId(thumbnailBlobId)
.setEncryptionKey(fileResult.getKey())
.setMimeType(modelFileData.getMimeType())
.setThumbnailMimeType(modelFileData.getThumbnailMimeType())
.setFileSize(modelFileData.getFileSize())
.setFileName(modelFileData.getFileName())
.setRenderingType(modelFileData.getRenderingType())
.setDescription(modelFileData.getCaption())
.setCorrelationId(messageModel.getCorrelationId())
.setMetaData(modelFileData.getMetaData());
fileMessage.setData(fileData);
if (messageId != null) {
messageModel.setApiMessageId(messageId.toString());
}
logger.info(
"Enqueue group file message ID {} to {}",
fileMessage.getMessageId(),
fileMessage.getToIdentity()
);
return fileMessage;
}, messageModel, identities);
}
@Override
public boolean createBoxedBallotMessage(final BallotData ballotData,
final BallotModel ballotModel,
final String[] filteredIdentities,
@Nullable GroupMessageModel abstractMessageModel) throws ThreemaException {
final BallotId ballotId = new BallotId(Utils.hexStringToByteArray(ballotModel.getApiBallotId()));
return this.sendMessage(messageId -> {
final GroupBallotCreateMessage msg = new GroupBallotCreateMessage();
msg.setMessageId(messageId);
msg.setBallotCreator(ballotModel.getCreatorIdentity());
msg.setBallotId(ballotId);
msg.setData(ballotData);
if (abstractMessageModel != null && messageId != null) {
abstractMessageModel.setApiMessageId(messageId.toString());
}
logger.info("Enqueue ballot message ID {} to {}", msg.getMessageId(), msg.getToIdentity());
return msg;
}, null, filteredIdentities);
}
@Override
public boolean createBoxedBallotVoteMessage(final BallotVote[] votes, final BallotModel ballotModel) throws ThreemaException {
final BallotId ballotId = new BallotId(Utils.hexStringToByteArray(ballotModel.getApiBallotId()));
String[] toIdentities = this.groupService.getGroupIdentities(this.group);
switch (ballotModel.getType()) {
case RESULT_ON_CLOSE:
String toIdentity = null;
for(String i: toIdentities) {
if(TestUtil.compare(i, ballotModel.getCreatorIdentity())) {
toIdentity = i;
}
}
if(toIdentity == null) {
throw new ThreemaException("cannot send a ballot vote to another group!");
}
toIdentities = new String[] {toIdentity};
//only to the creator
break;
}
return this.sendMessage(messageId -> {
final GroupBallotVoteMessage msg = new GroupBallotVoteMessage();
msg.setMessageId(messageId);
msg.setBallotCreator(ballotModel.getCreatorIdentity());
msg.setBallotId(ballotId);
for (BallotVote v : votes) {
msg.getBallotVotes().add(v);
}
logger.info("Enqueue ballot vote message ID {} to {}", msg.getMessageId(), msg.getToIdentity());
return msg;
}, null, toIdentities);
}
@Override
public List loadMessages(MessageService.MessageFilter filter) throws SQLException {
return this.databaseServiceNew.getGroupMessageModelFactory().find(
this.group.getId(),
filter);
}
@Override
public long getMessagesCount() {
return this.databaseServiceNew.getGroupMessageModelFactory().countMessages(
this.group.getId());
}
@Override
public long getUnreadMessagesCount() {
return this.databaseServiceNew.getGroupMessageModelFactory().countUnreadMessages(
this.group.getId());
}
@Override
public List getUnreadMessages() {
return this.databaseServiceNew.getGroupMessageModelFactory().getUnreadMessages(
this.group.getId());
}
public GroupModel getGroup() {
return this.group;
}
@Override
public boolean isEqual(MessageReceiver o) {
return o instanceof GroupMessageReceiver && ((GroupMessageReceiver) o).getGroup().getId() == this.getGroup().getId();
}
@Override
public String getDisplayName() {
return NameUtil.getDisplayName(this.group, this.groupService);
}
@Override
public String getShortName() {
return getDisplayName();
}
@Override
public void prepareIntent(Intent intent) {
intent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, this.group.getId());
}
@Override
public Bitmap getNotificationAvatar() {
//lacy
if(this.avatar == null && this.groupService != null) {
this.avatar = this.groupService.getAvatar(group, false);
}
return this.avatar;
}
@Override
@Deprecated
public int getUniqueId() {
if (this.groupService != null && this.group != null) {
return this.groupService.getUniqueId(this.group);
}
return 0;
}
@Override
public String getUniqueIdString() {
if (this.groupService != null && this.group != null) {
return this.groupService.getUniqueIdString(this.group);
}
return "";
}
@Override
public EncryptResult encryptFileData(final byte[] fileData) throws ThreemaException {
//generate random symmetric key for file encryption
SecureRandom rnd = new SecureRandom();
final byte[] encryptionKey = new byte[NaCl.SYMMKEYBYTES];
rnd.nextBytes(encryptionKey);
NaCl.symmetricEncryptDataInplace(fileData, encryptionKey, ProtocolDefines.FILE_NONCE);
BlobUploader blobUploaderThumbnail = apiService.createUploader(fileData);
blobUploaderThumbnail.setVersion(ThreemaApplication.getAppVersion());
return new EncryptResult() {
@Override
public byte[] getData() {
return fileData;
}
@Override
public byte[] getKey() {
return encryptionKey;
}
@Override
public byte[] getNonce() {
return ProtocolDefines.FILE_NONCE;
}
@Override
public int getSize() {
return fileData.length;
}
};
}
@Override
public EncryptResult encryptFileThumbnailData(byte[] fileThumbnailData, final byte[] encryptionKey) throws ThreemaException {
final byte[] thumbnailBoxed = NaCl.symmetricEncryptData(fileThumbnailData, encryptionKey, ProtocolDefines.FILE_THUMBNAIL_NONCE);
BlobUploader blobUploaderThumbnail = apiService.createUploader(thumbnailBoxed);
blobUploaderThumbnail.setVersion(ThreemaApplication.getAppVersion());
return new EncryptResult() {
@Override
public byte[] getData() {
return thumbnailBoxed;
}
@Override
public byte[] getKey() {
return encryptionKey;
}
@Override
public byte[] getNonce() {
return ProtocolDefines.FILE_THUMBNAIL_NONCE;
}
@Override
public int getSize() {
return thumbnailBoxed.length;
}
};
}
@Override
public boolean isMessageBelongsToMe(AbstractMessageModel message) {
return message instanceof GroupMessageModel
&& ((GroupMessageModel)message).getGroupId() == this.group.getId();
}
@Override
public boolean sendMediaData() {
// don't really send off group media if user is the only group member left - keep it local
String[] groupIdentities = this.groupService.getGroupIdentities(this.group);
return groupIdentities == null || groupIdentities.length != 1 || !groupService.isGroupMember(this.group);
}
@Override
public boolean offerRetry() {
return false;
}
@Override
public boolean validateSendingPermission(OnSendingPermissionDenied onSendingPermissionDenied) {
//TODO: cache access? performance
GroupAccessModel access = this.groupService.getAccess(getGroup(), true);
if(access == null) {
//what?
return false;
}
if(!access.getCanSendMessageAccess().isAllowed()) {
if(onSendingPermissionDenied != null) {
onSendingPermissionDenied.denied(access.getCanSendMessageAccess().getNotAllowedTestResourceId());
}
return false;
}
return true;
}
@Override
@MessageReceiverType
public int getType() {
return Type_GROUP;
}
@Override
public String[] getIdentities() {
return this.groupService.getGroupIdentities(this.group);
}
@Override
public String[] getIdentities(final int requiredFeature) {
List members = Functional.filter(this.groupService.getGroupMembers(this.group), new IPredicateNonNull() {
@Override
public boolean apply(@NonNull GroupMemberModel groupMemberModel) {
ContactModel model = contactService.getByIdentity(groupMemberModel.getIdentity());
return model != null && ThreemaFeature.hasFeature(model.getFeatureMask(), requiredFeature);
}
});
String[] identities = new String[members.size()];
for(int p = 0; p < members.size(); p++) {
identities[p] = members.get(p).getIdentity();
}
return identities;
}
private boolean sendMessage(GroupMessagingService.CreateApiMessage createApiMessage, AbstractMessageModel messageModel) throws ThreemaException {
return this.sendMessage(createApiMessage, messageModel, null);
}
/**
* Send a message to a group.
*
* @param createApiMessage A callback that creates the {@link AbstractGroupMessage} that will be sent.
* @param messageModel The model representing this message. It will be updated with status updates.
* @param groupIdentities List of group identities that will receive this group message. If set to null, the identities belonging to the current group will be used.
* @return
* @throws ThreemaException
*/
private boolean sendMessage(
@NonNull GroupMessagingService.CreateApiMessage createApiMessage,
final AbstractMessageModel messageModel,
@Nullable String[] groupIdentities
) throws ThreemaException {
if(groupIdentities == null) {
groupIdentities = this.groupService.getGroupIdentities(this.group);
}
// do not send messages to a broadcast/gateway group that does not receive and store incoming messages
if (groupIdentities.length >= 2
&& !GroupUtil.sendMessageToCreator(group)) {
// remove creator from list of recipients
ArrayList fixedGroupIdentities = new ArrayList<>(Arrays.asList(groupIdentities));
fixedGroupIdentities.remove(group.getCreatorIdentity());
groupIdentities = fixedGroupIdentities.toArray(new String[0]);
}
// don't really send off messages if user is the only group member left - keep them local
if (groupIdentities.length == 1 && groupService.isGroupMember(this.group)) {
if(messageModel != null) {
MessageId messageId = new MessageId();
messageModel.setIsQueued(true);
messageModel.setApiMessageId(messageId.toString());
groupService.setIsArchived(group, false);
messageModel.setState(MessageState.READ);
messageModel.setModifiedAt(new Date());
return true;
}
}
int enqueuedMessagesCount = this.groupMessagingService.sendMessage(this.group, groupIdentities, createApiMessage, queuedGroupMessage -> {
// Set as queued (first)
groupService.setIsArchived(group, false);
if(messageModel == null) {
return;
}
if(!messageModel.isQueued()) {
messageModel.setIsQueued(true);
}
//save identity message model
databaseServiceNew.getGroupMessagePendingMessageIdModelFactory()
.create(
new GroupMessagePendingMessageIdModel(messageModel.getId(), queuedGroupMessage.getMessageId().toString()));
});
return enqueuedMessagesCount > 0;
}
@Override
public String toString() {
return "GroupMessageReceiver (GroupId = " + this.group.getId() + ")";
}
}