package ch.threema.app.services;

import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.database.sqlite.SQLiteException;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.location.Location;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.Uri;
import android.os.Build;
import android.provider.DocumentsContract;
import android.text.format.DateUtils;
import android.widget.Toast;

import org.slf4j.Logger;

import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.WeakReference;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.collection.ArrayMap;
import androidx.core.app.NotificationManagerCompat;
import ch.threema.app.ExecutorServices;
import ch.threema.app.R;
import ch.threema.app.ThreemaApplication;
import ch.threema.app.emojis.EmojiUtil;
import ch.threema.app.eventbus.GlobalEventBuses;
import ch.threema.app.eventbus.events.MessageEvent;
import ch.threema.app.exceptions.NotAllowedException;
import ch.threema.app.exceptions.TranscodeCanceledException;
import ch.threema.app.managers.ServiceManager;
import ch.threema.app.messagereceiver.ContactMessageReceiver;
import ch.threema.app.messagereceiver.DistributionListMessageReceiver;
import ch.threema.app.messagereceiver.GroupMessageReceiver;
import ch.threema.app.messagereceiver.MessageReceiver;
import ch.threema.app.multidevice.MultiDeviceManager;
import ch.threema.app.notifications.NotificationIDs;
import ch.threema.app.preference.service.PreferenceService;
import ch.threema.app.preference.service.SynchronizedSettingsService;
import ch.threema.app.routines.MarkAsReadRoutine;
import ch.threema.app.services.messageplayer.MessagePlayerService;
import ch.threema.app.services.notification.NotificationService;
import ch.threema.app.services.poll.PollService;
import ch.threema.app.services.poll.PollUpdateResult;
import ch.threema.app.ui.MediaItem;
import ch.threema.app.utils.BitmapUtil;
import ch.threema.app.utils.ConfigUtils;
import ch.threema.app.utils.ElapsedTimeFormatter;
import ch.threema.app.utils.FileUtil;
import ch.threema.app.utils.GeoLocationUtil;
import ch.threema.app.utils.IconUtil;
import ch.threema.app.utils.MessageUtil;
import ch.threema.app.utils.MimeUtil;
import ch.threema.app.utils.NameUtil;
import ch.threema.app.utils.PollUtil;
import ch.threema.app.utils.QuoteUtil;
import ch.threema.app.utils.RuntimeUtil;
import ch.threema.app.utils.ThumbnailUtil;
import ch.threema.app.utils.VideoUtil;
import ch.threema.app.video.transcoder.VideoConfig;
import ch.threema.app.video.transcoder.VideoTranscoder;
import ch.threema.app.voip.groupcall.GroupCallDescription;
import ch.threema.base.ProgressListener;
import ch.threema.base.ThreemaException;
import ch.threema.base.crypto.NaCl;
import ch.threema.base.crypto.SymmetricEncryptionResult;
import ch.threema.base.crypto.SymmetricEncryptionService;
import ch.threema.data.datatypes.ContactConversationId;
import ch.threema.data.datatypes.GroupConversationId;
import ch.threema.data.models.EmojiReactionData;
import ch.threema.data.models.GroupModel;
import ch.threema.data.repositories.ContactModelRepository;
import ch.threema.data.repositories.EditHistoryRepository;
import ch.threema.data.repositories.EmojiReactionEntryCreateException;
import ch.threema.data.repositories.EmojiReactionEntryRemoveException;
import ch.threema.data.repositories.EmojiReactionsRepository;
import ch.threema.domain.models.AcquaintanceLevel;
import ch.threema.domain.models.GroupId;
import ch.threema.domain.models.MessageId;
import ch.threema.domain.protocol.blob.BlobScope;
import ch.threema.domain.protocol.blob.BlobUploader;
import ch.threema.domain.protocol.csp.MessageTooLongException;
import ch.threema.domain.protocol.csp.ProtocolDefines;
import ch.threema.domain.protocol.csp.messages.AbstractGroupMessage;
import ch.threema.domain.protocol.csp.messages.AbstractMessage;
import ch.threema.domain.protocol.csp.messages.BadMessageException;
import ch.threema.domain.protocol.csp.messages.DeleteMessage;
import ch.threema.domain.protocol.csp.messages.GroupTextMessage;
import ch.threema.domain.protocol.csp.messages.TextMessage;
import ch.threema.domain.protocol.csp.messages.file.FileData;
import ch.threema.domain.protocol.csp.messages.fs.ForwardSecurityMode;
import ch.threema.domain.protocol.csp.messages.location.GroupLocationMessage;
import ch.threema.domain.protocol.csp.messages.location.LocationMessage;
import ch.threema.domain.protocol.csp.messages.location.Poi;
import ch.threema.domain.protocol.csp.messages.poll.GroupPollSetupMessage;
import ch.threema.domain.protocol.csp.messages.poll.PollSetupInterface;
import ch.threema.domain.protocol.csp.messages.poll.PollSetupMessage;
import ch.threema.domain.stores.IdentityStore;
import ch.threema.domain.taskmanager.TriggerSource;
import ch.threema.libthreema.CryptoException;
import ch.threema.protobuf.csp.e2e.Reaction;
import ch.threema.storage.DatabaseService;
import ch.threema.storage.models.AbstractMessageModel;
import ch.threema.storage.models.ContactModel;
import ch.threema.storage.models.DistributionListMessageModel;
import ch.threema.storage.models.FirstUnreadMessageModel;
import ch.threema.storage.models.MessageModel;
import ch.threema.storage.models.MessageState;
import ch.threema.storage.models.MessageType;
import ch.threema.storage.models.access.GroupAccessModel;
import ch.threema.storage.models.data.LocationDataModel;
import ch.threema.storage.models.data.MessageContentsType;
import ch.threema.storage.models.data.media.FileDataModel;
import ch.threema.storage.models.data.media.MediaMessageDataInterface;
import ch.threema.storage.models.data.media.PollDataModel;
import ch.threema.storage.models.data.status.ForwardSecurityStatusDataModel;
import ch.threema.storage.models.data.status.GroupCallStatusDataModel;
import ch.threema.storage.models.data.status.GroupStatusDataModel;
import ch.threema.storage.models.data.status.VoipStatusDataModel;
import ch.threema.storage.models.group.GroupMessageModel;
import ch.threema.storage.models.group.GroupModelOld;
import ch.threema.storage.models.poll.PollModel;

import static ch.threema.app.AppConstants.MAX_BLOB_SIZE;
import static ch.threema.app.AppConstants.MAX_BLOB_SIZE_MB;
import static ch.threema.app.preference.service.PreferenceService.IMAGE_SCALE_DEFAULT;
import static ch.threema.app.ui.MediaItem.TIME_UNDEFINED;
import static ch.threema.app.ui.MediaItem.TYPE_FILE;
import static ch.threema.app.ui.MediaItem.TYPE_IMAGE;
import static ch.threema.app.ui.MediaItem.TYPE_IMAGE_ANIMATED;
import static ch.threema.app.ui.MediaItem.TYPE_IMAGE_CAM;
import static ch.threema.app.ui.MediaItem.TYPE_LOCATION;
import static ch.threema.app.ui.MediaItem.TYPE_TEXT;
import static ch.threema.app.ui.MediaItem.TYPE_VIDEO;
import static ch.threema.app.ui.MediaItem.TYPE_VIDEO_CAM;
import static ch.threema.app.ui.MediaItem.TYPE_VOICEMESSAGE;
import static ch.threema.app.utils.MessageUtilKt.canBeEdited;
import static ch.threema.app.utils.StreamUtilKt.getFromUri;
import static ch.threema.base.utils.LoggingKt.getThreemaLogger;
import static ch.threema.common.ByteArrayExtensionsKt.toHexString;
import static ch.threema.common.InputStreamExtensionsKt.copyTo;
import static ch.threema.common.JavaCompat.areEqual;
import static ch.threema.common.JavaCompat.hexToByteArray;
import static ch.threema.common.JavaCompat.isNullOrEmpty;
import static ch.threema.common.SecureRandomExtensionsKt.generateRandomBytes;
import static ch.threema.common.SecureRandomExtensionsKt.secureRandom;
import static ch.threema.domain.protocol.csp.messages.file.FileData.RENDERING_STICKER;
import static kotlin.io.ByteStreamsKt.readBytes;

public class MessageServiceImpl implements MessageService {
    private static final Logger logger = getThreemaLogger("MessageServiceImpl");

    public static final long FILE_AUTO_DOWNLOAD_MAX_SIZE_M = 5; // MB
    public static final long FILE_AUTO_DOWNLOAD_MAX_SIZE_ISO = FILE_AUTO_DOWNLOAD_MAX_SIZE_M * 1024 * 1024; // used for calculations
    public static final long FILE_AUTO_DOWNLOAD_MAX_SIZE_SI = FILE_AUTO_DOWNLOAD_MAX_SIZE_M * 1000 * 1000; // used for presentation only
    public static final int THUMBNAIL_SIZE_PX = 512;

    private final @NonNull Context context;

    // Services
    private final MessageSendingService messageSendingService;
    private final DatabaseService databaseService;
    private final ContactService contactService;
    private final FileService fileService;
    private final IdentityStore identityStore;
    private final PollService pollService;
    @NonNull
    private final PreferenceService preferenceService;
    @NonNull
    private final SynchronizedSettingsService synchronizedSettingsService;
    private final GroupService groupService;
    private final ApiService apiService;
    private final DownloadService downloadService;
    private final ConversationCategoryService conversationCategoryService;
    @NonNull
    private final BlockedIdentitiesService blockedIdentitiesService;
    private final SymmetricEncryptionService symmetricEncryptionService;
    @NonNull
    private final GlobalEventBuses globalEventBuses;

    // Repositories
    private final EditHistoryRepository editHistoryRepository;
    private final EmojiReactionsRepository emojiReactionsRepository;
    private final ContactModelRepository contactModelRepository;

    // Managers
    private final MultiDeviceManager multiDeviceManager;

    // Caches
    private final Collection<MessageModel> contactMessageCache;
    private final Collection<GroupMessageModel> groupMessageCache;
    private final Collection<DistributionListMessageModel> distributionListMessageCache;

    public MessageServiceImpl(
        @NonNull
        Context context,
        CacheService cacheService,
        DatabaseService databaseService,
        ContactService contactService,
        FileService fileService,
        IdentityStore identityStore,
        SymmetricEncryptionService symmetricEncryptionService,
        @NonNull PreferenceService preferenceService,
        @NonNull SynchronizedSettingsService synchronizedSettingsService,
        PollService pollService,
        GroupService groupService,
        ApiService apiService,
        DownloadService downloadService,
        @NonNull ConversationCategoryService conversationCategoryService,
        @NonNull BlockedIdentitiesService blockedIdentitiesService,
        MultiDeviceManager multiDeviceManager,
        EditHistoryRepository editHistoryRepository,
        EmojiReactionsRepository emojiReactionsRepository,
        @NonNull ContactModelRepository contactModelRepository,
        @NonNull GlobalEventBuses globalEventBuses
    ) {
        this.context = context;
        this.databaseService = databaseService;
        this.contactService = contactService;
        this.fileService = fileService;
        this.identityStore = identityStore;
        this.symmetricEncryptionService = symmetricEncryptionService;
        this.preferenceService = preferenceService;
        this.synchronizedSettingsService = synchronizedSettingsService;
        this.pollService = pollService;
        this.groupService = groupService;
        this.apiService = apiService;
        this.downloadService = downloadService;
        this.conversationCategoryService = conversationCategoryService;
        this.blockedIdentitiesService = blockedIdentitiesService;
        this.globalEventBuses = globalEventBuses;

        contactMessageCache = cacheService.getMessageModelCache();
        groupMessageCache = cacheService.getGroupMessageModelCache();
        distributionListMessageCache = cacheService.getDistributionListMessageCache();

        this.multiDeviceManager = multiDeviceManager;

        this.editHistoryRepository = editHistoryRepository;
        this.emojiReactionsRepository = emojiReactionsRepository;
        this.contactModelRepository = contactModelRepository;

        // init queue
        messageSendingService = new MessageSendingServiceExponentialBackOff(new MessageSendingService.MessageSendingServiceState() {
            @Override
            public void processingFailed(AbstractMessageModel messageModel, MessageReceiver<AbstractMessageModel> receiver) {
                //remove send machine
                removeSendMachine(messageModel);
                updateOutgoingMessageState(messageModel, MessageState.SENDFAILED, Instant.now());
            }

            @Override
            public void exception(Exception x, int tries) {
                if (tries >= 5) {
                    logger.error("Exception", x);
                }
            }
        });
    }

    private void cache(AbstractMessageModel m) {
        if (m instanceof GroupMessageModel) {
            synchronized (groupMessageCache) {
                groupMessageCache.add((GroupMessageModel) m);
            }
        } else if (m instanceof MessageModel) {
            synchronized (contactMessageCache) {
                contactMessageCache.add((MessageModel) m);
            }
        }
    }

    @Override
    public AbstractMessageModel createStatusMessage(String statusMessage, MessageReceiver receiver) {
        AbstractMessageModel model = receiver.createAndSaveStatusModel(statusMessage, Instant.now());
        fireOnNewMessage(model);
        return model;
    }

    @Override
    public AbstractMessageModel createVoipStatus(
        @NonNull VoipStatusDataModel data,
        @NonNull MessageReceiver receiver,
        boolean isOutbox,
        boolean isRead
    ) {
        logger.info("Storing voip status message (outbox={}, status={}, reason={})",
            isOutbox, data.getStatus(), data.getReason());
        final AbstractMessageModel model = receiver.createLocalModel(
            MessageType.VOIP_STATUS,
            MessageContentsType.VOIP_STATUS,
            data.getInstant() != null ? data.getInstant() : Instant.now()
        );
        model.setOutbox(isOutbox);
        model.setVoipStatusData(data);
        model.setSaved(true);
        model.setRead(isRead);
        receiver.saveLocalModel(model);
        fireOnNewMessage(model);
        return model;
    }

    @Override
    @Nullable
    public AbstractMessageModel createGroupCallStatus(
        @NonNull GroupCallStatusDataModel data,
        @NonNull MessageReceiver receiver,
        @Nullable GroupCallDescription call,
        boolean isOutbox,
        Instant postedDate) {
        if (receiver instanceof GroupMessageReceiver && ((GroupMessageReceiver) receiver).getGroup() == null) {
            logger.info("Unable to store group call status message. Group no longer exists");
            return null;
        }

        logger.info("Storing group call status message for call={}", call != null ? call.getCallId() : "n/a");
        final AbstractMessageModel model = receiver.createLocalModel(
            MessageType.GROUP_CALL_STATUS,
            MessageContentsType.GROUP_CALL_STATUS,
            Instant.now()
        );
        model.setPostedAt(postedDate);
        model.setOutbox(isOutbox);
        model.setGroupCallStatusData(data);
        model.setSaved(true);
        model.setStatusMessage(true);
        model.setRead(data.getStatus() != GroupCallStatusDataModel.STATUS_STARTED);
        receiver.saveLocalModel(model);
        fireOnNewMessage(model);
        return model;
    }

    @Override
    public AbstractMessageModel createForwardSecurityStatus(
        @NonNull MessageReceiver receiver,
        @ForwardSecurityStatusDataModel.ForwardSecurityStatusType int type,
        int quantity,
        @Nullable String staticText) {
        logger.info("Storing forward security status message of type {}", type);

        final AbstractMessageModel model = receiver.createLocalModel(
            MessageType.FORWARD_SECURITY_STATUS,
            MessageContentsType.FORWARD_SECURITY_STATUS,
            Instant.now()
        );
        model.setOutbox(false);
        model.setForwardSecurityStatusData(ForwardSecurityStatusDataModel.create(type, quantity, staticText));
        model.setSaved(true);
        model.setStatusMessage(true);
        model.setRead(true);
        receiver.saveLocalModel(model);
        fireOnNewMessage(model);
        return model;
    }

    @Override
    public AbstractMessageModel createGroupStatus(
        @NonNull GroupMessageReceiver receiver,
        @NonNull GroupStatusDataModel.GroupStatusType type,
        @Nullable String identity,
        @Nullable String pollName,
        @Nullable String newGroupName
    ) {
        logger.info("Storing group status message of type {}", type.getType());

        final GroupMessageModel model = receiver.createLocalModel(
            MessageType.GROUP_STATUS,
            MessageContentsType.GROUP_STATUS,
            Instant.now()
        );
        model.setOutbox(false);
        model.setGroupStatusData(GroupStatusDataModel.create(type, identity, pollName, newGroupName));
        model.setSaved(true);
        model.setStatusMessage(true);
        model.setRead(true);
        receiver.saveLocalModel(model);
        fireOnNewMessage(model);
        return model;
    }

    public AbstractMessageModel createNewPollMessage(
        MessageId messageId,
        PollModel pollModel,
        PollDataModel.Type type,
        MessageReceiver receiver,
        int messageFlags,
        ForwardSecurityMode forwardSecurityMode) {
        AbstractMessageModel model = receiver.createLocalModel(messageId, MessageType.POLL, MessageContentsType.POLL, Instant.now());
        //hack: save poll id into body string
        model.setIdentity(pollModel.getCreatorIdentity());
        model.setSaved(true);
        model.setPollData(new PollDataModel(type, pollModel.getId()));
        model.setOutbox(pollModel.getCreatorIdentity().equals(identityStore.getIdentityString()));
        model.setMessageFlags(messageFlags);
        model.setForwardSecurityMode(forwardSecurityMode);
        receiver.saveLocalModel(model);
        cache(model);
        fireOnNewMessage(model);

        return model;
    }

    /**
     * Send a text message to the specified receiver.
     *
     * @param message         The message text. May not be longer than {@link ProtocolDefines#MAX_TEXT_MESSAGE_LEN} UTF-8 bytes.
     * @param messageReceiver The receiver for this message.
     * @return the model of the sent message
     * @throws MessageTooLongException if the message is too long.
     * @throws ThreemaException        if the message text is empty after trimming.
     */
    @Override
    public AbstractMessageModel sendText(
        @NonNull String message,
        @NonNull MessageReceiver messageReceiver
    ) throws ThreemaException {
        final String tag = "sendTextMessage";

        logger.info("{}: start", tag);

        String trimmedMessage = validateTextMessage(message);

        logger.debug("{}: create model instance", tag);
        final AbstractMessageModel messageModel = messageReceiver.createLocalModel(MessageType.TEXT, MessageContentsType.TEXT, Instant.now());
        logger.debug("{}: cache", tag);
        cache(messageModel);

        messageModel.setOutbox(true);
        messageModel.setBodyAndQuotedMessageId(trimmedMessage);
        messageModel.setState(MessageState.SENDING);
        messageModel.setSaved(true);

        logger.debug("{}: save db", tag);
        messageReceiver.saveLocalModel(messageModel);
        logger.debug("{}: fire create message", tag);
        fireOnNewMessage(messageModel);

        messageReceiver.createAndSendTextMessage(messageModel);
        MessageId messageId = messageModel.getMessageId();
        logger.info("{}: message {} successfully queued", tag, messageId);
        messageReceiver.saveLocalModel(messageModel);

        fireOnUpdatedMessage(messageModel);

        return messageModel;
    }

    @Override
    public void sendEditedMessageText(
        @NonNull AbstractMessageModel message, // Let `message` be the referred message.
        @NonNull String newText,
        @NonNull Instant editedAt,
        @NonNull MessageReceiver receiver
    ) throws ThreemaException {
        logger.debug("editText message = {}", message.getApiMessageId());

        if (!message.isOutbox()) {
            throw new ThreemaException("Tried editing a message that is not outgoing. message = " + message.getApiMessageId());
        }

        String trimmedNewText = validateTextMessage(newText);

        if (Objects.equals(message.getBody(), trimmedNewText)) {
            throw new ThreemaException("Tried editing a message with no changes. message = " + message.getApiMessageId());
        }

        if (message.getPostedAt() == null) {
            logger.error("postedAt is null for messageId={}}", message.getId());
            return;
        }

        if (!canBeEdited(message, isNotesGroup(receiver), editedAt, AbstractMessageModel::getPostedAt)) {
            logger.error("Message can not be edited");
            return;
        }

        if (receiver instanceof ContactMessageReceiver) {
            ((ContactMessageReceiver) receiver).sendEditMessage(
                message.getId(),
                trimmedNewText,
                editedAt
            );
        } else if (receiver instanceof GroupMessageReceiver) {
            ((GroupMessageReceiver) receiver).sendEditMessage(
                message.getId(),
                trimmedNewText,
                editedAt
            );
        } else {
            throw new ThreemaException("Unsupported receiver type of: " + receiver.getClass());
        }

        saveEditedMessageText(message, newText, editedAt);
    }

    private boolean isNotesGroup(@NonNull MessageReceiver receiver) {
        if (receiver instanceof GroupMessageReceiver) {
            return groupService.isNotesGroup(((GroupMessageReceiver) receiver).getGroup());
        }
        return false;
    }

    @Override
    public void saveEditedMessageText(@NonNull AbstractMessageModel message, String text, @Nullable Instant editedAt) {
        logger.info("Save edited message = {}", message.getApiMessageId());

        if (editedAt != null) {
            editHistoryRepository.createEntry(message);
        }

        // Edit `message` as defined by the associated _Edit applies to_ property and
        //  add an indicator to `message`, informing the user that the message has
        //  been edited by the user at `created-at`.
        switch (message.getType()) {
            case TEXT:
                message.setBody(text);
                break;
            case FILE:
                message.setCaption(text);
                message.getFileData().setCaption(text);
                message.setBody(message.getFileData().toString());
                break;
            default:
                logger.error("Tried saving an edited message of unsupported type {} for messageId = {}}", message.getType(), message.getId());
                return;
        }

        message.setEditedAt(editedAt);

        save(message);
        fireOnUpdatedMessage(message);
        globalEventBuses.getMessages().emit(new MessageEvent.MessageEdited(message));
    }

    @Override
    public boolean saveEmojiReactionMessage(
        @NonNull AbstractMessageModel targetMessage,
        @NonNull String senderIdentity,
        @Nullable Reaction.ActionCase actionCase,
        @NonNull String emojiSequence,
        @NonNull Instant createdAt
    ) {
        logger.debug("saving emoji reaction of type {} to message {}", actionCase, targetMessage.getApiMessageId());

        if (actionCase == Reaction.ActionCase.APPLY) {
            try {
                emojiReactionsRepository.createEntry(targetMessage, senderIdentity, emojiSequence, createdAt);
            } catch (EmojiReactionEntryCreateException | IllegalStateException e) {
                logger.error("Unable to create emoji reaction.", e);
                return false;
            }
        } else if (actionCase == Reaction.ActionCase.WITHDRAW) {
            try {
                emojiReactionsRepository.removeEntry(targetMessage, senderIdentity, emojiSequence);
            } catch (EmojiReactionEntryRemoveException | IllegalStateException e) {
                logger.error("Unable to remove emoji reaction.", e);
                return false;
            }
        } else {
            logger.warn("Unsupported emoji reaction action case {}. Ignoring message.", actionCase);
            return false;
        }
        fireOnUpdatedMessage(targetMessage);
        return true;
    }

    @Override
    public void clearMessageState(@NonNull AbstractMessageModel targetMessage) {
        if (targetMessage.getState() != MessageState.USERACK && targetMessage.getState() != MessageState.USERDEC) {
            return;
        }

        MessageState newMessageState;
        String myIdentity = identityStore != null ? identityStore.getIdentityString() : null;

        if (targetMessage.isRead()) {
            newMessageState = MessageState.READ;
        } else if (targetMessage.getDeliveredAt() != null) {
            newMessageState = MessageState.DELIVERED;
        } else {
            newMessageState = MessageState.SENT;
        }

        targetMessage.setState(newMessageState);
        if (targetMessage instanceof GroupMessageModel && myIdentity != null) {
            groupService.removeGroupMessageState((GroupMessageModel) targetMessage, myIdentity);
        }
        save(targetMessage);
    }

    @WorkerThread
    @Override
    public synchronized boolean sendEmojiReaction(
        @NonNull AbstractMessageModel message,
        @NonNull String emojiSequence,
        @NonNull MessageReceiver receiver,
        boolean markAsRead
    ) throws ThreemaException {
        logger.debug("Send emoji reaction to message {} (id={})", message.getApiMessageId(), message.getId());
        logger.trace("Reaction: '{}'", emojiSequence);

        if (!EmojiUtil.isFullyQualifiedEmoji(emojiSequence)) {
            logger.warn("Attempt to send non fully-qualified emoji sequence '{}'", emojiSequence);
            // Return true, as the return value only indicates whether this failed due to
            // compatibility issues when a phase 1 client tries to send an emoji sequence
            // to a client without reactions support.
            return true;
        }

        @MessageReceiver.EmojiReactionsSupport final int reactionSupport = receiver.getEmojiReactionSupport();

        if (markAsRead) {
            markAsRead(
                /* message */ message,
                /* silent */ true
            );
        }

        final String myIdentity = identityStore.getIdentityString();
        if (myIdentity == null) {
            throw new ThreemaException("Cannot send emoji reaction when no identity is available");
        }
        List<EmojiReactionData> emojiReactionData =
            emojiReactionsRepository.safeGetReactionsByMessage(message);

        Reaction.ActionCase actionCase = Reaction.ActionCase.APPLY;

        // check if there's already an identical reaction with us as the sender. if yes, withdraw
        // it.
        if (containsEmojiSequence(emojiReactionData, emojiSequence, myIdentity)) {
            actionCase = Reaction.ActionCase.WITHDRAW;
        }

        Instant reactedAt = Instant.now();

        // If there is a new message state set, it means that a legacy reaction was sent and the
        // state of the message needs to be updated.
        MessageState newMessageState = null;
        if (receiver instanceof ContactMessageReceiver) {
            newMessageState = ((ContactMessageReceiver) receiver).sendReaction(
                message,
                actionCase,
                emojiSequence,
                reactedAt // use current timestamp for reaction message
            );
        } else if (receiver instanceof GroupMessageReceiver) {
            ((GroupMessageReceiver) receiver).sendReaction(
                message,
                actionCase,
                emojiSequence,
                reactedAt // use current timestamp for reaction message
            );
        } else {
            throw new ThreemaException("Unsupported receiver type of: " + receiver.getClass());
        }

        if (newMessageState == null) {
            // In case the new message state is null, then an emoji reaction has been sent. The
            // sequence can be stored normally.
            if (actionCase == Reaction.ActionCase.APPLY) {
                emojiReactionsRepository.createEntry(message, myIdentity, emojiSequence, reactedAt);
            } else {
                emojiReactionsRepository.removeEntry(message, myIdentity, emojiSequence);
            }
        } else {
            // In case there is a new message state, then a legacy reaction has been used. In this
            // case we need to update the message state.
            updateAckDecState(message, newMessageState, null, reactedAt);
        }

        showToastOnPartialReactionSupport(
            reactionSupport,
            actionCase,
            emojiSequence
        );

        fireOnUpdatedMessage(message);
        return true;
    }

    /**
     * Show a toast if reaction support is only partial for the current receiver.
     * Also note that for now a toast is only shown if the action is APPLY.
     * If the reaction can be mapped to ACK/DEC, no toast is shown.
     */
    @AnyThread
    private void showToastOnPartialReactionSupport(
        @MessageReceiver.EmojiReactionsSupport int reactionSupport,
        @NonNull Reaction.ActionCase actionCase,
        @NonNull String emojiSequence
    ) {
        if (reactionSupport == MessageReceiver.Reactions_PARTIAL
            && actionCase == Reaction.ActionCase.APPLY
            && !EmojiUtil.isThumbsUpOrDownEmoji(emojiSequence)) {
            RuntimeUtil.runOnUiThread(() ->
                Toast.makeText(context, R.string.group_emoji_reactions_partially_supported, Toast.LENGTH_SHORT).show());
        }
    }

    private boolean containsEmojiSequence(@Nullable List<EmojiReactionData> emojiReactionData, @NonNull String emojiSequence, @NonNull String senderIdentity) {
        return
            emojiReactionData != null &&
                emojiReactionData.stream().anyMatch(a -> a.senderIdentity.equals(senderIdentity) && a.emojiSequence.equals(emojiSequence));
    }

    @Override
    public void sendDeleteMessage(
        @NonNull AbstractMessageModel message, // Let `message` be the referred message.
        @NonNull MessageReceiver receiver
    ) throws Exception {
        logger.debug("sendDeleteMessage        message = {}", message.getApiMessageId());

        if (!message.isOutbox()) {
            logger.error("Tried deleting a message that is not outgoing. message = {}", message.getId());
        }

        if (message.getPostedAt() == null) {
            logger.error("postedAt is null for messageId={}}", message.getId());
            return;
        }

        // Let `created-at` be the current timestamp to be applied to the delete message
        Instant createdAt = Instant.now();

        final @Nullable GroupModel groupModel;
        if (receiver instanceof GroupMessageReceiver) {
            groupModel = ((GroupMessageReceiver) receiver).getGroupModel();
        } else {
            groupModel = null;
        }
        final boolean isNotesGroup = groupModel != null && Boolean.TRUE.equals(groupModel.isNotesGroup());

        // If the group is not a notes group, we have to check the messages age
        if (!isNotesGroup) {
            long deltaTime = createdAt.toEpochMilli() - message.getPostedAt().toEpochMilli();
            // If the referred message has been sent (`sent-at`) more than 6 hours ago, prevent creation and abort these steps.
            if (deltaTime > DeleteMessage.DELETE_MESSAGES_MAX_AGE) {
                logger.error("Cannot delete message older than {}}ms", DeleteMessage.DELETE_MESSAGES_MAX_AGE);
                return;
            }
        }

        // Replace `message` with a message informing the user that the message of the user has been removed at `created-at`.
        deleteMessageContentsAndRelatedData(message, createdAt);

        if (receiver instanceof ContactMessageReceiver) {
            ((ContactMessageReceiver) receiver).sendDeleteMessage(
                message.getId(),
                createdAt
            );
        } else if (receiver instanceof GroupMessageReceiver) {
            ((GroupMessageReceiver) receiver).sendDeleteMessage(
                message.getId(),
                createdAt
            );
        } else {
            throw new ThreemaException("Unsupported receiver type of: " + receiver.getClass());
        }
    }

    @Override
    public void deleteMessageContentsAndRelatedData(@NonNull AbstractMessageModel message, Instant deletedAt) {
        logger.info("deleteMessageContents = {}", message.getApiMessageId());

        var messageUid = message.getUid();
        if (messageUid != null) {
            fileService.deleteMessageFiles(messageUid, false);
        }

        message.setBody(null);
        message.setCaption(null);

        message.setState(null);
        if (message instanceof GroupMessageModel) {
            ((GroupMessageModel) message).setGroupMessageStates(null);
        }

        message.setDeletedAt(deletedAt);

        save(message);

        // Delete the edit history and emoji reactions. Note that the foreign keys do not work in this case, as the
        // original message entry is not removed from the database.
        editHistoryRepository.deleteByMessageUid(message.getUid());

        emojiReactionsRepository.deleteAllReactionsForMessage(message);

        fireOnUpdatedMessage(message);
        globalEventBuses.getMessages().emit(new MessageEvent.MessageDeletedForAll(message));
    }

    @Override
    public AbstractMessageModel sendLocation(@NonNull Location location, @Nullable String poiName, MessageReceiver receiver, final CompletionHandler completionHandler) throws ThreemaException {
        final String tag = "sendLocationMessage";
        logger.info("{}: start", tag);

        AbstractMessageModel messageModel = receiver.createLocalModel(MessageType.LOCATION, MessageContentsType.LOCATION, Instant.now());
        cache(messageModel);

        @Nullable Poi poi = null;
        try {
            final @NonNull String lookedUpPoiAddress = GeoLocationUtil.getAddressFromLocation(
                context,
                location.getLatitude(),
                location.getLongitude()
            );
            if (poiName != null && !poiName.isBlank()) {
                poi = new Poi.Named(poiName, lookedUpPoiAddress);
            } else {
                poi = new Poi.Unnamed(lookedUpPoiAddress);
            }
        } catch (IOException e) {
            logger.error("Exception", e);
            //do not show this error!
        }

        messageModel.setLocationData(
            new LocationDataModel(
                location.getLatitude(),
                location.getLongitude(),
                (double) location.getAccuracy(),
                poi
            )
        );

        messageModel.setOutbox(true);
        messageModel.setState(MessageState.PENDING);
        messageModel.setSaved(true);
        receiver.saveLocalModel(messageModel);

        fireOnNewMessage(messageModel);

        receiver.createAndSendLocationMessage(messageModel);

        fireOnUpdatedMessage(messageModel);

        if (completionHandler != null)
            completionHandler.sendQueued(messageModel);

        return messageModel;
    }

    @Override
    @WorkerThread
    public void resendMessage(
        @NonNull AbstractMessageModel messageModel,
        @NonNull MessageReceiver<AbstractMessageModel> receiver,
        @Nullable CompletionHandler completionHandler,
        @NonNull Collection<String> recipientIdentities,
        @NonNull MessageId messageId,
        @NonNull TriggerSource triggerSource
    ) throws Exception {
        NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
        notificationManager.cancel(NotificationIDs.UNSENT_MESSAGE_NOTIFICATION_ID);

        if (messageModel.getState() == MessageState.SENDFAILED || messageModel.getState() == MessageState.FS_KEY_MISMATCH) {
            if (messageModel.getType() == MessageType.FILE) {
                resendFileMessage(messageModel, receiver, completionHandler, recipientIdentities);
            } else if (messageModel.getType() == MessageType.POLL) {
                PollModel pollModel = pollService.get(messageModel.getPollData().getPollId());
                if (pollModel != null) {
                    resendPollMessage(messageModel, pollModel, receiver, messageId, triggerSource);
                }
            } else if (messageModel.getType() == MessageType.TEXT) {
                resendTextMessage(messageModel, receiver, recipientIdentities);
            } else if (messageModel.getType() == MessageType.LOCATION) {
                resendLocationMessage(messageModel, receiver, completionHandler, recipientIdentities);
            }
        }
    }

    @WorkerThread
    private void resendTextMessage(
        final @NonNull AbstractMessageModel messageModel,
        final MessageReceiver receiver,
        final Collection<String> recipientIdentities
    ) {
        if (receiver instanceof ContactMessageReceiver && messageModel instanceof MessageModel) {
            ((ContactMessageReceiver) receiver).resendTextMessage((MessageModel) messageModel);
        } else if (receiver instanceof GroupMessageReceiver && messageModel instanceof GroupMessageModel) {
            ((GroupMessageReceiver) receiver).resendTextMessage(
                (GroupMessageModel) messageModel,
                recipientIdentities
            );
        } else if (receiver instanceof DistributionListMessageReceiver) {
            logger.warn("Cannot resend messages in a distribution list");
            return;
        } else {
            logger.warn("Incompatible message receiver and message model type");
            return;
        }

        updateOutgoingMessageState(messageModel, MessageState.SENDING, Instant.now());
        fireOnUpdatedMessage(messageModel);
    }

    @WorkerThread
    private void resendLocationMessage(
        @NonNull AbstractMessageModel messageModel,
        @NonNull MessageReceiver receiver,
        final @Nullable CompletionHandler completionHandler,
        @NonNull Collection<String> recipientIdentities
    ) {
        if (receiver instanceof ContactMessageReceiver && messageModel instanceof MessageModel) {
            ((ContactMessageReceiver) receiver).resendLocationMessage((MessageModel) messageModel);
        } else if (receiver instanceof GroupMessageReceiver && messageModel instanceof GroupMessageModel) {
            ((GroupMessageReceiver) receiver).resendLocationMessage(
                (GroupMessageModel) messageModel,
                recipientIdentities
            );
        } else if (receiver instanceof DistributionListMessageReceiver) {
            logger.warn("Cannot resend messages in a distribution list");
            return;
        } else {
            logger.error("Incompatible message receiver and message model type");
            return;
        }

        updateOutgoingMessageState(messageModel, MessageState.SENDING, Instant.now());
        fireOnUpdatedMessage(messageModel);
        if (completionHandler != null) {
            completionHandler.sendQueued(messageModel);
        }
    }

    @WorkerThread
    private void resendFileMessage(
        final @NonNull AbstractMessageModel messageModel,
        final @NonNull MessageReceiver<AbstractMessageModel> receiver,
        final @Nullable CompletionHandler completionHandler,
        final @NonNull Collection<String> recipientIdentities
    ) throws Exception {

        // check if a message file exists that could be resent or abort immediately
        var messageUid = messageModel.getUid();
        if (messageUid == null || !fileService.hasMessageFile(messageUid)) {
            throw new ThreemaException("Message file not present");
        }

        updateOutgoingMessageState(messageModel, MessageState.PENDING, Instant.now());

        //enqueue processing and uploading stuff...
        messageSendingService.addToQueue(new MessageSendingService.MessageSendingProcess() {
            public byte[] blobIdThumbnail;
            public byte[] blobId;
            public byte[] thumbnailData;
            public byte[] fileData;
            public int fileDataBoxedLength;

            private SymmetricEncryptionResult contentEncryptResult;
            private SymmetricEncryptionResult thumbnailEncryptResult;

            public boolean success = false;

            @Override
            public MessageReceiver<AbstractMessageModel> getReceiver() {
                return receiver;
            }

            @Override
            public AbstractMessageModel getMessageModel() {
                return messageModel;
            }

            @Override
            public boolean send() throws Exception {
                SendMachine sendMachine = getSendMachine(messageModel);
                sendMachine.reset()
                    .next(() -> {
                        // get file data
                        File decryptedMessageFile = fileService.decryptMessageFileToTempFile(messageModel);

                        if (decryptedMessageFile != null) {
                            try (FileInputStream inputStream = new FileInputStream(decryptedMessageFile)) {
                                fileDataBoxedLength = inputStream.available();
                                fileData = new byte[fileDataBoxedLength + NaCl.BOX_OVERHEAD_BYTES];
                                var readBytes = copyTo(inputStream, fileData, NaCl.BOX_OVERHEAD_BYTES, fileDataBoxedLength);
                                if (readBytes != fileDataBoxedLength) {
                                    throw new EOFException("Expected " + fileDataBoxedLength + " but got only " + readBytes + " bytes");
                                }
                            }
                        } else {
                            throw new ThreemaException("Message file not present");
                        }
                    })
                    .next(() -> {
                        // encrypt file data
                        contentEncryptResult = symmetricEncryptionService.encryptInplace(fileData, ProtocolDefines.FILE_NONCE);
                        if (contentEncryptResult.isEmpty()) {
                            throw new ThreemaException("File data encrypt failed");
                        }
                    })
                    .next(() -> {
                        // get thumbnail data
                        var messageUid = messageModel.getUid();
                        if (messageUid != null) {
                            try (InputStream is = fileService.decryptedMessageThumbnailToStream(messageUid)) {
                                if (is != null) {
                                    thumbnailData = readBytes(is);
                                } else {
                                    thumbnailData = null;
                                }
                            } catch (Exception e) {
                                logger.debug("No thumbnail for file message");
                            }
                        }
                    })
                    .next(() -> {
                        // upload (encrypted) file data
                        BlobUploader blobUploader = initUploader(
                            getMessageModel(),
                            contentEncryptResult.data,
                            getReceiver()
                        );
                        blobUploader.progressListener = new ProgressListener() {
                            @Override
                            public void onFinished(boolean success) {
                                setMessageLoadingFinished(messageModel);
                            }
                        };
                        blobId = blobUploader.upload();
                    })
                    .next(() -> {
                        if (thumbnailData != null) {
                            // encrypt and upload thumbnail data
                            thumbnailEncryptResult = symmetricEncryptionService.encrypt(thumbnailData, contentEncryptResult.key, ProtocolDefines.FILE_THUMBNAIL_NONCE);

                            if (thumbnailEncryptResult.isEmpty()) {
                                throw new ThreemaException("Thumbnail encryption failed");
                            } else {
                                BlobUploader blobUploader = initUploader(
                                    getMessageModel(),
                                    thumbnailEncryptResult.data,
                                    getReceiver()
                                );
                                blobUploader.progressListener = new ProgressListener() {
                                    @Override
                                    public void onFinished(boolean success) {
                                        setMessageLoadingFinished(messageModel);
                                    }
                                };
                                blobIdThumbnail = blobUploader.upload();
                            }
                        }
                    })
                    .next(() -> {
                        getReceiver().createAndSendFileMessage(
                            blobIdThumbnail,
                            blobId,
                            contentEncryptResult,
                            messageModel,
                            recipientIdentities
                        );
                        save(messageModel);
                    })
                    .next(() -> {
                        updateOutgoingMessageState(messageModel, MessageState.SENDING, Instant.now());

                        if (completionHandler != null)
                            completionHandler.sendComplete(messageModel);

                        success = true;
                    });

                if (success) {
                    removeSendMachine(sendMachine);
                }
                return success;
            }
        });
    }

    @Override
    public AbstractMessageModel sendPollMessage(
        @NonNull PollModel pollModel,
        @NonNull MessageId messageId,
        @NonNull TriggerSource triggerSource
    ) throws MessageTooLongException {
        //create a new poll model
        MessageReceiver receiver = pollService.getReceiver(pollModel);

        if (receiver != null) {
            //ok...
            logger.debug("sendPollMessage to {}", receiver);
            final AbstractMessageModel messageModel = receiver.createLocalModel(MessageType.POLL, MessageContentsType.POLL, Instant.now());
            cache(messageModel);

            messageModel.setOutbox(true);
            messageModel.setState(MessageState.PENDING);

            messageModel.setPollData(new PollDataModel(
                pollModel.getState() == PollModel.State.OPEN ?
                    PollDataModel.Type.POLL_CREATED :
                    PollDataModel.Type.POLL_CLOSED,
                pollModel.getId()));

            messageModel.setSaved(true);
            receiver.saveLocalModel(messageModel);
            fireOnNewMessage(messageModel);
            resendPollMessage(messageModel, pollModel, receiver, messageId, triggerSource);

            return messageModel;
        }

        return null;
    }

    private void resendPollMessage(
        AbstractMessageModel messageModel,
        PollModel pollModel,
        MessageReceiver<?> receiver,
        @NonNull MessageId messageId,
        @NonNull TriggerSource triggerSource
    ) throws MessageTooLongException {
        // get poll data
        if (messageModel == null || pollModel == null || receiver == null) {
            return;
        }
        updateOutgoingMessageState(messageModel, MessageState.PENDING, Instant.now());
        try {
            pollService.publish(receiver, pollModel, messageModel, messageId, triggerSource);
        } catch (NotAllowedException | MessageTooLongException x) {
            logger.error("Exception", x);
            if (x instanceof MessageTooLongException) {
                remove(messageModel);
                fireOnRemovedMessage(messageModel);
                throw new MessageTooLongException();
            } else {
                updateOutgoingMessageState(messageModel, MessageState.SENDFAILED, Instant.now());
            }
        }
    }

    @Nullable
    @Override
    public MessageModel getContactMessageModel(
        @NonNull final MessageId apiMessageId,
        @NonNull final String identity
    ) {
        // Check contact message cache first
        synchronized (contactMessageCache) {
            MessageModel messageModel = contactMessageCache.stream()
                .filter(model ->
                    model.getApiMessageId() != null
                        && model.getApiMessageId().equals(apiMessageId.toString())
                        && areEqual(model.getIdentity(), identity)
                )
                .findFirst()
                .orElse(null);
            if (messageModel != null) {
                return messageModel;
            }
        }

        // If not cached, load from database (and cache it)
        MessageModel contactMessageModel = databaseService.getMessageModelFactory().getByApiMessageIdAndIdentity(
            apiMessageId,
            identity);
        if (contactMessageModel != null) {
            cache(contactMessageModel);
        }

        return contactMessageModel;
    }

    /**
     * Get the AbstractMessageModel of a group message referenced by messageId, creatorId, and groupId
     *
     * @param messageId       the message
     * @param creatorIdentity the creator of the group
     * @param groupId         the group id
     * @return a GroupMessageModel of the matching message or null in case a message could not be found
     */
    @Override
    @Nullable
    public GroupMessageModel getGroupMessageModel(
        @NonNull final MessageId messageId,
        @NonNull final String creatorIdentity,
        @NonNull final GroupId groupId
    ) {
        String apiMessageIdString = messageId.toString();
        if (apiMessageIdString == null) {
            return null;
        }

        GroupModelOld groupModel = groupService.getByApiGroupIdAndCreator(groupId, creatorIdentity);
        if (groupModel == null) {
            return null;
        }

        // check group message cache first
        synchronized (groupMessageCache) {
            GroupMessageModel messageModel = groupMessageCache.stream()
                .filter(model -> (apiMessageIdString.equals(model.getApiMessageId()) && groupModel.getId() == model.getGroupId()))
                .findFirst()
                .orElse(null);
            if (messageModel != null) {
                return messageModel;
            }
        }

        // retrieve from database
        GroupMessageModel groupMessageModel = databaseService.getGroupMessageModelFactory().getByApiMessageIdAndGroupId(
            messageId,
            groupModel.getId());

        if (groupMessageModel != null) {
            cache(groupMessageModel);
            return groupMessageModel;
        }

        return null;
    }

    @Override
    public void updateOutgoingMessageState(
        @NonNull AbstractMessageModel messageModel,
        @NonNull MessageState state,
        @NonNull Instant date
    ) {
        if (!messageModel.isOutbox()) {
            throw new IllegalArgumentException("Updating outgoing message state on incoming message " + messageModel.getApiMessageId());
        }

        if (MessageUtil.isReaction(state)) {
            throw new IllegalArgumentException("The given message state is a reaction: " + state);
        }

        if (messageModel.isDeleted()) {
            return;
        }

        synchronized (this) {
            logger.debug(
                "Updating message state from {} to {} at {}",
                messageModel.getState(), state, date.toEpochMilli()
            );

            boolean hasChanges = true;

            // Save date of state change
            switch (state) {
                case SENT:
                    // Note that we do not check whether the posted at time already exists as this
                    // value is already set when the message model has been created. We just update
                    // it when the message actually has been sent.
                    messageModel.setPostedAt(date);
                    messageModel.setModifiedAt(date);
                    break;
                case DELIVERED:
                    if (messageModel.getDeliveredAt() != null) {
                        logger.warn("'Delivered at' already set for message {}", messageModel.getApiMessageId());
                    }
                    messageModel.setDeliveredAt(date);
                    messageModel.setModifiedAt(date);
                    break;
                case READ:
                    if (messageModel.getReadAt() != null) {
                        logger.warn("'Read at' already set for message {}", messageModel.getApiMessageId());
                    }
                    messageModel.setReadAt(date);
                    messageModel.setModifiedAt(date);
                    break;
                case SENDFAILED:
                case FS_KEY_MISMATCH:
                case CONSUMED:
                    messageModel.setModifiedAt(date);
                    break;
                default:
                    hasChanges = false;
            }

            // Change the state only if it is possible
            if (MessageUtil.canChangeToState(messageModel.getState(), state, messageModel instanceof GroupMessageModel)) {
                messageModel.setState(state);
                hasChanges = true;
            } else {
                logger.warn(
                    "State transition from {} to {}, ignoring",
                    messageModel.getState(), state
                );
            }

            if (hasChanges) {
                save(messageModel);
                fireOnUpdatedMessage(messageModel);
            }
        }
    }

    @Override
    public void addMessageReaction(
        @NonNull AbstractMessageModel messageModel,
        @NonNull MessageState state,
        @NonNull String fromIdentity,
        @NonNull Instant date
    ) {
        if (!MessageUtil.isReaction(state)) {
            throw new IllegalArgumentException("The given message state is not a reaction: " + state);
        }
        updateAckDecState(messageModel, state, fromIdentity, date);
    }

    /**
     * Special compatibility handling for state changes to ACK and DEC. Saves these messages to the reactions database
     *
     * @param messageModel   The target message model of this state change / reaction
     * @param newState       The desired new state (ACK or DEC)
     * @param senderIdentity The identity of the sender who sent this state change / reaction
     */
    private void updateAckDecState(
        @NonNull AbstractMessageModel messageModel,
        @NonNull MessageState newState,
        @Nullable String senderIdentity,
        @NonNull Instant createdAt
    ) {
        if (newState != MessageState.USERACK && newState != MessageState.USERDEC) {
            return;
        }

        if (senderIdentity == null) {
            senderIdentity = identityStore.getIdentityString();
        }

        clearMessageState(messageModel); // TODO(ANDR-3325): Remove

        handleEmojiReaction(messageModel, newState, senderIdentity, createdAt);
    }

    /**
     * Map state changes (acknowledge and decline) to their emoji reaction equivalents keeping in account
     * the mutual exclusivity of acks and decs
     *
     * @param messageModel The AbstractMessageModel of the target message
     * @param state        The desired new state
     * @param fromIdentity The identity of the sender of this ack/dec reaction
     */
    private void handleEmojiReaction(
        @NonNull AbstractMessageModel messageModel,
        @NonNull MessageState state,
        @NonNull String fromIdentity,
        @NonNull Instant createdAt
    ) {
        if (state == MessageState.USERACK) {
            saveEmojiReactionMessage(messageModel, fromIdentity, Reaction.ActionCase.WITHDRAW, EmojiUtil.THUMBS_DOWN_SEQUENCE, createdAt);
            saveEmojiReactionMessage(messageModel, fromIdentity, Reaction.ActionCase.APPLY, EmojiUtil.THUMBS_UP_SEQUENCE, createdAt);
        } else if (state == MessageState.USERDEC) {
            saveEmojiReactionMessage(messageModel, fromIdentity, Reaction.ActionCase.WITHDRAW, EmojiUtil.THUMBS_UP_SEQUENCE, createdAt);
            saveEmojiReactionMessage(messageModel, fromIdentity, Reaction.ActionCase.APPLY, EmojiUtil.THUMBS_DOWN_SEQUENCE, createdAt);
        }
    }

    @Override
    public boolean markAsRead(AbstractMessageModel message, boolean silent) {
        logger.debug("markAsRead message = {} silent = {}", message.getApiMessageId(), silent);
        boolean saved = false;

        if (MessageUtil.canMarkAsRead(message)) {
            ContactModel contactModel = contactService.getByIdentity(message.getIdentity());

            // Check whether the message allows read receipt before setting the message to read
            // because a message only allows a read receipt if has not been marked as read yet.
            boolean messageAllowsDeliveryReceipt = MessageUtil.canSendDeliveryReceipt(message, ProtocolDefines.DELIVERYRECEIPT_MSGREAD);

            Instant readAt = Instant.now();

            //save is read
            message.setRead(true);
            message.setReadAt(readAt);
            message.setModifiedAt(readAt);

            save(message);

            if (!silent) {
                //fire on modified if not silent
                fireOnUpdatedMessage(message);
            }

            saved = true;

            if (contactModel == null) {
                return saved;
            }

            if (message.getApiMessageId() == null) {
                logger.info("Message id is null; cannot send read receipt or reflect message update");
                return saved;
            }

            boolean receiverAllowsDeliveryReceipt;
            switch (contactModel.getReadReceipts()) {
                case ContactModel.SEND:
                    receiverAllowsDeliveryReceipt = true;
                    break;
                case ContactModel.DONT_SEND:
                    receiverAllowsDeliveryReceipt = false;
                    break;
                default:
                    receiverAllowsDeliveryReceipt = synchronizedSettingsService.areReadReceiptsEnabled();
                    break;
            }

            if (messageAllowsDeliveryReceipt && receiverAllowsDeliveryReceipt) {
                contactService.createReceiver(contactModel).sendDeliveryReceipt(
                    ProtocolDefines.DELIVERYRECEIPT_MSGREAD,
                    new MessageId[]{message.getMessageId()},
                    readAt.toEpochMilli()
                );
                logger.info("Enqueued delivery receipt (read) message for message ID {} from {}",
                    message.getApiMessageId(), contactModel.getIdentity());
            } else {
                if (message instanceof MessageModel) {
                    contactService.createReceiver(contactModel).sendIncomingMessageUpdateRead(
                        Set.of(message.getMessageId()), readAt.toEpochMilli()
                    );
                } else if (message instanceof GroupMessageModel) {
                    int localGroupId = ((GroupMessageModel) message).getGroupId();
                    GroupModelOld groupModel = groupService.getById(localGroupId);
                    if (groupModel != null) {
                        groupService.createReceiver(groupModel).sendIncomingMessageUpdateRead(
                            Set.of(message.getMessageId()),
                            readAt.toEpochMilli()
                        );
                    } else {
                        logger.warn("Could not find group with local group id {}", localGroupId);
                    }
                }
            }
        }

        return saved;
    }

    @Override
    @WorkerThread
    public boolean markAsConsumed(AbstractMessageModel message) throws ThreemaException {
        logger.debug("markAsConsumed message = {}", message.getApiMessageId());
        boolean saved = false;

        if (MessageUtil.canMarkAsConsumed(message)) {
            // save consumed state
            message.setState(MessageState.CONSUMED);
            message.setModifiedAt(Instant.now());

            save(message);

            saved = true;

            fireOnUpdatedMessage(message);
        }

        return saved;
    }

    @Override
    public void remove(AbstractMessageModel messageModel) {
        remove(messageModel, false);
    }

    @Override
    public void remove(final AbstractMessageModel messageModel, boolean silent) {
        SendMachine machine = getSendMachine(messageModel, false);
        if (machine != null) {
            //abort pending send machine
            //do not remove SendMachine (fix ANDR-522)
            machine.abort();
        }

        //remove pending uploads
        cancelUploader(messageModel);

        // remove associated files
        var messageUid = messageModel.getUid();
        if (messageUid != null) {
            fileService.deleteMessageFiles(messageUid, false);
        }

        //remove from dao
        if (messageModel instanceof GroupMessageModel) {
            databaseService.getGroupMessageModelFactory().delete(
                (GroupMessageModel) messageModel
            );

            //remove from cache
            synchronized (groupMessageCache) {
                Iterator<GroupMessageModel> i = groupMessageCache.iterator();
                while (i.hasNext()) {
                    if (i.next().getId() == messageModel.getId()) {
                        i.remove();
                    }
                }
            }
        } else if (messageModel instanceof DistributionListMessageModel) {
            databaseService.getDistributionListMessageModelFactory().delete(
                (DistributionListMessageModel) messageModel
            );

            //remove from cache
            synchronized (distributionListMessageCache) {
                Iterator<DistributionListMessageModel> i = distributionListMessageCache.iterator();
                while (i.hasNext()) {
                    if (i.next().getId() == messageModel.getId()) {
                        i.remove();
                    }
                }
            }
        } else if (messageModel instanceof MessageModel) {
            databaseService.getMessageModelFactory().delete((MessageModel) messageModel);

            //remove from cache
            synchronized (contactMessageCache) {
                Iterator<MessageModel> i = contactMessageCache.iterator();
                while (i.hasNext()) {
                    if (i.next().getId() == messageModel.getId()) {
                        i.remove();
                    }
                }
            }
        }

        if (!silent) {
            fireOnRemovedMessage(messageModel);
        }
    }

    @Override
    public boolean processIncomingContactMessage(final AbstractMessage message, @NonNull TriggerSource triggerSource) throws Exception {
        logger.info("processIncomingContactMessage: {}", message.getMessageId());

        final String senderIdentity = message.getFromIdentity();
        if (senderIdentity == null) {
            logger.error("Could not process a message of type {} without a sender identity", message.getType());
            return false;
        }

        MessageModel messageModel = null;

        MessageModel existingModel = databaseService.getMessageModelFactory()
            .getByApiMessageIdAndIdentity(message.getMessageId(), message.getFromIdentity());

        if (existingModel != null) {
            //first search in cache
            MessageModel savedMessageModel;
            logger.info("processIncomingContactMessage: {} check contact message cache", message.getMessageId());
            synchronized (contactMessageCache) {
                savedMessageModel = contactMessageCache.stream()
                    .filter(model ->
                        model.getApiMessageId() != null &&
                            model.getApiMessageId().equals(message.getMessageId().toString())
                            && senderIdentity.equals(model.getIdentity())
                    )
                    .findFirst()
                    .orElse(null);
            }
            logger.info("processIncomingContactMessage: {} check contact message cache end", message.getMessageId());

            if (savedMessageModel == null) {
                //get from sql result
                savedMessageModel = existingModel;
            }

            if (savedMessageModel.isSaved()) {
                //do nothing!
                return true;
            } else {
                messageModel = savedMessageModel;
            }
        }

        // Look up contact model
        //
        // Note: At this point, the contact should have been created by the IncomingMessageProcessor.
        final ContactModel contactModel = contactService.getByIdentity(senderIdentity);
        if (contactModel == null) {
            logger.error("Could not process a message of type {} from an unknown contact", message.getType());
            logger.info("processIncomingContactMessage: {} FAILED", message.getMessageId());
            return false;
        }

        // As soon as we get a direct message, unhide and unarchive the contact
        contactService.setAcquaintanceLevel(senderIdentity, AcquaintanceLevel.DIRECT);
        contactService.unarchive(senderIdentity, triggerSource);

        // Bump "lastUpdateAt" if necessary, depending on the message type. Note that due to the
        // event bus, we should bump the last update before saving the box message. Saving the box
        // message will trigger the event bus that, among other things, update the webclient. For
        // this purpose it is important that the last update flag has already been bumped.
        if (message.bumpLastUpdate()) {
            contactService.bumpLastUpdate(senderIdentity);
        }

        // Handle message depending on subtype
        final Class<? extends AbstractMessage> messageClass = message.getClass();
        if (messageClass.equals(TextMessage.class)) {
            messageModel = saveBoxMessage((TextMessage) message, messageModel, contactModel);
        } else if (messageClass.equals(LocationMessage.class)) {
            messageModel = saveBoxMessage((LocationMessage) message, messageModel, contactModel);
        } else if (messageClass.equals(PollSetupMessage.class)) {
            messageModel = saveBoxMessage((PollSetupMessage) message, messageModel, contactModel);
        }

        if (messageModel == null) {
            logger.info("processIncomingContactMessage: {} FAILED", message.getMessageId());
            return false;
        }

        logger.info("processIncomingContactMessage: {} SUCCESS - Message ID = {}", message.getMessageId(), messageModel.getId());
        return true;
    }

    @Override
    public boolean processIncomingGroupMessage(
        @NonNull AbstractGroupMessage message,
        @NonNull TriggerSource triggerSource
    ) throws Exception {
        logger.info("processIncomingGroupMessage: {}", message.getMessageId());
        GroupMessageModel messageModel = null;

        // First of all, check if i can receive messages. Note that the common group receive steps
        // must have been executed at this point.
        GroupModelOld groupModel = groupService.getByGroupMessage(message);
        if (groupModel == null) {
            logger.error("GroupMessage {}: error: no groupModel", message.getMessageId());
            return false;
        }

        //is allowed?
        GroupAccessModel access = groupService.getAccess(groupModel, false);
        if (access == null ||
            !access.getCanReceiveMessageAccess().isAllowed()) {
            //not allowed to receive a message, ignore message but
            //set success to true (remove from server)
            logger.error("GroupMessage {}: error: not allowed", message.getMessageId());
            return true;
        }

        // is the user blocked?
        if (blockedIdentitiesService.isBlocked(message.getFromIdentity())) {
            //set success to true (remove from server)
            logger.info("GroupMessage {}: Sender is blocked, ignoring", message.getMessageId());
            return true;
        }

        // reset archived status
        groupService.unarchive((long) groupModel.getId(), triggerSource);

        // Bump "lastUpdateAt" if necessary, depending on the message type
        //
        // Note: Do this before the message is saved! Saving the message will trigger the event bus
        // that will re-sort the conversation list. At that point in time, lastUpdate should already
        // be correct.
        if (message.bumpLastUpdate()) {
            groupService.bumpLastUpdate(groupModel);
        }

        GroupMessageModel existingModel = databaseService.getGroupMessageModelFactory().getByApiMessageIdAndIdentity(
            message.getMessageId(),
            message.getFromIdentity()
        );

        if (existingModel != null) {
            if (existingModel.isSaved()) {
                //do nothing!
                logger.error("GroupMessage {}: error: message already exists", message.getMessageId());
                return true;
            } else {
                //use the first non saved model to edit!
                logger.error("GroupMessage {}: error: reusing unsaved model", message.getMessageId());
                messageModel = existingModel;
            }
        }

        if (message.getClass().equals(GroupTextMessage.class)) {
            messageModel = saveGroupMessage((GroupTextMessage) message, messageModel);
        } else if (message.getClass().equals(GroupLocationMessage.class)) {
            messageModel = saveGroupMessage((GroupLocationMessage) message, messageModel);
        } else if (message.getClass().equals(GroupPollSetupMessage.class)) {
            messageModel = saveGroupMessage((GroupPollSetupMessage) message, messageModel);
        }

        if (messageModel != null) {
            logger.info("processIncomingGroupMessage: {} SUCCESS - Message ID = {}", message.getMessageId(), messageModel.getId());
            return true;
        } else {
            logger.info("processIncomingGroupMessage: {} FAILED", message.getMessageId());
            return false;
        }
    }

    /**
     * Process a 1:1 text message (0x01).
     */
    private MessageModel saveBoxMessage(
        @NonNull TextMessage message,
        MessageModel messageModel,
        @NonNull ContactModel contactModel
    ) {
        if (messageModel == null) {
            ContactMessageReceiver r = contactService.createReceiver(contactModel);
            messageModel = r.createLocalModel(message.getMessageId(), MessageType.TEXT, MessageContentsType.TEXT, message.getTimestamp());
            cache(messageModel);

            messageModel.setMessageFlags(message.getMessageFlags());
            messageModel.setOutbox(false);
            // replace CR by LF for Window$ Phone compatibility - me be removed soon.
            String body = message.getText() != null ? message.getText().replace("\r", "\n") : null;

            messageModel.setBodyAndQuotedMessageId(body);
            messageModel.setIdentity(contactModel.getIdentity());
            messageModel.setForwardSecurityMode(message.getForwardSecurityMode());
            messageModel.setSaved(true);

            databaseService.getMessageModelFactory().create(messageModel);

            fireOnNewMessage(messageModel);
        }

        return messageModel;
    }

    /**
     * Process a 1:1 poll setup message (0x15).
     */
    private MessageModel saveBoxMessage(
        @NonNull PollSetupMessage message,
        MessageModel messageModel,
        @NonNull ContactModel contactModel
    ) throws Exception {
        MessageReceiver messageReceiver = contactService.createReceiver(contactModel);
        return (MessageModel) savePollCreateMessage(
            messageReceiver,
            message.getMessageId(),
            message,
            messageModel,
            message.getMessageFlags(),
            message.getForwardSecurityMode(),
            // Note that this may also be remote, but it is certainly never local. To be safe,
            // we use sync as this will prevent sending any csp messages.
            TriggerSource.SYNC
        );
    }

    private GroupMessageModel saveGroupMessage(GroupPollSetupMessage message, GroupMessageModel messageModel) throws Exception {
        GroupModelOld groupModel = groupService.getByGroupMessage(message);

        if (groupModel == null) {
            return null;
        }

        MessageReceiver messageReceiver = groupService.createReceiver(groupModel);

        return (GroupMessageModel) savePollCreateMessage(
            messageReceiver,
            message.getMessageId(),
            message,
            messageModel,
            message.getMessageFlags(),
            message.getForwardSecurityMode(),
            // Note that this may also be remote, but it is certainly never local. To be safe,
            // we use sync as this will prevent sending any csp messages.
            TriggerSource.SYNC
        );
    }

    @Override
    public AbstractMessageModel savePollCreateMessage(
        @NonNull MessageReceiver<?> receiver,
        @NonNull MessageId messageId,
        @NonNull PollSetupInterface message,
        @Nullable AbstractMessageModel messageModel,
        int messageFlags,
        @Nullable ForwardSecurityMode forwardSecurityMode,
        @NonNull TriggerSource triggerSource
    ) throws ThreemaException, BadMessageException {
        PollUpdateResult result = pollService.update(message, messageId, triggerSource);

        if (result.getPollModel() == null) {
            throw new ThreemaException("could not create poll model");
        }

        switch (result.getOperation()) {
            case CREATE:
            case CLOSE:
                messageModel = createNewPollMessage(
                    messageId,
                    result.getPollModel(),
                    (result.getOperation() == PollUpdateResult.Operation.CREATE ?
                        PollDataModel.Type.POLL_CREATED :
                        PollDataModel.Type.POLL_CLOSED),
                    receiver,
                    messageFlags,
                    forwardSecurityMode);
        }

        return messageModel;
    }

    /**
     * @return {@code true} if the thumbnail was downloaded and saved
     */
    @Override
    public boolean downloadThumbnailIfPresent(@NonNull FileData fileData, @NonNull AbstractMessageModel messageModel) throws Exception {

        if (fileData.getThumbnailBlobId() == null) {
            return false;
        }

        logger.info("Downloading thumbnail of message {}", messageModel.getApiMessageId());
        final AbstractMessageModel messageModel1 = messageModel;

        // If multi-device is active, we always mark as done. Otherwise we do not mark as done if its a group message
        boolean shouldMarkAsDone = multiDeviceManager.isMultiDeviceActive() || !(messageModel instanceof GroupMessageModel);
        @Nullable BlobScope blobScopeMarkAsDone = null;
        if (shouldMarkAsDone) {
            blobScopeMarkAsDone = messageModel.getBlobScopeForMarkAsDone();
        }

        byte[] thumbnailBlob = downloadService.download(
            messageModel.getId(),
            fileData.getThumbnailBlobId(),
            messageModel.getBlobScopeForDownload(),
            blobScopeMarkAsDone,
            new ProgressListener() {
                @Override
                public void onFinished(boolean success) {
                    setMessageLoadingFinished(messageModel1);
                }
            });

        if (thumbnailBlob == null) {
            downloadService.error(messageModel.getId());
            logger.info("Error downloading thumbnail for message {}", messageModel.getApiMessageId());
            throw new ThreemaException("Error downloading thumbnail");
        }

        byte[] thumbnail = symmetricEncryptionService.decrypt(thumbnailBlob, fileData.getEncryptionKey(), ProtocolDefines.FILE_THUMBNAIL_NONCE);

        if (thumbnail != null) {
            try {
                fileService.writeConversationMediaThumbnail(messageModel, thumbnail);
            } catch (Exception exception) {
                downloadService.error(messageModel.getId());
                logger.info("Error writing thumbnail for message {}", messageModel.getApiMessageId());
                throw exception;
            }
        }

        downloadService.complete(messageModel.getId(), fileData.getThumbnailBlobId());
        return true;
    }

    private GroupMessageModel saveGroupMessage(GroupTextMessage message, GroupMessageModel messageModel) {
        GroupModelOld groupModel = groupService.getByGroupMessage(message);

        if (groupModel == null) {
            return null;
        }

        if (messageModel == null) {
            GroupMessageReceiver r = groupService.createReceiver(groupModel);
            messageModel = r.createLocalModel(message.getMessageId(), MessageType.TEXT, MessageContentsType.TEXT, message.getTimestamp());
            cache(messageModel);

            messageModel.setMessageFlags(message.getMessageFlags());
            messageModel.setOutbox(false);
            // replace CR by LF for Window$ Phone compatibility - me be removed soon.
            String body = message.getText() != null ? message.getText().replace("\r", "\n") : null;

            messageModel.setBodyAndQuotedMessageId(body);
            messageModel.setSaved(true);
            messageModel.setIdentity(message.getFromIdentity());
            messageModel.setForwardSecurityMode(message.getForwardSecurityMode());

            r.saveLocalModel(messageModel);

            fireOnNewMessage(messageModel);
        }

        return messageModel;
    }

    private boolean shouldAutoDownload(MessageType type) {
        if (preferenceService != null) {
            ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
            NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
            if (activeNetwork != null) {
                switch (activeNetwork.getType()) {
                    case ConnectivityManager.TYPE_ETHERNET:
                        // fallthrough
                    case ConnectivityManager.TYPE_WIFI:
                        return preferenceService.getWifiAutoDownload().contains(String.valueOf(type.serializedValue));
                    case ConnectivityManager.TYPE_MOBILE:
                        return preferenceService.getMobileAutoDownload().contains(String.valueOf(type.serializedValue));
                    default:
                        break;
                }
            }
        }
        return false;
    }

    /**
     * Check if the file in question should be auto-downloaded or not
     * This depends on file type, file size and user preference (settings)
     *
     * @param messageModel AbstractMessageModel to check
     * @return true if file should be downloaded immediately, false otherwise
     */
    @Override
    public boolean shouldAutoDownload(@NonNull AbstractMessageModel messageModel) {
        MessageType type = MessageType.FILE;
        FileDataModel fileDataModel = messageModel.getFileData();

        if (preferenceService != null) {
            ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
            NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
            if (activeNetwork != null) {
                boolean canDownload = false;

                switch (activeNetwork.getType()) {
                    case ConnectivityManager.TYPE_ETHERNET:
                        // fallthrough
                    case ConnectivityManager.TYPE_WIFI:
                        canDownload = preferenceService.getWifiAutoDownload().contains(String.valueOf(type.serializedValue));
                        break;
                    case ConnectivityManager.TYPE_MOBILE:
                        canDownload = preferenceService.getMobileAutoDownload().contains(String.valueOf(type.serializedValue));
                        break;
                    default:
                        break;
                }

                if (canDownload) {
                    return fileDataModel.getFileSize() <= FILE_AUTO_DOWNLOAD_MAX_SIZE_ISO;
                }
            }
        }
        return false;
    }

    @Nullable
    @WorkerThread
    private GroupMessageModel saveGroupMessage(GroupLocationMessage message, GroupMessageModel messageModel) {
        GroupModelOld groupModel = groupService.getByGroupMessage(message);
        boolean isNewMessage = false;
        if (groupModel == null) {
            return null;
        }

        MessageReceiver r = groupService.createReceiver(groupModel);

        if (messageModel == null) {
            messageModel = (GroupMessageModel) r.createLocalModel(message.getMessageId(), MessageType.LOCATION, MessageContentsType.LOCATION, message.getTimestamp());
            cache(messageModel);

            messageModel.setMessageFlags(message.getMessageFlags());
            messageModel.setOutbox(false);
            messageModel.setIdentity(message.getFromIdentity());

            r.saveLocalModel(messageModel);
            isNewMessage = true;
        }

        // If the location model is missing an address, we perform a lookup based on the coordinates
        @Nullable Poi effectivePoi = message.getPoi();
        if (effectivePoi == null) {
            try {
                // Will result in "Unknown address" as a fallback value
                final @NonNull String lookedUpPoiAddress = GeoLocationUtil.getAddressFromLocation(
                    context,
                    message.getLatitude(),
                    message.getLongitude()
                );
                effectivePoi = new Poi.Unnamed(lookedUpPoiAddress);
            } catch (IOException ioException) {
                logger.error("Exception", ioException);
            }
        }

        messageModel.setLocationData(new LocationDataModel(
            message.getLatitude(),
            message.getLongitude(),
            message.getAccuracy(),
            effectivePoi
        ));

        messageModel.setSaved(true);

        r.saveLocalModel(messageModel);
        if (isNewMessage) {
            fireOnNewMessage(messageModel);
        } else {
            fireOnUpdatedMessage(messageModel);
        }

        return messageModel;
    }

    /**
     * Process a 1:1 location message (0x10).
     */
    @WorkerThread
    private MessageModel saveBoxMessage(
        @NonNull LocationMessage message,
        MessageModel messageModel,
        @NonNull ContactModel contactModel
    ) {
        ContactMessageReceiver r = contactService.createReceiver(contactModel);
        if (messageModel == null) {
            messageModel = r.createLocalModel(message.getMessageId(), MessageType.LOCATION, MessageContentsType.LOCATION, message.getTimestamp());
            cache(messageModel);
            messageModel.setMessageFlags(message.getMessageFlags());
            messageModel.setOutbox(false);
        }

        messageModel.setIdentity(contactModel.getIdentity());
        messageModel.setForwardSecurityMode(message.getForwardSecurityMode());
        messageModel.setSaved(true);

        messageModel.setLocationData(
            new LocationDataModel(
                message.getLatitude(),
                message.getLongitude(),
                message.getAccuracy(),
                message.getPoi()
            )
        );

        // We save the message model already here to ensure it is in the database in case the app
        // gets killed before resolving the address.
        databaseService.getMessageModelFactory().create(messageModel);

        // If the location model is missing an address, we perform a lookup based on the coordinates
        if (message.getPoi() == null) {
            try {
                // Will result in "Unknown address" as a fallback value
                final @NonNull String lookedUpPoiAddress = GeoLocationUtil.getAddressFromLocation(
                    context,
                    message.getLatitude(),
                    message.getLongitude()
                );

                messageModel.setLocationData(
                    new LocationDataModel(
                        message.getLatitude(),
                        message.getLongitude(),
                        message.getAccuracy(),
                        new Poi.Unnamed(lookedUpPoiAddress)
                    )
                );

                // Update the db record
                databaseService.getMessageModelFactory().update(messageModel);

            } catch (IOException ioException) {
                logger.error("Exception", ioException);
            }
        }

        fireOnNewMessage(messageModel);

        return messageModel;
    }

    @Override
    @NonNull
    public List<AbstractMessageModel> getMessagesForReceiver(@NonNull MessageReceiver receiver, MessageFilter messageFilter) {
        return getMessagesForReceiver(receiver, messageFilter, true);
    }

    @Override
    @NonNull
    public List<AbstractMessageModel> getMessagesForReceiver(@NonNull MessageReceiver receiver, MessageFilter messageFilter, boolean appendUnreadMessage) {
        final @NonNull List<AbstractMessageModel> messages = receiver.loadMessages(messageFilter);
        if (!appendUnreadMessage) {
            return messages;
        }
        switch (receiver.getType()) {
            case MessageReceiver.Type_GROUP:
            case MessageReceiver.Type_CONTACT:
                return markFirstUnread(messages);
            default:
                return messages;
        }
    }

    /**
     * Mark the first unread Message
     *
     * @param messageModels Message Models
     */
    @NonNull
    private List<AbstractMessageModel> markFirstUnread(@NonNull List<AbstractMessageModel> messageModels) {
        synchronized (messageModels) {
            int firstUnreadMessagePosition = -1;
            for (int n = 0; n < messageModels.size(); n++) {
                AbstractMessageModel m = messageModels.get(n);

                if (m != null) {
                    if (m.isOutbox()) {
                        break;
                    } else {
                        if (m.isRead()) {
                            break;
                        } else if (!m.isStatusMessage()) {
                            firstUnreadMessagePosition = n;
                        }
                    }
                }
            }

            if (firstUnreadMessagePosition > -1) {
                FirstUnreadMessageModel firstUnreadMessageModel = new FirstUnreadMessageModel();
                firstUnreadMessageModel.setCreatedAt(messageModels.get(firstUnreadMessagePosition).getCreatedAt());
                messageModels.add(firstUnreadMessagePosition + 1, firstUnreadMessageModel);
            }
        }

        return messageModels;
    }

    @Override
    public List<AbstractMessageModel> getMessagesForReceiver(@NonNull MessageReceiver receiver) {
        return getMessagesForReceiver(receiver, null);
    }

    @Override
    public List<AbstractMessageModel> getMessageForPoll(final PollModel pollModel) {
        MessageReceiver receiver = pollService.getReceiver(pollModel);

        if (receiver != null) {
            List<AbstractMessageModel> pollMessages = receiver.loadMessages(new MessageFilter() {
                @Override
                public long getPageSize() {
                    return 0;
                }

                @Override
                public Integer getPageReferenceId() {
                    return null;
                }

                @Override
                public boolean withStatusMessages() {
                    return false;
                }

                @Override
                public boolean withUnsaved() {
                    return true;
                }

                @Override
                public boolean onlyUnread() {
                    return false;
                }

                @Override
                public boolean onlyDownloaded() {
                    return false;
                }

                @Override
                public MessageType[] types() {
                    return new MessageType[]{
                        MessageType.POLL
                    };
                }

                @Override
                public int[] contentTypes() {
                    return null;
                }

                @Override
                public int[] displayTags() {
                    return null;
                }
            });

            return pollMessages.stream()
                .filter(model -> model.getPollData().getPollId() == pollModel.getId())
                .collect(Collectors.toList());
        }
        return null;
    }

    private List<AbstractMessageModel> getContactMessagesForText(String query, boolean includeArchived, boolean starredOnly, boolean sortAscending) {
        return databaseService.getMessageModelFactory().getMessagesByText(query, includeArchived, starredOnly, sortAscending);
    }

    private List<AbstractMessageModel> getGroupMessagesForText(String query, boolean includeArchived, boolean starredOnly, boolean sortAscending) {
        return databaseService.getGroupMessageModelFactory().getMessagesByText(query, includeArchived, starredOnly, sortAscending);
    }

    @Override
    @NonNull
    public List<AbstractMessageModel> getMessagesForText(@Nullable String queryString, @MessageFilterFlags int filterFlags, boolean sortAscending) {
        List<AbstractMessageModel> messageModels = new ArrayList<>();

        boolean includeArchived = (filterFlags & FILTER_INCLUDE_ARCHIVED) == FILTER_INCLUDE_ARCHIVED;
        boolean starredOnly = (filterFlags & FILTER_STARRED_ONLY) == FILTER_STARRED_ONLY;

        if ((filterFlags & FILTER_CHATS) == FILTER_CHATS) {
            messageModels.addAll(getContactMessagesForText(queryString, includeArchived,
                starredOnly,
                sortAscending));
        }

        if ((filterFlags & FILTER_GROUPS) == FILTER_GROUPS) {
            messageModels.addAll(getGroupMessagesForText(queryString, includeArchived,
                starredOnly,
                sortAscending));
        }

        if (!messageModels.isEmpty()) {
            if (sortAscending) {
                Collections.sort(messageModels, (o1, o2) -> o1.getCreatedAt().compareTo(o2.getCreatedAt()));
            } else {
                Collections.sort(messageModels, (o1, o2) -> o2.getCreatedAt().compareTo(o1.getCreatedAt()));
            }
        }
        return messageModels;
    }

    @Override
    @WorkerThread
    public int unstarAllMessages() {
        return
            databaseService.getMessageModelFactory().unstarAllMessages() +
                databaseService.getGroupMessageModelFactory().unstarAllMessages();
    }

    @Override
    @WorkerThread
    public long countStarredMessages() throws SQLiteException {
        return
            databaseService.getMessageModelFactory().countStarredMessages() +
                databaseService.getGroupMessageModelFactory().countStarredMessages();
    }

    @Override
    @Nullable
    public MessageModel getContactMessageModel(final Integer id) {
        @Nullable MessageModel messageModel;
        synchronized (contactMessageCache) {
            messageModel = contactMessageCache.stream()
                .filter(model -> model.getId() == id)
                .findFirst()
                .orElse(null);
        }
        if (messageModel == null) {
            messageModel = databaseService.getMessageModelFactory().getById(id);
            if (messageModel != null) {
                synchronized (contactMessageCache) {
                    contactMessageCache.add(messageModel);
                }
            }
        }
        return messageModel;
    }

    private @Nullable MessageModel getContactMessageModel(
        @NonNull final String apiMessageId,
        @NonNull ContactMessageReceiver messageReceiver
    ) {
        MessageModel model;
        synchronized (contactMessageCache) {
            model = contactMessageCache.stream()
                .filter(messageModel ->
                    apiMessageId.equals(messageModel.getApiMessageId())
                        && messageReceiver.getContact().getIdentity().equals(messageModel.getIdentity())
                )
                .findFirst()
                .orElse(null);
        }
        if (model == null) {
            try {
                model = databaseService.getMessageModelFactory().getByApiMessageIdAndIdentity(
                    new MessageId(hexToByteArray(apiMessageId)),
                    messageReceiver.getContact().getIdentity()
                );
                if (model != null) {
                    synchronized (contactMessageCache) {
                        contactMessageCache.add(model);
                    }
                }
            } catch (IllegalArgumentException ignore) {
                logger.warn("Encountered invalid message ID in contact message");
            }
        }
        return model;
    }

    @Nullable
    @Override
    public MessageModel getContactMessageModel(final String uid) {
        return databaseService.getMessageModelFactory().getByUid(uid);
    }

    @Override
    @Nullable
    public GroupMessageModel getGroupMessageModel(final Integer id) {
        synchronized (groupMessageCache) {
            GroupMessageModel groupMessageModel = groupMessageCache.stream()
                .filter(model -> model.getId() == id)
                .findFirst()
                .orElse(null);
            if (groupMessageModel == null) {
                groupMessageModel = databaseService.getGroupMessageModelFactory().getById(id);
                if (groupMessageModel != null) {
                    groupMessageCache.add(groupMessageModel);
                }
            }
            return groupMessageModel;
        }
    }

    @Nullable
    @Override
    public GroupMessageModel getGroupMessageModel(final String uid) {
        return databaseService.getGroupMessageModelFactory().getByUid(uid);
    }

    private GroupMessageModel getGroupMessageModel(
        @NonNull final String apiMessageId,
        @NonNull GroupMessageReceiver messageReceiver
    ) {
        int groupId = messageReceiver.getGroup().getId();
        synchronized (groupMessageCache) {
            GroupMessageModel groupMessageModel = groupMessageCache.stream()
                .filter(messageModel ->
                    apiMessageId.equals(messageModel.getApiMessageId())
                        && groupId == messageModel.getGroupId()
                )
                .findFirst()
                .orElse(null);

            if (groupMessageModel == null) {
                try {
                    groupMessageModel = databaseService.getGroupMessageModelFactory().getByApiMessageIdAndGroupId(
                        new MessageId(hexToByteArray(apiMessageId)),
                        groupId
                    );
                    if (groupMessageModel != null) {
                        groupMessageCache.add(groupMessageModel);
                    }
                } catch (IllegalArgumentException ignore) {
                    logger.warn("Encountered invalid message ID in group message");
                }
            }
            return groupMessageModel;
        }
    }

    @Override
    @Nullable
    public DistributionListMessageModel getDistributionListMessageModel(long id) {
        return databaseService.getDistributionListMessageModelFactory().getById(id);
    }

    @Override
    public MessageString getMessageString(AbstractMessageModel messageModel, int maxLength) {
        return getMessageString(messageModel, maxLength, true);
    }

    @NonNull
    @Override
    public MessageString getMessageString(AbstractMessageModel messageModel, int maxLength, boolean withPrefix) {
        boolean isPrivate;
        String prefix = "";

        if (messageModel instanceof GroupMessageModel) {
            //append Username
            if (withPrefix) {
                var shortName = NameUtil.getShortName(context, messageModel, contactService, preferenceService.getContactNameFormat());
                if (shortName != null) {
                    prefix = shortName + ": ";
                }
            }
            final @Nullable GroupModelOld groupModel = groupService.getById(((GroupMessageModel) messageModel).getGroupId());
            final @Nullable GroupConversationId groupConversationId = groupModel != null
                ? new GroupConversationId(groupModel.getId())
                : null;
            isPrivate = groupConversationId != null && conversationCategoryService.isMarkedAsPrivate(groupConversationId);
        } else {
            final @Nullable String identity = messageModel.getIdentity();
            if (identity != null) {
                isPrivate = conversationCategoryService.isMarkedAsPrivate(
                    new ContactConversationId(identity)
                );
            } else {
                logger.error("The identity of the message model is null");
                isPrivate = false;
            }
        }

        if (isPrivate) {
            return new MessageString(context.getString(R.string.new_messages_locked));
        }

        if (messageModel.isDeleted()) {
            return new MessageString(context.getString(R.string.message_was_deleted));
        }

        switch (messageModel.getType()) {
            case TEXT:
                @Nullable String messageText = QuoteUtil.getMessageBody(
                    messageModel.getType(),
                    messageModel.getBody(),
                    messageModel.getCaption(),
                    messageModel.isOutbox(),
                    false,
                    preferenceService.getContactNameFormat()
                );
                String rawMessageText = prefix + messageText;
                if (maxLength > 0 && messageText != null && messageText.length() > maxLength) {
                    messageText = messageText.substring(0, maxLength - 3) + "...";
                }
                return new MessageString(messageText, rawMessageText);
            case LOCATION:
                String locationString = prefix + context.getString(R.string.location_placeholder);
                final @NonNull LocationDataModel locationDataModel = messageModel.getLocationData();
                if (locationDataModel.poiNameOrNull != null) {
                    locationString += ": " + locationDataModel.poiNameOrNull;
                }
                return new MessageString(locationString);
            case FILE:
                if (MimeUtil.isImageFile(messageModel.getFileData().getMimeType())) {
                    if (isNullOrEmpty(messageModel.getCaption())) {
                        return new MessageString(prefix + context.getString(R.string.image_placeholder));
                    } else {
                        return new MessageString(prefix + context.getString(R.string.image_placeholder) + ": " + messageModel.getFileData().getCaption());
                    }
                } else if (MimeUtil.isVideoFile(messageModel.getFileData().getMimeType())) {
                    if (isNullOrEmpty(messageModel.getFileData().getCaption())) {
                        String durationString = ElapsedTimeFormatter.secondsToString(messageModel.getFileData().getDurationSeconds());
                        return new MessageString(prefix + context.getString(R.string.video_placeholder) + " (" + durationString + ")");
                    } else {
                        return new MessageString(prefix + context.getString(R.string.video_placeholder) + ": " + messageModel.getFileData().getCaption());
                    }
                } else if (MimeUtil.isAudioFile(messageModel.getFileData().getMimeType())) {
                    if (isNullOrEmpty(messageModel.getFileData().getCaption())) {
                        String durationString = ElapsedTimeFormatter.secondsToString(messageModel.getFileData().getDurationSeconds());
                        if ("00:00".equals(durationString)) {
                            return new MessageString(prefix + context.getString(R.string.audio_placeholder));
                        } else {
                            return new MessageString(prefix + context.getString(R.string.audio_placeholder) + " (" + durationString + ")");
                        }
                    } else {
                        return new MessageString(prefix + context.getString(R.string.audio_placeholder) + ": " + messageModel.getFileData().getCaption());
                    }
                } else {
                    if (isNullOrEmpty(messageModel.getFileData().getCaption())) {
                        return new MessageString(prefix + context.getString(R.string.file_placeholder) + ": " + messageModel.getFileData().getFileName());
                    } else {
                        return new MessageString(prefix + context.getString(R.string.file_placeholder) + ": " + messageModel.getFileData().getCaption());
                    }
                }
            case POLL:
                @NonNull String pollNotificationString = "";
                if (messageModel.getBody() != null && !messageModel.getBody().isEmpty()) {
                    final @NonNull PollDataModel pollDataModel = PollDataModel.deserialize(messageModel.getBody());
                    pollNotificationString = PollUtil.getNotificationString(context, pollDataModel.getPollId());
                }
                return new MessageString(
                    prefix + context.getString(R.string.ballot_placeholder) + ": " + pollNotificationString
                );
            case VOIP_STATUS:
                return new MessageString(
                    prefix + MessageUtil.getViewElement(messageModel, preferenceService.getContactNameFormat()).placeholder
                );
            default:
                return new MessageString(prefix);
        }
    }

    @Override
    @WorkerThread
    public boolean downloadMediaMessage(
        @Nullable AbstractMessageModel mediaMessageModel,
        @Nullable ProgressListener progressListener
    ) throws Exception {
        if (!MessageUtil.hasDataFile(mediaMessageModel)) {
            throw new ThreemaException("message is not a media message");
        }

        MediaMessageDataInterface data = getDataForMessageType(mediaMessageModel);

        if (data != null && !data.isDownloaded()) {
            if (downloadAndWriteMediaData(mediaMessageModel, data, progressListener)) {
                setDownloadCompleted(mediaMessageModel, data);
                saveImagesAndVideosToGalleryIfEnabled(mediaMessageModel, data);
                return true;
            } else {
                logger.error("Decryption failed");
                this.downloadService.error(mediaMessageModel.getId());
                throw new ThreemaException("Decryption failed");
            }
        }
        return false;
    }

    private @Nullable MediaMessageDataInterface getDataForMessageType(
        @NonNull AbstractMessageModel mediaMessageModel
    ) {
        if (mediaMessageModel.getType() == MessageType.FILE) {
            return mediaMessageModel.getFileData();
        }
        return null;
    }

    private boolean downloadAndWriteMediaData(
        @NonNull AbstractMessageModel mediaMessageModel,
        @NonNull MediaMessageDataInterface data,
        @Nullable ProgressListener progressListener
    ) throws ThreemaException {
        var messageUid = mediaMessageModel.getUid();
        if (messageUid != null && fileService.hasMessageFile(messageUid)) {
            return true;
        }

        // If multi-device is active, we always mark as done. Otherwise we do not mark as done if its a group message
        boolean shouldMarkAsDone = multiDeviceManager.isMultiDeviceActive() || !(mediaMessageModel instanceof GroupMessageModel);
        @Nullable BlobScope blobScopeMarkAsDone = null;
        if (shouldMarkAsDone) {
            blobScopeMarkAsDone = mediaMessageModel.getBlobScopeForMarkAsDone();
        }

        byte[] blob = downloadService.download(
            mediaMessageModel.getId(),
            data.getBlobId(),
            mediaMessageModel.getBlobScopeForDownload(),
            blobScopeMarkAsDone,
            progressListener
        );
        if (blob == null || blob.length < NaCl.BOX_OVERHEAD_BYTES) {
            logger.error("Blob for message {} is empty", mediaMessageModel.getApiMessageId());

            downloadService.error(mediaMessageModel.getId());
            // blob download failed or empty or canceled
            throw new ThreemaException("failed to download message");
        }

        boolean success = decryptMediaDataAndWriteConversationMedia(mediaMessageModel, data, blob);

        if (success && (messageUid == null || !fileService.hasMessageThumbnail(messageUid))) {
            createAndWriteMediaThumbnail(mediaMessageModel);
        }
        return success;
    }

    private void setDownloadCompleted(@NonNull AbstractMessageModel mediaMessageModel, @NonNull MediaMessageDataInterface data) {
        if (mediaMessageModel.getType() == MessageType.FILE) {
            mediaMessageModel.getFileData().isDownloaded(true);
        }
        mediaMessageModel.writeDataModelToBody();

        save(mediaMessageModel);

        fireOnUpdatedMessage(mediaMessageModel);

        downloadService.complete(mediaMessageModel.getId(), data.getBlobId());
    }

    @WorkerThread
    private void saveImagesAndVideosToGalleryIfEnabled(@NonNull AbstractMessageModel mediaMessageModel, @NonNull MediaMessageDataInterface data) {
        if (preferenceService.isSaveMedia() && isImageOrVideoFile(mediaMessageModel, data)) {

            final boolean isPrivate;
            if (mediaMessageModel instanceof GroupMessageModel) {
                isPrivate = conversationCategoryService.isMarkedAsPrivate(
                    new GroupConversationId(((GroupMessageModel) mediaMessageModel).getGroupId())
                );
            } else {
                final @Nullable String identity = mediaMessageModel.getIdentity();
                if (identity != null) {
                    isPrivate = conversationCategoryService.isMarkedAsPrivate(
                        new ContactConversationId(identity)
                    );
                } else {
                    isPrivate = false;
                }
            }

            if (!isPrivate) {
                try {
                    fileService.copyMediaFileToGallery(mediaMessageModel);
                } catch (IOException e) {
                    logger.warn("Failed to save to gallery", e);
                }
            }
        }
    }

    private boolean isImageOrVideoFile(@NonNull AbstractMessageModel mediaMessageModel, @NonNull MediaMessageDataInterface data) {
        MessageType type = mediaMessageModel.getType();
        return type == MessageType.FILE && FileUtil.isImageOrVideoFile((FileDataModel) data);
    }

    private boolean decryptMediaDataAndWriteConversationMedia(
        @NonNull AbstractMessageModel messageModel,
        @NonNull MediaMessageDataInterface data,
        @NonNull byte[] blob
    ) throws ThreemaException {
        logger.info("Decrypting blob for message {}", messageModel.getApiMessageId());


        int length;
        String legacyNonce = messageModel.getFileData().getMetaDataString(FileDataModel.METADATA_KEY_LEGACY_NONCE);
        if (legacyNonce != null) {
            // If a nonce is present, it means that this message was created by transforming an image message into a file message.
            var contact = contactModelRepository.getByIdentity(messageModel.getIdentity());
            if (contact == null || contact.getData() == null || contact.getData().publicKey == null) {
                throw new ThreemaException("Unable to decrypt image blob, contact not found");
            }
            byte[] encryptionKey = contact.getData().publicKey;
            byte[] nonce = hexToByteArray(legacyNonce);
            blob = identityStore.decryptData(blob, nonce, encryptionKey);
            length = blob.length;
        } else {
            try {
                symmetricEncryptionService.decryptInplace(blob, data.getEncryptionKey(), ProtocolDefines.FILE_NONCE);
            } catch (IllegalArgumentException | CryptoException exception) {
                throw new ThreemaException("Unable to decrypt media", exception);
            }
            length = blob.length - NaCl.BOX_OVERHEAD_BYTES;
        }

        logger.info("Write conversation media for message {}", messageModel.getApiMessageId());

        // save the file
        try {
            var messageUid = messageModel.getUid();
            if (messageUid != null) {
                if (fileService.writeConversationMedia(messageUid, blob, 0, length, true)) {
                    logger.info("Media for message {} successfully saved.", messageModel.getApiMessageId());
                    return true;
                }
            }
        } catch (Exception e) {
            logger.warn("Unable to save media");

            downloadService.error(messageModel.getId());

            throw new ThreemaException("Unable to save media");
        }
        return false;
    }

    private void createAndWriteMediaThumbnail(@NonNull AbstractMessageModel messageModel) {
        if (!MessageUtil.canHaveThumbnailFile(messageModel)) {
            // ignore messages that cannot have a thumbnail
            return;
        }

        try {
            File file = fileService.decryptMessageFileToTempFile(messageModel);
            byte[] thumbnailData = ThumbnailUtil.generateThumbnailData(context, getMimeTypeString(messageModel), file);
            if (thumbnailData != null) {
                fileService.writeConversationMediaThumbnail(messageModel, thumbnailData);
            }
        } catch (Exception e) {
            logger.error("Could not write conversation media thumbnail", e);
        }
    }

    @Override
    public boolean cancelMessageDownload(AbstractMessageModel messageModel) {
        return downloadService.cancel(messageModel.getId());
    }

    private void fireOnNewMessage(final AbstractMessageModel messageModel) {
        logger.debug("fireOnNewMessage for message {}", messageModel.getApiMessageId());
        globalEventBuses.getMessages().emit(new MessageEvent.NewMessage(messageModel));
    }

    private void fireOnUpdatedMessage(final AbstractMessageModel messageModel) {
        globalEventBuses.getMessages().emit(new MessageEvent.MessagesUpdated(messageModel));
    }

    private void fireOnRemovedMessage(final AbstractMessageModel messageModel) {
        globalEventBuses.getMessages().emit(new MessageEvent.MessageRemovedLocally(messageModel));
    }

    private void setMessageLoadingFinished(AbstractMessageModel messageModel) {
        cancelUploader(messageModel);
    }

    @Override
    public void removeAll() throws SQLException, IOException, ThreemaException {
        //use the fast way
        databaseService.getMessageModelFactory().deleteAll();
        databaseService.getGroupMessageModelFactory().deleteAll();
        databaseService.getDistributionListMessageModelFactory().deleteAll();

        //clear all caches
        synchronized (contactMessageCache) {
            contactMessageCache.clear();
        }

        //clear all caches
        synchronized (groupMessageCache) {
            groupMessageCache.clear();
        }

        //clear all caches
        synchronized (distributionListMessageCache) {
            distributionListMessageCache.clear();
        }

        //clear all media files
        fileService.deleteMediaFiles();
    }

    @Override
    public void save(final AbstractMessageModel messageModel) {
        if (messageModel != null) {
            if (messageModel instanceof MessageModel) {
                synchronized (contactMessageCache) {
                    databaseService.getMessageModelFactory().createOrUpdate(
                        (MessageModel) messageModel
                    );

                    // Update the cache
                    Iterator<MessageModel> iterator = contactMessageCache.iterator();
                    while (iterator.hasNext()) {
                        MessageModel cached = iterator.next();
                        if (cached.getId() == messageModel.getId() && cached != messageModel) {
                            // Remove old message model from cache if not the same object
                            iterator.remove();
                        }
                    }

                }
            } else if (messageModel instanceof GroupMessageModel) {
                synchronized (groupMessageCache) {
                    databaseService.getGroupMessageModelFactory().createOrUpdate(
                        (GroupMessageModel) messageModel);

                    // remove "old" message models from cache
                    groupMessageCache.stream()
                        .filter(model -> model.getId() == messageModel.getId() && model != messageModel)
                        .forEach(model -> {
                            logger.debug("Updating cached data for group message model {}", messageModel.getApiMessageId());
                            model.copyFrom((GroupMessageModel) messageModel);
                        });
                }
            } else if (messageModel instanceof DistributionListMessageModel) {
                synchronized (distributionListMessageCache) {
                    databaseService.getDistributionListMessageModelFactory().createOrUpdate(
                        (DistributionListMessageModel) messageModel);

                    // remove "old" message models from cache
                    distributionListMessageCache.stream()
                        .filter(model -> model.getId() == messageModel.getId() && model != messageModel)
                        .forEach(model -> {
                            // remove cached unsaved object
                            logger.debug("copy from distribution list message model fix");
                            model.copyFrom(messageModel);
                        });
                }
            }

            // Cache the element for more actions
            cache(messageModel);
        }
    }

    @Override
    public long getTotalMessageCount() {
        //simple count
        return databaseService.getMessageModelFactory().count()
            + databaseService.getGroupMessageModelFactory().count()
            + databaseService.getDistributionListMessageModelFactory().count();

    }

    @NonNull
    private String getMimeTypeString(AbstractMessageModel model) {
        @Nullable MessageType type = model.getType();

        if (type == null) {
            logger.error("No message type set for message {} use fall back mime type '{}'", model.getApiMessageId(), MimeUtil.MIME_TYPE_ANY);
            return MimeUtil.MIME_TYPE_ANY;
        }
        if (type == MessageType.FILE) {
            return model.getFileData().getMimeType();
        }
        return MimeUtil.MIME_TYPE_ANY;
    }

    private String getLeastCommonDenominatorMimeType(ArrayList<AbstractMessageModel> models) {
        String mimeType = getMimeTypeString(models.get(0));

        if (models.size() > 1) {
            for (int i = 1; i < models.size(); i++) {
                mimeType = MimeUtil.getCommonMimeType(mimeType, getMimeTypeString(models.get(i)));
            }
        }

        return mimeType;
    }

    @Override
    public boolean shareMediaMessages(final Context context, ArrayList<AbstractMessageModel> models, ArrayList<Uri> shareFileUris, String caption) {
        if (context != null && models != null && shareFileUris != null) {
            if (!models.isEmpty() && !shareFileUris.isEmpty()) {
                Intent intent;
                if (models.size() == 1) {
                    AbstractMessageModel model = models.get(0);
                    Uri shareFileUri = shareFileUris.get(0);

                    if (shareFileUri == null) {
                        logger.info("No file to share");
                        return false;
                    }

                    intent = new Intent(Intent.ACTION_SEND);
                    intent.putExtra(Intent.EXTRA_STREAM, shareFileUri);
                    intent.setType(getMimeTypeString(model));
                    if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(shareFileUri.getScheme())) {
                        intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
                    }
                    if (!isNullOrEmpty(caption)) {
                        intent.putExtra(Intent.EXTRA_TEXT, caption);
                    }
                } else {
                    intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
                    intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, shareFileUris);

                    Uri firstShareFileUri = shareFileUris.get(0);

                    intent.setType(getLeastCommonDenominatorMimeType(models));
                    if (firstShareFileUri != null) {
                        if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(firstShareFileUri.getScheme())) {
                            intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
                        }
                    }
                }

                try {
                    context.startActivity(Intent.createChooser(intent, context.getResources().getText(R.string.share_via)));

                    return true;
                } catch (ActivityNotFoundException e) {
                    // make sure Toast runs in UI thread
                    RuntimeUtil.runOnUiThread(() -> Toast.makeText(context, R.string.no_activity_for_mime_type, Toast.LENGTH_SHORT).show());
                }
            }
        }
        return false;
    }

    @Override
    public boolean viewMediaMessage(final Context context, AbstractMessageModel model, Uri uri) {
        if (context != null && model != null && uri != null) {
            Intent intent = new Intent(Intent.ACTION_VIEW);

            String mimeType = getMimeTypeString(model);
            if (mimeType.isEmpty()) {
                logger.warn("Mime type is empty for message {}", model.getApiMessageId());
            }
            if (MimeUtil.isImageFile(mimeType)) {
                // some viewers cannot handle image/gif - give them a generic mime type
                mimeType = MimeUtil.MIME_TYPE_IMAGE;
            }
            intent.setDataAndType(uri, mimeType);
            if (ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme())) {
                intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
            } else if (!(context instanceof Activity)) {
                intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            }

            try {
                context.startActivity(intent);
            } catch (ActivityNotFoundException e) {
                // make sure Toast runs in UI thread
                RuntimeUtil.runOnUiThread(() -> Toast.makeText(context, R.string.no_activity_for_mime_type, Toast.LENGTH_SHORT).show());
            } catch (SecurityException e) {
                logger.error("Error firing ACTION_VIEW intent", e);
                RuntimeUtil.runOnUiThread(() -> Toast.makeText(context, context.getString(R.string.no_activity_for_mime_type) + " " + e.getMessage(), Toast.LENGTH_SHORT).show());
            }
        }
        return false;
    }

    @Override
    public boolean shareTextMessage(Context context, AbstractMessageModel messageModel) {
        if (messageModel != null) {
            String text = "";

            Intent intent = new Intent();
            if (messageModel.getType() == MessageType.LOCATION) {
                Uri locationUri = GeoLocationUtil.getLocationUri(messageModel);

                final @NonNull LocationDataModel locationDataModel = messageModel.getLocationData();
                if (locationDataModel.poiAddressOrNull != null) {
                    text = locationDataModel.poiAddressOrNull + " - ";
                }
                text += locationUri.toString();
            } else {
                text = QuoteUtil.getMessageBody(
                    messageModel.getType(),
                    messageModel.getBody(),
                    messageModel.getCaption(),
                    messageModel.isOutbox(),
                    false,
                    preferenceService.getContactNameFormat()
                );
            }

            intent.setAction(Intent.ACTION_SEND);
            intent.putExtra(android.content.Intent.EXTRA_TEXT, text);
            intent.setType(MimeUtil.MIME_TYPE_TEXT);

            try {
                context.startActivity(Intent.createChooser(intent, context.getResources().getText(R.string.share_via)));
            } catch (Exception e) {
                Toast.makeText(context, R.string.no_activity_for_mime_type, Toast.LENGTH_LONG).show();
                logger.error("Exception", e);
            }
        }
        return false;
    }

    @Override
    public void markConversationAsRead(@NonNull MessageReceiver messageReceiver, NotificationService notificationService) {
        @SuppressWarnings("unchecked")
        List<AbstractMessageModel> unreadMessages = messageReceiver.loadMessages(new MessageService.MessageFilter() {
            @Override
            public long getPageSize() {
                return 0;
            }

            @Override
            public Integer getPageReferenceId() {
                return null;
            }

            @Override
            public boolean withStatusMessages() {
                return false;
            }

            @Override
            public boolean withUnsaved() {
                return false;
            }

            @Override
            public boolean onlyUnread() {
                return true;
            }

            @Override
            public boolean onlyDownloaded() {
                return false;
            }

            @Override
            public MessageType[] types() {
                return null;
            }

            @Override
            public int[] contentTypes() {
                return null;
            }

            @Override
            public int[] displayTags() {
                return null;
            }
        });

        new MarkAsReadRoutine(this, notificationService)
            .run(unreadMessages, messageReceiver);
        notificationService.cancel(messageReceiver.getConversationId());
    }

    @Override
    @Nullable
    public AbstractMessageModel getMessageModelFromId(int id, String type) {
        if (id != 0 && !isNullOrEmpty(type)) {
            if (type.equals(MessageModel.class.toString())) {
                return getContactMessageModel(id);
            } else if (type.equals(GroupMessageModel.class.toString())) {
                return getGroupMessageModel(id);
            } else if (type.equals(DistributionListMessageModel.class.toString())) {
                return getDistributionListMessageModel(id);
            }
        }
        return null;
    }

    @Override
    @Nullable
    public AbstractMessageModel getMessageModelByApiMessageIdAndReceiver(
        @Nullable String apiMessageId,
        @NonNull MessageReceiver messageReceiver
    ) {
        if (apiMessageId != null) {
            if (messageReceiver instanceof ContactMessageReceiver) {
                return getContactMessageModel(apiMessageId, (ContactMessageReceiver) messageReceiver);
            } else if (messageReceiver instanceof GroupMessageReceiver) {
                return getGroupMessageModel(apiMessageId, (GroupMessageReceiver) messageReceiver);
            } else if (messageReceiver instanceof DistributionListMessageReceiver) {
                // We cannot return a message model with a certain api message id for distribution
                // lists, because the api message id is null for all distribution list messages
                return null;
            }
        }
        return null;
    }

    /*******************************************************************************************
     * Uploader Cache (used to cancel running downloads)
     *******************************************************************************************/

    private final Map<String, BlobUploader> uploaders = new ArrayMap<>();
    private final Map<String, WeakReference<VideoTranscoder>> videoTranscoders = new ArrayMap<>();


    /**
     * Create a new BlobUploader. An existing uploader will be canceled.
     */
    @NonNull
    private BlobUploader initUploader(
        AbstractMessageModel messageModel,
        byte[] data,
        @NonNull MessageReceiver<?> messageReceiver
    ) throws ThreemaException {
        synchronized (uploaders) {
            final @NonNull String key = cancelUploader(messageModel);

            final boolean isNotesGroup =
                messageReceiver instanceof GroupMessageReceiver &&
                    groupService.isNotesGroup(((GroupMessageReceiver) messageReceiver).getGroup());

            boolean shouldPersist = shouldPersistUploadForMessage(messageModel, isNotesGroup);

            // If the message is sent to a notes group, the blob scope must not be "public"
            @NonNull BlobScope blobScope = isNotesGroup ? BlobScope.Local.INSTANCE : BlobScope.Public.INSTANCE;

            BlobUploader uploader = apiService.createUploader(
                data,
                shouldPersist,
                blobScope
            );
            uploaders.put(key, uploader);

            logger.debug("Created new uploader for message {}, persist={}", key, shouldPersist);
            return uploader;
        }
    }

    private boolean shouldPersistUploadForMessage(@NonNull AbstractMessageModel messageModel, boolean isNotesGroup) {
        if (messageModel instanceof MessageModel) {
            // 1:1 messages do not need to be persisted
            return false;
        } else if (messageModel instanceof GroupMessageModel) {
            // Messages in groups need to be persisted if it is not a notes group
            return !isNotesGroup;
        } else if (messageModel instanceof DistributionListMessageModel) {
            // Messages in distribution lists must be persisted
            return true;
        } else {
            // This cannot happen
            logger.error("Unexpected message model. Cannot determine whether it should be persisted or not");
            return false;
        }
    }

    @NonNull
    private String getLoaderKey(@NonNull AbstractMessageModel messageModel) {
        return messageModel.getClass() + "-" + messageModel.getUid();
    }

    /**
     * Cancel an existing BlobUploader for the same {@code messageModel}
     */
    @NonNull
    private String cancelUploader(@NonNull AbstractMessageModel messageModel) {
        synchronized (uploaders) {
            String key = getLoaderKey(messageModel);

            final @Nullable BlobUploader blobUploader = uploaders.get(key);
            if (blobUploader != null) {
                logger.debug("cancel upload of message {}", key);
                blobUploader.cancel();
                uploaders.remove(key);
            }

            return key;
        }
    }

    /**
     * cancel an existing video transcoding
     */
    private String cancelTranscoding(AbstractMessageModel messageModel) {
        synchronized (videoTranscoders) {
            String key = getLoaderKey(messageModel);

            if (videoTranscoders.containsKey(key)) {
                logger.debug("cancel transcoding of message {}", key);
                WeakReference<VideoTranscoder> videoTranscoderRef = videoTranscoders.get(key);
                if (videoTranscoderRef != null) {
                    if (videoTranscoderRef.get() != null) {
                        videoTranscoderRef.get().cancel();
                    }
                }
                videoTranscoders.remove(key);
            }
            return key;
        }
    }

    @Override
    public void cancelMessageUpload(AbstractMessageModel messageModel) {
        updateOutgoingMessageState(messageModel, MessageState.SENDFAILED, Instant.now());

        if (messageSendingService != null) {
            messageSendingService.abort(messageModel.getUid());
        }
        removeSendMachine(messageModel);
        cancelUploader(messageModel);
    }

    @Override
    public void cancelVideoTranscoding(AbstractMessageModel messageModel) {
        updateOutgoingMessageState(messageModel, MessageState.SENDFAILED, Instant.now());

        removeSendMachine(messageModel);
        cancelTranscoding(messageModel);
    }

    /******************************************************************************************
     * Sending Message Machine
     * * Handling sending steps of image/video/audio or file messages
     * * Can be aborted
     ******************************************************************************************/

    public final Map<String, SendMachine> sendMachineInstances = new HashMap<>();

    /**
     * Remove a instantiated sendmachine if exists
     */
    public void removeSendMachine(SendMachine sendMachine) {
        if (sendMachine != null) {
            sendMachine.abort();

            //remove from instances
            synchronized (sendMachineInstances) {
                for (Iterator<Map.Entry<String, SendMachine>> it = sendMachineInstances.entrySet().iterator(); it.hasNext(); ) {
                    Map.Entry<String, SendMachine> entry = it.next();
                    if (entry.getValue() == sendMachine) {
                        logger.debug("remove send machine from instance map");
                        it.remove();
                    }
                }
            }
        }
    }

    public void removeSendMachine(AbstractMessageModel messageModel) {
        if (messageModel == null) {
            //ignore
            return;
        }

        removeSendMachine(getSendMachine(messageModel, false));
    }

    /**
     * get or create a existing send machine
     */
    public SendMachine getSendMachine(AbstractMessageModel abstractMessageModel) {
        return getSendMachine(abstractMessageModel, true);
    }

    /**
     * get a send machine or create one (and cache into machine instances)
     * can return NULL
     */
    public SendMachine getSendMachine(AbstractMessageModel abstractMessageModel, boolean createIfNotExists) {
        synchronized (sendMachineInstances) {
            //be sure to "generate" a unique key
            String key = abstractMessageModel.getClass() + "-" + abstractMessageModel.getUid();

            SendMachine instance = null;
            if (sendMachineInstances.containsKey(key)) {
                instance = sendMachineInstances.get(key);
            } else if (createIfNotExists) {
                instance = new SendMachine();
                sendMachineInstances.put(key, instance);
            }
            return instance;
        }
    }

    interface SendMachineProcess {
        void run() throws Exception;
    }

    private static class SendMachine {
        private int nextStep = 0;
        private int currentStep = 0;
        private boolean aborted = false;

        public SendMachine reset() {
            currentStep = 0;
            return this;
        }

        public SendMachine abort() {
            logger.debug("SendMachine: Aborted");
            aborted = true;
            return this;
        }

        public SendMachine next(SendMachineProcess process) throws Exception {
            if (aborted) {
                logger.debug("SendMachine: Ignore step, aborted");
                //do nothing
                return this;
            }

            if (nextStep == currentStep++) {
                try {
                    if (process != null) {
                        process.run();
                    }

                    nextStep++;
                } catch (Exception x) {
                    logger.error("SendMachine: Exception", x);
                    throw x;
                }
            }
            return this;
        }
    }

    @Override
    public MessageReceiver getMessageReceiver(AbstractMessageModel messageModel) throws ThreemaException {
        if (messageModel instanceof MessageModel) {
            return contactService.createReceiver(contactService.getByIdentity(messageModel.getIdentity()));
        } else if (messageModel instanceof GroupMessageModel) {
            return groupService.createReceiver(groupService.getById(((GroupMessageModel) messageModel).getGroupId()));
        } else if (messageModel instanceof DistributionListMessageModel) {
            DistributionListService ds = ServiceManager.require().getDistributionListService();
            if (ds != null) {
                return ds.createReceiver(ds.getById(((DistributionListMessageModel) messageModel).getDistributionListId()));
            }
        }
        throw new ThreemaException("No receiver for this message");
    }


    /******************************************************************************************************/

    public interface SendResultListener {
        void onError(String errorMessage);

        void onCompleted();
    }

    /**
     * Send media messages of any kind to an arbitrary number of receivers using a thread pool
     *
     * @param mediaItems       List of MediaItems to be sent
     * @param messageReceivers List of MessageReceivers
     */
    @AnyThread
    @Override
    public void sendMediaAsync(@NonNull List<MediaItem> mediaItems, @NonNull List<MessageReceiver> messageReceivers) {
        sendMediaAsync(mediaItems, messageReceivers, null);
    }

    /**
     * Send media messages of any kind to an arbitrary number of receivers using a thread pool
     *
     * @param mediaItems         List of MediaItems to be sent
     * @param messageReceivers   List of MessageReceivers
     * @param sendResultListener Listener to notify when messages are queued
     */
    @AnyThread
    @Override
    public void sendMediaAsync(
        @NonNull final List<MediaItem> mediaItems,
        @NonNull final List<MessageReceiver> messageReceivers,
        @Nullable final SendResultListener sendResultListener
    ) {
        ExecutorServices.getSendMessageExecutorService().submit(() -> {
            sendMedia(mediaItems, messageReceivers, sendResultListener);
        });
    }

    /**
     * Send media messages of any kind to an arbitrary number of receivers in a single thread i.e. one message after the other
     *
     * @param mediaItems       List of MediaItems to be sent
     * @param messageReceivers List of MessageReceivers
     */
    @AnyThread
    @Override
    public void sendMediaSingleThread(
        @NonNull final List<MediaItem> mediaItems,
        @NonNull final List<MessageReceiver> messageReceivers) {
        ExecutorServices.getSendMessageSingleThreadExecutorService().submit(() -> {
            sendMedia(mediaItems, messageReceivers, null);
        });
    }

    /**
     * Send media messages of any kind to an arbitrary number of receivers
     *
     * @param mediaItems         List of MediaItems to be sent
     * @param messageReceivers   List of MessageReceivers
     * @param sendResultListener Listener to notify when messages are queued
     * @return AbstractMessageModel of a successfully queued message, null if no message could be queued
     */
    @WorkerThread
    @Override
    public @Nullable AbstractMessageModel sendMedia(
        @NonNull final List<MediaItem> mediaItems,
        @NonNull final List<MessageReceiver> messageReceivers,
        @Nullable final SendResultListener sendResultListener
    ) {
        AbstractMessageModel successfulMessageModel = null;
        int failedCounter = 0;

        // resolve receivers to account for distribution lists
        final MessageReceiver[] resolvedReceivers = MessageUtil.addDistributionListReceivers(messageReceivers.toArray(new MessageReceiver[0]));

        logger.info("sendMedia: Sending {} items to {} receivers", mediaItems.size(), resolvedReceivers.length);

        String correlationId = getCorrelationId();

        for (MediaItem mediaItem : mediaItems) {
            logger.info("sendMedia: Now sending item of type {}", mediaItem.getType());
            if (TYPE_TEXT == mediaItem.getType()) {
                String text = mediaItem.getCaption();
                if (!isNullOrEmpty(text)) {
                    for (MessageReceiver messageReceiver : resolvedReceivers) {
                        try {
                            successfulMessageModel = sendText(text, messageReceiver);
                            if (successfulMessageModel != null) {
                                logger.info("Text successfully sent");
                            } else {
                                failedCounter++;
                                logger.info("Text send failed");
                            }
                        } catch (Exception e) {
                            failedCounter++;
                            logger.error("Could not send text message", e);
                        }
                    }
                } else {
                    failedCounter++;
                    logger.info("Text is empty");
                }
                continue;
            } else if (TYPE_LOCATION == mediaItem.getType()) {
                Location location = GeoLocationUtil.getLocationFromUri(mediaItem.getUri());
                if (location != null) {
                    for (MessageReceiver messageReceiver : resolvedReceivers) {
                        try {
                            successfulMessageModel = sendLocation(location, "", messageReceiver, null);
                        } catch (Exception e) {
                            failedCounter++;
                            logger.error("Could not send location message");
                        }
                    }
                } else {
                    failedCounter++;
                    logger.info("Sending location failed: invalid location");
                }
                continue;
            }

            final Map<MessageReceiver, AbstractMessageModel> messageModels = new HashMap<>();

            final FileDataModel fileDataModel = createFileDataModel(context, mediaItem);
            if (fileDataModel == null) {
                logger.info("Unable to create FileDataModel");
                failedCounter++;
                continue;
            }

            if (!createFileMessagesAndSetPending(correlationId, mediaItem, resolvedReceivers, messageModels, fileDataModel)) {
                logger.info("Unable to create messages");
                failedCounter++;
                continue;
            }

            if (!allChatsArePrivate(resolvedReceivers)) {
                saveToGallery(mediaItem);
            }

            try {
                final Map<String, Object> metaData = new HashMap<>();
                final byte[] contentData = generateContentData(mediaItem, resolvedReceivers, messageModels, fileDataModel, metaData);
                final byte[] thumbnailData = generateThumbnailData(mediaItem, fileDataModel, metaData);
                fileDataModel.setMetaData(metaData);

                if (thumbnailData != null) {
                    writeThumbnails(messageModels, resolvedReceivers, thumbnailData);
                } else {
                    logger.info("Unable to generate thumbnails");
                }

                if (contentData != null) {
                    if (encryptAndSend(resolvedReceivers, messageModels, fileDataModel, thumbnailData, contentData)) {
                        successfulMessageModel = messageModels.get(resolvedReceivers[0]);
                    } else {
                        throw new ThreemaException("Error encrypting and sending");
                    }
                } else {
                    logger.info("Error encrypting and sending");
                    failedCounter++;
                    markAsTerminallyFailed(resolvedReceivers, messageModels);
                }
            } catch (ThreemaException e) {
                if (e instanceof TranscodeCanceledException) {
                    logger.info("Video transcoding canceled");
                    // canceling is not really a failure
                } else {
                    logger.error("Exception", e);
                    failedCounter++;
                }
                markAsTerminallyFailed(resolvedReceivers, messageModels);
            }
        }

        if (failedCounter == 0) {
            logger.info("sendMedia: Successfully queued.");
            if (sendResultListener != null) {
                sendResultListener.onCompleted();
            }
        } else {
            logger.error("sendMedia: Did not complete successfully, failedCounter={}", failedCounter);
            final String errorString = context.getString(R.string.an_error_occurred_during_send);
            RuntimeUtil.runOnUiThread(() -> Toast.makeText(context, errorString, Toast.LENGTH_LONG).show());
            if (sendResultListener != null) {
                sendResultListener.onError(errorString);
            }
        }
        return successfulMessageModel;
    }

    /**
     * Write thumbnails to local storage
     */
    private void writeThumbnails(Map<MessageReceiver, AbstractMessageModel> messageModels, MessageReceiver[] resolvedReceivers, byte[] thumbnailData) {
        for (MessageReceiver messageReceiver : resolvedReceivers) {
            if (thumbnailData != null) {
                try {
                    var messageModel = messageModels.get(messageReceiver);
                    if (messageModel != null) {
                        fileService.writeConversationMediaThumbnail(messageModel, thumbnailData);
                        fireOnUpdatedMessage(messageModels.get(messageReceiver));
                    }
                } catch (Exception ignored) {
                    // having no thumbnail is not really fatal
                }
            }
        }
    }

    /**
     * Generate content data for this MediaItem
     *
     * @return content data as a byte array or null if content data could not be generated
     */
    @WorkerThread
    private @Nullable byte[] generateContentData(
        @NonNull MediaItem mediaItem,
        @NonNull MessageReceiver[] resolvedReceivers,
        @NonNull Map<MessageReceiver, AbstractMessageModel> messageModels,
        @NonNull FileDataModel fileDataModel,
        @NonNull Map<String, Object> metaData
    ) throws ThreemaException {
        switch (mediaItem.getType()) {
            case TYPE_VIDEO:
                // fallthrough
            case TYPE_VIDEO_CAM:
                @VideoTranscoder.TranscoderResult int result = transcodeVideo(mediaItem, resolvedReceivers, messageModels);
                if (result == VideoTranscoder.SUCCESS) {
                    return getContentData(mediaItem);
                } else if (result == VideoTranscoder.CANCELED) {
                    throw new TranscodeCanceledException();
                }
                break;
            case TYPE_IMAGE:
                // scale and rotate / flip images
                int maxSize = ConfigUtils.getPreferredImageDimensions(mediaItem.getImageScale() == IMAGE_SCALE_DEFAULT ?
                    preferenceService.getImageScale() : mediaItem.getImageScale());

                Bitmap bitmap = null;
                try {
                    boolean hasNoTransparency = MimeUtil.MIME_TYPE_IMAGE_JPEG.equals(mediaItem.getMimeType());
                    bitmap = BitmapUtil.safeGetBitmapFromUri(context, mediaItem.getUri(), maxSize, false, false, false);
                    if (bitmap != null) {
                        bitmap = adjustBitmapOrientation(bitmap, mediaItem, metaData);

                        final byte[] imageByteArray;
                        if (hasNoTransparency) {
                            imageByteArray = BitmapUtil.getJpegByteArray(bitmap, mediaItem.getRotation(), mediaItem.getFlip());
                        } else {
                            imageByteArray = BitmapUtil.getPngByteArray(bitmap, mediaItem.getRotation(), mediaItem.getFlip());

                            if (!MimeUtil.MIME_TYPE_IMAGE_PNG.equals(mediaItem.getMimeType())) {
                                fileDataModel.setMimeType(MimeUtil.MIME_TYPE_IMAGE_PNG);

                                if (fileDataModel.getFileName() != null) {
                                    int dot = fileDataModel.getFileName().lastIndexOf(".");
                                    if (dot > 1) {
                                        String filenamePart = fileDataModel.getFileName().substring(0, dot);
                                        fileDataModel.setFileName(filenamePart + ".png");
                                    }
                                }
                            }
                        }
                        if (imageByteArray != null) {
                            fileDataModel.setFileSize(imageByteArray.length);
                            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
                            outputStream.write(new byte[NaCl.BOX_OVERHEAD_BYTES]);
                            outputStream.write(imageByteArray);

                            return outputStream.toByteArray();
                        }
                    }
                } catch (Exception e) {
                    logger.error("Exception", e);
                } finally {
                    if (bitmap != null && !bitmap.isRecycled()) {
                        bitmap.recycle();
                    }
                }
                break;
            case TYPE_IMAGE_CAM:
                // cam images will always be sent in their original size. no scaling needed but possibly rotate and flip
                try (InputStream inputStream = getFromUri(context, mediaItem.getUri())) {
                    if (inputStream != null && inputStream.available() > 0) {
                        bitmap = BitmapFactory.decodeStream(new BufferedInputStream(inputStream), null, null);
                        if (bitmap != null) {
                            bitmap = adjustBitmapOrientation(bitmap, mediaItem, metaData);

                            final byte[] imageByteArray = BitmapUtil.getJpegByteArray(bitmap, mediaItem.getRotation(), mediaItem.getFlip());
                            if (imageByteArray != null) {
                                ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
                                outputStream.write(new byte[NaCl.BOX_OVERHEAD_BYTES]);
                                outputStream.write(imageByteArray);

                                return outputStream.toByteArray();
                            }
                        }
                    }
                } catch (Exception e) {
                    logger.error("Exception", e);
                }
                break;
            case TYPE_IMAGE_ANIMATED:
                metaData.put(FileDataModel.METADATA_KEY_ANIMATED, true);
                // fallthrough
            case TYPE_VOICEMESSAGE:
                // fallthrough
            case TYPE_FILE:
                // "regular" file messages
                return getContentData(mediaItem);
            default:
                // media type currently not supported
                break;
        }
        return null;
    }

    /**
     * Rotate/flip bitmap according to exif information and add final dimensions to the file message's meta data also keeping in
     * account local orientation (if any)
     *
     * @param bitmap    The Bitmap
     * @param mediaItem The MediaItem instance that contains orientation info about this particular item
     * @param metaData  A map with meta data that is going to be added to a file message
     * @return A new bitmap with adjusted orientation
     */
    @NonNull
    private Bitmap adjustBitmapOrientation(
        @NonNull Bitmap bitmap,
        @NonNull MediaItem mediaItem,
        @NonNull Map<String, Object> metaData
    ) {
        bitmap = BitmapUtil.rotateBitmap(
            bitmap,
            mediaItem.getExifRotation(),
            mediaItem.getExifFlip());

        boolean isRotated = mediaItem.getRotation() == 90 || mediaItem.getRotation() == 270;
        metaData.put(FileDataModel.METADATA_KEY_WIDTH, isRotated ? bitmap.getHeight() : bitmap.getWidth());
        metaData.put(FileDataModel.METADATA_KEY_HEIGHT, isRotated ? bitmap.getWidth() : bitmap.getHeight());

        return bitmap;
    }

    /**
     * Generate thumbnail data for this MediaItem
     *
     * @return byte array of the thumbnail bitmap, null if thumbnail could not be generated
     */
    @WorkerThread
    private @Nullable byte[] generateThumbnailData(
        @NonNull MediaItem mediaItem,
        @NonNull FileDataModel fileDataModel,
        @NonNull Map<String, Object> metaData
    ) {
        Bitmap thumbnailBitmap = null;

        int mediaType = mediaItem.getType();

        // we want thumbnails for images and videos even if they are to be sent as files
        if (MimeUtil.isSupportedImageFile(fileDataModel.getMimeType())) {
            mediaType = TYPE_IMAGE;
        } else if (MimeUtil.isVideoFile(fileDataModel.getMimeType())) {
            mediaType = TYPE_VIDEO;
        }

        switch (mediaType) {
            case MediaItem.TYPE_VIDEO:
                // fallthrough
            case MediaItem.TYPE_VIDEO_CAM:
                // add duration to metadata
                long trimmedDuration = mediaItem.getDurationMs();
                if (mediaItem.getEndTimeMs() != TIME_UNDEFINED && (mediaItem.getEndTimeMs() != 0L || mediaItem.getStartTimeMs() != 0L)) {
                    trimmedDuration = mediaItem.getEndTimeMs() - mediaItem.getStartTimeMs();
                } else {
                    if (mediaItem.getDurationMs() == 0) {
                        // empty duration means full video
                        trimmedDuration = VideoUtil.getVideoDuration(context, mediaItem.getUri());
                        mediaItem.setDurationMs(trimmedDuration);
                    }
                }
                metaData.put(FileDataModel.METADATA_KEY_DURATION, (float) trimmedDuration / (float) DateUtils.SECOND_IN_MILLIS);
                thumbnailBitmap = IconUtil.getVideoThumbnailFromUri(context, mediaItem);
                fileDataModel.setThumbnailMimeType(MimeUtil.MIME_TYPE_IMAGE_JPEG);
                break;
            case MediaItem.TYPE_IMAGE:
                BitmapUtil.ExifOrientation exifOrientation = BitmapUtil.getExifOrientation(context, mediaItem.getUri());
                mediaItem.setExifRotation((int) exifOrientation.getRotation());
                mediaItem.setExifFlip(exifOrientation.getFlip());
                boolean hasNoTransparency = MimeUtil.MIME_TYPE_IMAGE_JPEG.equals(mediaItem.getMimeType());
                if (hasNoTransparency && mediaItem.getRenderingType() != RENDERING_STICKER) {
                    fileDataModel.setThumbnailMimeType(MimeUtil.MIME_TYPE_IMAGE_JPEG);
                } else {
                    fileDataModel.setThumbnailMimeType(MimeUtil.MIME_TYPE_IMAGE_PNG);
                }
                thumbnailBitmap = BitmapUtil.safeGetBitmapFromUri(context, mediaItem.getUri(), THUMBNAIL_SIZE_PX, false, true, false);
                if (thumbnailBitmap != null) {
                    thumbnailBitmap = BitmapUtil.rotateBitmap(BitmapUtil.rotateBitmap(
                        thumbnailBitmap,
                        mediaItem.getExifRotation(),
                        mediaItem.getExifFlip()), mediaItem.getRotation(), mediaItem.getFlip());
                }
                break;
            case MediaItem.TYPE_IMAGE_CAM:
                // camera images are always sent as JPGs
                fileDataModel.setThumbnailMimeType(MimeUtil.MIME_TYPE_IMAGE_JPEG);
                thumbnailBitmap = BitmapUtil.safeGetBitmapFromUri(context, mediaItem.getUri(), THUMBNAIL_SIZE_PX, false, true, false);
                if (thumbnailBitmap != null) {
                    thumbnailBitmap = BitmapUtil.rotateBitmap(BitmapUtil.rotateBitmap(
                        thumbnailBitmap,
                        mediaItem.getExifRotation(),
                        mediaItem.getExifFlip()), mediaItem.getRotation(), mediaItem.getFlip());
                }
                break;
            case TYPE_IMAGE_ANIMATED:
                fileDataModel.setThumbnailMimeType(MimeUtil.MIME_TYPE_IMAGE_PNG);
                thumbnailBitmap = IconUtil.getThumbnailFromUri(context, mediaItem.getUri(), THUMBNAIL_SIZE_PX, fileDataModel.getMimeType(), true);
                break;
            case MediaItem.TYPE_VOICEMESSAGE:
                metaData.put(FileDataModel.METADATA_KEY_DURATION, (float) mediaItem.getDurationMs() / (float) DateUtils.SECOND_IN_MILLIS);
                // voice messages do not have thumbnails
                thumbnailBitmap = null;
                break;
            case MediaItem.TYPE_FILE:
                // just an arbitrary file
                thumbnailBitmap = null;
                break;
            default:
                break;
        }

        final byte[] thumbnailData;
        if (thumbnailBitmap != null) {
            // convert bitmap to byte array
            if (MimeUtil.MIME_TYPE_IMAGE_JPEG.equals(fileDataModel.getThumbnailMimeType())) {
                thumbnailData = BitmapUtil.bitmapToJpegByteArray(thumbnailBitmap);
                fileDataModel.setThumbnailMimeType(MimeUtil.MIME_TYPE_IMAGE_JPEG);
            } else {
                thumbnailData = BitmapUtil.bitmapToPngByteArray(thumbnailBitmap);
                fileDataModel.setThumbnailMimeType(MimeUtil.MIME_TYPE_IMAGE_PNG);
            }
            thumbnailBitmap.recycle();
        } else {
            thumbnailData = null;
        }
        return thumbnailData;
    }

    /**
     * Encrypt content and thumbnail data, upload blobs and queue messages for the specified MediaItem
     *
     * @param resolvedReceivers MessageReceivers to send the MediaItem to
     * @param messageModels     MessageModels for above MessageReceivers
     * @param fileDataModel     fileDataModel for this message
     * @param thumbnailData     Byte Array of thumbnail bitmap to be uploaded as a blob
     * @param contentData       Byte Array of Content to be uploaded as a blob
     * @return true if the message was queued successfully, false otherwise. Note that errors that occur during sending are not handled here.
     */
    @WorkerThread
    private boolean encryptAndSend(
        @NonNull MessageReceiver<AbstractMessageModel>[] resolvedReceivers,
        @NonNull Map<MessageReceiver, AbstractMessageModel> messageModels,
        @NonNull FileDataModel fileDataModel,
        @Nullable byte[] thumbnailData,
        @NonNull byte[] contentData
    ) {
        final SymmetricEncryptionResult[] contentEncryptResult = new SymmetricEncryptionResult[1];
        final SymmetricEncryptionResult[] thumbnailEncryptResult = new SymmetricEncryptionResult[1];
        thumbnailEncryptResult[0] = null;
        contentEncryptResult[0] = null;

        for (MessageReceiver messageReceiver : resolvedReceivers) {
            // save content first as it will be modified later on
            AbstractMessageModel messageModel = messageModels.get(messageReceiver);
            if (messageModel == null) {
                // no messagemodel has been created for this receiver - skip
                continue;
            }

            if (messageReceiver instanceof GroupMessageReceiver
                && groupService.isNotesGroup(((GroupMessageReceiver) messageReceiver).getGroup())
            ) {
                // In case of a notes group, we set the message state directly to read
                messageModel.setState(MessageState.READ);
            } else {
                // Otherwise we initialize the message model with pending to show a progress bar
                messageModel.setState(MessageState.PENDING); // shows a progress bar
            }
            save(messageModel);

            try {
                var messageUid = messageModel.getUid();
                if (messageUid != null) {
                    fileService.writeConversationMedia(messageUid, contentData, NaCl.BOX_OVERHEAD_BYTES, contentData.length - NaCl.BOX_OVERHEAD_BYTES);
                }
            } catch (Exception e) {
                // Failure to write local media is not necessarily fatal, continue
                logger.debug("Exception", e);
            }
        }

        for (MessageReceiver<AbstractMessageModel> messageReceiver : resolvedReceivers) {
            //enqueue processing and uploading stuff...
            AbstractMessageModel messageModel = messageModels.get(messageReceiver);
            if (messageModel == null) {
                // no messagemodel has been created for this receiver - skip
                logger.info("Mo MessageModel could be created for this receiver - skip");
                continue;
            }

            messageSendingService.addToQueue(new MessageSendingService.MessageSendingProcess() {
                private byte[] thumbnailBlobId;
                private byte[] contentBlobId;

                public boolean success = false;

                @Override
                public MessageReceiver<AbstractMessageModel> getReceiver() {
                    return messageReceiver;
                }

                @Override
                public AbstractMessageModel getMessageModel() {
                    return messageModel;
                }

                @Override
                public boolean send() throws Exception {
                    SendMachine sendMachine = getSendMachine(messageModel);
                    sendMachine.reset()
                        .next(() -> {
                            boolean hasChanges = false;
                            if (getReceiver().shouldSendMediaData()) {
                                // encrypt file data
                                // note that encryptFileData() will overwrite contents of provided content data!
                                if (contentEncryptResult[0] == null) {
                                    contentEncryptResult[0] = symmetricEncryptionService.encryptInplace(contentData, ProtocolDefines.FILE_NONCE);
                                    if (contentEncryptResult[0].isEmpty()) {
                                        throw new ThreemaException("File data encrypt failed");
                                    }
                                }
                                messageModel.setState(MessageState.UPLOADING);
                                hasChanges = true;
                            }
                            if (hasChanges) {
                                save(messageModel);
                            }
                            fileDataModel.setFileSize(contentData.length - NaCl.BOX_OVERHEAD_BYTES);
                            messageModel.setFileData(fileDataModel);
                            fireOnUpdatedMessage(messageModel);
                        })
                        .next(() -> {
                            if (getReceiver().shouldSendMediaData()) {
                                // upload file data
                                BlobUploader blobUploader = initUploader(
                                    getMessageModel(),
                                    contentEncryptResult[0].data,
                                    getReceiver()
                                );
                                blobUploader.progressListener = new ProgressListener() {
                                    @Override
                                    public void onFinished(boolean success) {
                                        setMessageLoadingFinished(messageModel);
                                    }
                                };
                                contentBlobId = blobUploader.upload();
                            }
                        })
                        .next(() -> {
                            if (getReceiver().shouldSendMediaData()) {
                                // encrypt and upload thumbnail
                                if (thumbnailData != null) {
                                    thumbnailEncryptResult[0] = symmetricEncryptionService
                                        .encrypt(thumbnailData, contentEncryptResult[0].key, ProtocolDefines.FILE_THUMBNAIL_NONCE);

                                    if (thumbnailEncryptResult[0].isEmpty()) {
                                        throw new ThreemaException("Thumbnail encrypt failed");
                                    } else {
                                        BlobUploader blobUploader = initUploader(
                                            getMessageModel(),
                                            thumbnailEncryptResult[0].data,
                                            getReceiver()
                                        );
                                        blobUploader.progressListener = new ProgressListener() {
                                            @Override
                                            public void onFinished(boolean success) {
                                                setMessageLoadingFinished(messageModel);
                                            }
                                        };
                                        thumbnailBlobId = blobUploader.upload();
                                        fireOnUpdatedMessage(messageModel);
                                    }
                                }
                            }
                        })
                        .next(() -> {
                            getReceiver().createAndSendFileMessage(
                                thumbnailBlobId,
                                contentBlobId,
                                contentEncryptResult[0],
                                messageModel,
                                null
                            );
                            updateOutgoingMessageState(messageModel,
                                getReceiver().shouldSendMediaData() && getReceiver().offerRetry() ?
                                    MessageState.SENDING :
                                    MessageState.SENT, Instant.now());

                            messageModel.setFileData(fileDataModel);
                            // save updated model
                            save(messageModel);
                        })
                        .next(() -> {
                            messageModel.setSaved(true);
                            // Verify current saved state
                            updateOutgoingMessageState(messageModel,
                                getReceiver().shouldSendMediaData() && getReceiver().offerRetry() ?
                                    MessageState.SENDING :
                                    MessageState.SENT, Instant.now());

                            if (!getReceiver().shouldSendMediaData()) {
                                // update status for message that stay local
                                fireOnUpdatedMessage(messageModel);
                            }
                            success = true;
                        });

                    if (success) {
                        removeSendMachine(sendMachine);
                    }
                    return success;
                }
            });
        }
        return true;
    }

    /**
     * Create MessageModels for all receivers, save local thumbnail and set MessageModels to PENDING for instant UI feedback
     *
     * @return true if all was hunky dory, false if an error occurred
     */
    @WorkerThread
    private boolean createFileMessagesAndSetPending(
        String correlationId,
        MediaItem mediaItem,
        MessageReceiver[] resolvedReceivers,
        Map<MessageReceiver, AbstractMessageModel> messageModels,
        FileDataModel fileDataModel
    ) {
        for (MessageReceiver messageReceiver : resolvedReceivers) {

            final AbstractMessageModel messageModel = messageReceiver.createLocalModel(MessageType.FILE, MimeUtil.getContentTypeFromFileData(fileDataModel), Instant.now());
            cache(messageModel);

            messageModel.setOutbox(true);
            messageModel.setState(MessageState.PENDING); // shows a progress bar
            messageModel.setFileData(fileDataModel);
            messageModel.setCorrelationId(correlationId);
            String trimmedCaption = mediaItem.getTrimmedCaption();
            if (trimmedCaption != null && !trimmedCaption.isBlank()) {
                messageModel.setCaption(trimmedCaption);
            }
            messageModel.setSaved(true);

            messageReceiver.saveLocalModel(messageModel);

            messageReceiver.bumpLastUpdate();

            messageModels.put(messageReceiver, messageModel);

            fireOnNewMessage(messageModel);
        }
        return true;
    }

    @SuppressLint("Range")
    public @Nullable FileDataModel createFileDataModel(Context context, MediaItem mediaItem) {
        ContentResolver contentResolver = context.getContentResolver();
        String mimeType = mediaItem.getMimeType();
        String filename = mediaItem.getFilename();

        if (mediaItem.getUri() == null) {
            return null;
        }

        if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(mediaItem.getUri().getScheme())) {
            if (isNullOrEmpty(filename)) {
                File file = new File(mediaItem.getUri().getPath());

                filename = file.getName();
            }
        } else {
            if (isNullOrEmpty(filename) || isNullOrEmpty(mimeType)) {
                String[] proj = {
                    DocumentsContract.Document.COLUMN_DISPLAY_NAME,
                    DocumentsContract.Document.COLUMN_MIME_TYPE
                };

                try (Cursor cursor = contentResolver.query(mediaItem.getUri(), proj, null, null, null)) {
                    if (cursor != null && cursor.moveToFirst()) {
                        if (isNullOrEmpty(filename)) {
                            filename = cursor.getString(
                                cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME));
                        }
                        if (isNullOrEmpty(mimeType) || MimeUtil.MIME_TYPE_DEFAULT.equals(mimeType)) {
                            mimeType = cursor.getString(
                                cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE));
                        }
                    }
                } catch (Exception e) {
                    logger.error("Unable to query content provider", e);
                }
            }
        }

        if (isNullOrEmpty(mimeType) || MimeUtil.MIME_TYPE_DEFAULT.equals(mimeType)) {
            mimeType = FileUtil.getMimeTypeFromUri(context, mediaItem.getUri());
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            // non-animated images are being sent as png files
            // we should fix the mime type before creating a local message model in order not to confuse the chat adapter
            if (MimeUtil.isAnimatedImageFormat(mimeType)
                && mediaItem.getType() != TYPE_IMAGE_ANIMATED
                && mediaItem.getType() != TYPE_FILE
                && mediaItem.getImageScale() != PreferenceService.IMAGE_SCALE_SEND_AS_FILE) {
                mimeType = MimeUtil.MIME_TYPE_IMAGE_PNG;
            }
        }

        @FileData.RenderingType int renderingType = mediaItem.getRenderingType();

        // rendering type overrides
        switch (mediaItem.getType()) {
            case TYPE_VOICEMESSAGE:
                filename = FileUtil.getDefaultFilename(mimeType); // the internal temporary file name is of no use to the recipient
                renderingType = FileData.RENDERING_MEDIA;
                break;
            case TYPE_IMAGE_ANIMATED:
                if (renderingType == FileData.RENDERING_DEFAULT) {
                    // do not override stickers
                    renderingType = FileData.RENDERING_MEDIA;
                }
                break;
            case TYPE_FILE:
                // "regular" file messages
                renderingType = FileData.RENDERING_DEFAULT;
                break;
            case TYPE_VIDEO:
                if (renderingType == FileData.RENDERING_MEDIA) {
                    // videos in formats other than MP4 are always transcoded and result in an MP4 file
                    mimeType = MimeUtil.MIME_TYPE_VIDEO_MP4;
                }
                // fallthrough
            default:
                if (mediaItem.getImageScale() == PreferenceService.IMAGE_SCALE_SEND_AS_FILE || mediaItem.getVideoSize() == PreferenceService.VIDEO_SIZE_SEND_AS_FILE) {
                    // images with scale type "send as file" get the default rendering type and a file name
                    renderingType = FileData.RENDERING_DEFAULT;
                    mediaItem.setType(TYPE_FILE);
                } else {
                    // unlike with "real" files we override the filename for regular (RENDERING_MEDIA) images and videos with a generic one to prevent privacy leaks
                    // this mimics the behavior of traditional image messages that did not have a filename at all
                    filename = FileUtil.getDefaultFilename(mimeType);
                }
                break;
        }

        if (isNullOrEmpty(filename)) {
            filename = FileUtil.getDefaultFilename(mimeType);
        }

        String caption = mediaItem.getTrimmedCaption();
        if (caption != null && caption.isBlank()) {
            caption = null;
        }

        return new FileDataModel(mimeType,
            null,
            0,
            filename,
            renderingType,
            caption,
            true,
            null);
    }

    /**
     * Transcode and trim this video according to the parameters set in the MediaItem object
     *
     * @return Result of transcoding
     */
    @WorkerThread
    private @VideoTranscoder.TranscoderResult int transcodeVideo(MediaItem mediaItem, MessageReceiver[] resolvedReceivers, Map<MessageReceiver, AbstractMessageModel> messageModels) {
        var serviceManager = ServiceManager.get();
        if (serviceManager == null) {
            return VideoTranscoder.FAILURE;
        }
        final MessagePlayerService messagePlayerService = serviceManager.getMessagePlayerService();

        int targetBitrate;
        @PreferenceService.VideoSize int desiredVideoSize = preferenceService.getVideoSize();
        if (mediaItem.getVideoSize() != PreferenceService.VIDEO_SIZE_DEFAULT) {
            desiredVideoSize = mediaItem.getVideoSize();
        }

        try {
            targetBitrate = VideoConfig.getTargetVideoBitrate(context, mediaItem, desiredVideoSize);
        } catch (ThreemaException e) {
            logger.error("Error getting target bitrate", e);
            // skip this MediaItem
            markAsTerminallyFailed(resolvedReceivers, messageModels);
            return VideoTranscoder.FAILURE;
        }

        if (targetBitrate == -1) {
            // will not fit
            logger.info("Video file ist too large");
            RuntimeUtil.runOnUiThread(() -> Toast.makeText(context, context.getString(R.string.file_too_large, MAX_BLOB_SIZE_MB), Toast.LENGTH_SHORT).show());
            // skip this MediaItem
            markAsTerminallyFailed(resolvedReceivers, messageModels);
            return VideoTranscoder.FAILURE;
        }

        logger.info("Target bitrate = {}", targetBitrate);

        if (mediaItem.hasChanges() ||
            targetBitrate > 0 ||
            !MimeUtil.MIME_TYPE_VIDEO_MP4.equalsIgnoreCase(mediaItem.getMimeType())) {

            logger.info("Video needs transcoding");

            // set models to TRANSCODING state
            for (Map.Entry<MessageReceiver, AbstractMessageModel> entry : messageModels.entrySet()) {
                AbstractMessageModel messageModel = entry.getValue();
                messageModel.setState(MessageState.TRANSCODING);
                save(messageModel);
                fireOnUpdatedMessage(messageModel);
            }

            File outputFile;
            try {
                outputFile = fileService.createTempFile(".trans", ".mp4");
            } catch (IOException e) {
                logger.error("Unable to open temp file");
                // skip this MediaItem
                markAsTerminallyFailed(resolvedReceivers, messageModels);
                return VideoTranscoder.FAILURE;
            }

            final VideoTranscoder.Builder transcoderBuilder = new VideoTranscoder.Builder(mediaItem.getUri(), outputFile);
            transcoderBuilder.includeAudio(!mediaItem.isMuted());

            if (mediaItem.needsTrimming()) {
                transcoderBuilder.trim(mediaItem.getStartTimeMs(), mediaItem.getEndTimeMs());
            }

            if (targetBitrate > 0) {
                int maxSize = VideoConfig.getMaxSizeFromBitrate(targetBitrate);
                transcoderBuilder.maxFrameHeight(maxSize);
                transcoderBuilder.maxFrameWidth(maxSize);
                transcoderBuilder.videoBitRate(targetBitrate);
                transcoderBuilder.iFrameInterval(2);
                transcoderBuilder.frameRate(25);
            }

            final VideoTranscoder videoTranscoder = transcoderBuilder.build(context);

            synchronized (videoTranscoders) {
                for (Map.Entry<MessageReceiver, AbstractMessageModel> entry : messageModels.entrySet()) {
                    AbstractMessageModel messageModel = entry.getValue();
                    String key = cancelTranscoding(messageModel);
                    videoTranscoders.put(key, new WeakReference<>(videoTranscoder));
                }
            }

            final @VideoTranscoder.TranscoderResult int transcoderResult = videoTranscoder.startSync(new VideoTranscoder.Listener() {
                @Override
                public void onStart() {
                    for (Map.Entry<MessageReceiver, AbstractMessageModel> entry : messageModels.entrySet()) {
                        AbstractMessageModel messageModel = entry.getValue();
                        messagePlayerService.setTranscodeStart(messageModel);
                    }
                }

                @Override
                public void onProgress(int progress) {
                    for (Map.Entry<MessageReceiver, AbstractMessageModel> entry : messageModels.entrySet()) {
                        AbstractMessageModel messageModel = entry.getValue();
                        messagePlayerService.setTranscodeProgress(messageModel, progress);
                    }
                }

                @Override
                public void onCanceled() {
                    for (Map.Entry<MessageReceiver, AbstractMessageModel> entry : messageModels.entrySet()) {
                        AbstractMessageModel messageModel = entry.getValue();
                        messagePlayerService.setTranscodeFinished(messageModel, true, null);
                    }
                }

                @Override
                public void onSuccess(VideoTranscoder.Stats stats) {
                    if (stats != null) {
                        logger.debug("success, stats = {}", stats);
                    }
                    for (Map.Entry<MessageReceiver, AbstractMessageModel> entry : messageModels.entrySet()) {
                        AbstractMessageModel messageModel = entry.getValue();
                        messagePlayerService.setTranscodeFinished(messageModel, true, null);
                    }
                }

                @Override
                public void onFailure() {
                    for (Map.Entry<MessageReceiver, AbstractMessageModel> entry : messageModels.entrySet()) {
                        AbstractMessageModel messageModel = entry.getValue();
                        messagePlayerService.setTranscodeFinished(messageModel, false, "Failure");
                    }
                }
            });

            if (transcoderResult != VideoTranscoder.SUCCESS) {
                // failure
                logger.info("Transcoding failure");
                return transcoderResult;
            }

            if (videoTranscoder.hasAudioTranscodingError()) {
                final int errorMessageResource;
                if (videoTranscoder.audioFormatUnsupported()) {
                    errorMessageResource = R.string.transcoder_unsupported_audio_format;
                } else {
                    errorMessageResource = R.string.transcoder_unknown_audio_error;
                }

                RuntimeUtil.runOnUiThread(() -> Toast.makeText(
                    ThreemaApplication.getAppContext(),
                    context.getString(errorMessageResource),
                    Toast.LENGTH_LONG
                ).show());
            }

            // remove original file and set transcoded file as new source file
            deleteTemporaryFile(mediaItem);
            mediaItem.setUri(Uri.fromFile(outputFile));
            mediaItem.setMimeType(MimeUtil.MIME_TYPE_VIDEO_MP4);
        } else {
            logger.info("No transcoding necessary");
        }
        return VideoTranscoder.SUCCESS;
    }

    /**
     * Generate a random correlation ID that identifies all media sent in one batch
     *
     * @return correlation Id
     */
    @Override
    public String getCorrelationId() {
        final byte[] random = generateRandomBytes(secureRandom(), 16);
        return toHexString(random);
    }

    @WorkerThread
    private void deleteTemporaryFile(MediaItem mediaItem) {
        if (mediaItem.getDeleteAfterUse()) {
            if (mediaItem.getUri() != null && ContentResolver.SCHEME_FILE.equalsIgnoreCase(mediaItem.getUri().getScheme())) {
                if (mediaItem.getUri().getPath() != null) {
                    FileUtil.deleteFileOrWarn(new File(mediaItem.getUri().getPath()), null, logger);
                }
            }
        }
    }

    /**
     * Check if all chats in the supplied list of MessageReceivers are set to "hidden"
     *
     * @return true if all chats are hidden (i.e. marked as "private"), false if there is at least one chat that is always visible
     */
    private boolean allChatsArePrivate(@NonNull MessageReceiver[] messageReceivers) {
        for (MessageReceiver messageReceiver : messageReceivers) {
            if (!conversationCategoryService.isMarkedAsPrivate(messageReceiver.getConversationId())) {
                return false;
            }
        }
        return true;
    }

    /**
     * Delete message models for specified receivers
     */
    private void markAsTerminallyFailed(
        MessageReceiver<AbstractMessageModel>[] resolvedReceivers,
        Map<MessageReceiver, AbstractMessageModel> messageModels
    ) {
        for (MessageReceiver messageReceiver : resolvedReceivers) {
            remove(messageModels.get(messageReceiver));
        }
    }

    /**
     * Get a byte array for the media represented by the MediaItem leaving room for NaCl Box header
     *
     * @param mediaItem MediaItem containing the Uri of the media
     * @return byte array of the media data or null if error occured
     */
    @WorkerThread
    private byte[] getContentData(MediaItem mediaItem) {
        try (InputStream inputStream = getFromUri(context, mediaItem.getUri())) {
            if (inputStream != null) {
                int fileLength = inputStream.available();

                if (fileLength > MAX_BLOB_SIZE) {
                    String errorMessage = context.getString(R.string.file_too_large, MAX_BLOB_SIZE_MB);
                    logger.info(errorMessage);
                    RuntimeUtil.runOnUiThread(() -> Toast.makeText(ThreemaApplication.getAppContext(), errorMessage, Toast.LENGTH_LONG).show());
                    return null;
                }

                if (fileLength == 0) {
                    // InputStream may not provide size
                    fileLength = MAX_BLOB_SIZE + 1;
                }

                if (ConfigUtils.checkAvailableMemory(fileLength + NaCl.BOX_OVERHEAD_BYTES)) {
                    byte[] fileData = new byte[fileLength + NaCl.BOX_OVERHEAD_BYTES];

                    try {
                        int readCount = 0;
                        try {
                            readCount = copyTo(inputStream, fileData, NaCl.BOX_OVERHEAD_BYTES, fileLength);
                        } catch (Exception e) {
                            // it's OK to get an EOF
                        }

                        if (readCount > MAX_BLOB_SIZE) {
                            String errorMessage = context.getString(R.string.file_too_large, MAX_BLOB_SIZE_MB);
                            logger.info(errorMessage);
                            RuntimeUtil.runOnUiThread(() -> Toast.makeText(ThreemaApplication.getAppContext(), errorMessage, Toast.LENGTH_LONG).show());
                            return null;
                        }

                        if (readCount < fileLength) {
                            return Arrays.copyOf(fileData, readCount + NaCl.BOX_OVERHEAD_BYTES);
                        }

                        return fileData;
                    } catch (OutOfMemoryError e) {
                        logger.error("Unable to create byte array", e);
                    }
                } else {
                    logger.info("Not enough memory to create byte array.");
                }
            } else {
                logger.info("Not enough memory to create byte array.");
            }
        } catch (IOException e) {
            logger.error("Unable to open file to send", e);
        }
        return null;
    }

    /**
     * Save outgoing media item recorded from within the app to gallery if enabled
     */
    @WorkerThread
    private void saveToGallery(MediaItem item) {
        var type = item.getType();
        if ((type == MediaItem.TYPE_IMAGE_CAM || type == MediaItem.TYPE_VIDEO_CAM) && preferenceService.isSaveMedia()) {
            try {
                var mimeType = type == TYPE_VIDEO_CAM
                    ? MimeUtil.MIME_TYPE_VIDEO_MPEG
 : MimeUtil.MIME_TYPE_IMAGE_JPEG;
                fileService.copyDecryptedFileToGallery(item.getUri(), mimeType);
            } catch (Exception e) {
                logger.error("Failed to save media to gallery", e);
            }
        }
    }

    /**
     * @param message the text message user input
     * @return trimmed message
     */
    @NonNull
    private String validateTextMessage(@NonNull String message) throws ThreemaException {
        // Strip leading/trailing whitespace and throw if nothing is left
        String trimmedMessage = message.trim();

        if (trimmedMessage.isEmpty()) {
            throw new ThreemaException("Tried to send empty message");
        }

        // Check maximum length in UTF-8 bytes (can be reached quickly with Unicode emojis etc.)
        if (message.getBytes(StandardCharsets.UTF_8).length > ProtocolDefines.MAX_TEXT_MESSAGE_LEN) {
            throw new MessageTooLongException();
        }

        return trimmedMessage;
    }
}
