package ch.threema.app.threemasafe;

import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.text.TextUtils;
import android.text.format.DateUtils;

import ch.threema.app.preference.service.SynchronizedSettingsService;
import ch.threema.app.protocolsteps.ValidContactsLookupSteps;
import ch.threema.app.restrictions.AppRestrictions;
import ch.threema.app.stores.EncryptedPreferenceStore;
import ch.threema.app.usecases.OverrideOneTimeHintsUseCase;
import ch.threema.base.crypto.NaCl;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.koin.java.KoinJavaComponent;
import org.slf4j.Logger;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.zip.Deflater;
import java.util.zip.GZIPInputStream;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.work.ExistingPeriodicWorkPolicy;
import androidx.work.ExistingWorkPolicy;
import androidx.work.OneTimeWorkRequest;
import androidx.work.PeriodicWorkRequest;
import androidx.work.WorkManager;
import ch.threema.app.BuildConfig;
import ch.threema.app.R;
import ch.threema.app.asynctasks.AddContactRestrictionPolicy;
import ch.threema.app.asynctasks.BasicAddOrUpdateContactBackgroundTask;
import ch.threema.app.asynctasks.ContactAvailable;
import ch.threema.app.asynctasks.ContactResult;
import ch.threema.app.services.ApiService;
import ch.threema.app.services.BlockedIdentitiesService;
import ch.threema.app.services.ContactService;
import ch.threema.app.services.ConversationCategoryService;
import ch.threema.app.services.DistributionListService;
import ch.threema.app.services.ExcludedSyncIdentitiesService;
import ch.threema.app.services.FileService;
import ch.threema.app.services.GroupService;
import ch.threema.app.services.ProfilePictureRecipientsService;
import ch.threema.app.services.LocaleService;
import ch.threema.app.preference.service.PreferenceService;
import ch.threema.app.services.UserService;
import ch.threema.app.utils.BitmapUtil;
import ch.threema.app.utils.ConfigUtils;
import ch.threema.app.utils.GzipOutputStream;
import ch.threema.app.utils.WorkManagerUtil;
import ch.threema.app.workers.ThreemaSafeUploadWorker;
import ch.threema.app.workers.WorkerNames;
import ch.threema.base.ThreemaException;

import static ch.threema.base.utils.LoggingKt.getThreemaLogger;

import ch.threema.common.Base64;
import ch.threema.common.Http;
import ch.threema.data.datatypes.ContactConversationId;
import ch.threema.data.datatypes.DistributionListConversationId;
import ch.threema.data.datatypes.GroupConversationId;
import ch.threema.data.datatypes.PredefinedContact;
import ch.threema.data.repositories.ContactModelRepository;
import ch.threema.domain.models.AcquaintanceLevel;
import ch.threema.domain.models.GroupId;
import ch.threema.domain.models.IdentityState;
import ch.threema.domain.models.IdentityType;
import ch.threema.domain.models.UserState;
import ch.threema.domain.models.VerificationLevel;
import ch.threema.domain.protocol.ServerAddressProvider;
import ch.threema.domain.protocol.api.APIConnector;
import ch.threema.domain.protocol.csp.ProtocolDefines;
import ch.threema.domain.stores.IdentityStore;
import ch.threema.domain.taskmanager.TriggerSource;
import ch.threema.libthreema.CryptoException;
import ch.threema.storage.DatabaseService;
import ch.threema.domain.libthreema.LibthreemaJavaBridge;
import ch.threema.storage.factories.ContactModelFactory;
import ch.threema.storage.factories.DistributionListMemberModelFactory;
import ch.threema.storage.factories.GroupMemberModelFactory;
import ch.threema.storage.factories.GroupModelFactory;
import ch.threema.storage.models.ContactModel;
import ch.threema.storage.models.DistributionListMemberModel;
import ch.threema.storage.models.DistributionListModel;
import ch.threema.storage.models.group.GroupMemberModel;
import ch.threema.storage.models.group.GroupModelOld;
import okhttp3.OkHttpClient;

import static ch.threema.app.preference.service.PreferenceService.PROFILEPIC_RELEASE_EVERYONE;
import static ch.threema.app.preference.service.PreferenceService.PROFILEPIC_RELEASE_NOBODY;
import static ch.threema.app.preference.service.PreferenceService.PROFILEPIC_RELEASE_ALLOW_LIST;
import static ch.threema.app.threemasafe.ThreemaSafeConfigureActivity.EXTRA_OPEN_HOME_ACTIVITY;
import static ch.threema.app.threemasafe.ThreemaSafeConfigureActivity.EXTRA_WORK_FORCE_PASSWORD;
import static ch.threema.common.ByteArrayExtensionsKt.toLong;
import static ch.threema.common.JavaCompat.hexToByteArray;
import static ch.threema.common.JavaCompat.isNullOrEmpty;
import static ch.threema.common.JavaCompat.toHexString;
import static ch.threema.common.LongExtensionsKt.toByteArray;
import static ch.threema.common.TimeExtensionsKt.toNonNegativeTimestamp;
import static ch.threema.common.SecureRandomExtensionsKt.generateRandomBytes;
import static ch.threema.common.SecureRandomExtensionsKt.secureRandom;
import static ch.threema.libthreema.LibthreemaKt.scrypt;

public class ThreemaSafeServiceImpl implements ThreemaSafeService {
    private static final Logger logger = getThreemaLogger("ThreemaSafeServiceImpl");

    private static final int MASTERKEY_LENGTH = 64;
    private static final int PROFILEPIC_MAX_WIDTH = 400;
    private static final int PROFILEPIC_QUALITY = 60;

    private static final String PROFILE_PIC_RELEASE_ALL_PLACEHOLDER = "*";

    private static final int ENCRYPTION_KEY_LENGTH = NaCl.SYMM_KEY_BYTES;
    private static final int PROTOCOL_VERSION = 1;

    public static final int MIN_PW_LENGTH = 8;
    public static final int MAX_PW_LENGTH = 4096;

    /* Threema Safe tags */
    private static final String TAG_SAFE_INFO = "info";
    private static final String TAG_SAFE_INFO_VERSION = "version";
    private static final String TAG_SAFE_INFO_DEVICE = "device";

    private static final String TAG_SAFE_USER = "user";
    private static final String TAG_SAFE_USER_PRIVATE_KEY = "privatekey";
    private static final String TAG_SAFE_USER_NICKNAME = "nickname";
    private static final String TAG_SAFE_USER_PROFILE_PIC = "profilePic";
    private static final String TAG_SAFE_USER_PROFILE_PIC_RELEASE = "profilePicRelease";
    private static final String TAG_SAFE_USER_LINKS = "links";
    private static final String TAG_SAFE_USER_LINK_TYPE = "type";
    private static final String TAG_SAFE_USER_LINK_VALUE = "value";
    private static final String TAG_SAFE_USER_LINK_TYPE_MOBILE = "mobile";
    private static final String TAG_SAFE_USER_LINK_TYPE_EMAIL = "email";

    private static final String TAG_SAFE_CONTACTS = "contacts";
    private static final String TAG_SAFE_CONTACT_IDENTITY = "identity";
    private static final String TAG_SAFE_CONTACT_PUBLIC_KEY = "publickey";
    private static final String TAG_SAFE_CONTACT_CREATED_AT = "createdAt";
    private static final String TAG_SAFE_CONTACT_VERIFICATION_LEVEL = "verification";
    private static final String TAG_SAFE_CONTACT_WORK_VERIFIED = "workVerified";
    private static final String TAG_SAFE_CONTACT_HIDDEN = "hidden";
    private static final String TAG_SAFE_CONTACT_FIRST_NAME = "firstname";
    private static final String TAG_SAFE_CONTACT_LAST_NAME = "lastname";
    private static final String TAG_SAFE_CONTACT_NICKNAME = "nickname";
    private static final String TAG_SAFE_CONTACT_LAST_UPDATE = "lastUpdate";
    private static final String TAG_SAFE_CONTACT_PRIVATE = "private";
    private static final String TAG_SAFE_CONTACT_READ_RECEIPTS = "readReceipts";
    private static final String TAG_SAFE_CONTACT_TYPING_INDICATORS = "typingIndicators";

    private static final String TAG_SAFE_GROUPS = "groups";
    private static final String TAG_SAFE_GROUP_ID = "id";
    private static final String TAG_SAFE_GROUP_CREATOR = "creator";
    private static final String TAG_SAFE_GROUP_NAME = "groupname";
    private static final String TAG_SAFE_GROUP_CREATED_AT = "createdAt";
    private static final String TAG_SAFE_GROUP_MEMBERS = "members";
    private static final String TAG_SAFE_GROUP_DELETED = "deleted";
    private static final String TAG_SAFE_GROUP_LAST_UPDATE = "lastUpdate";
    private static final String TAG_SAFE_GROUP_PRIVATE = "private";

    private static final String TAG_SAFE_DISTRIBUTIONLISTS = "distributionlists";
    private static final String TAG_SAFE_DISTRIBUTIONLIST_ID = "id";
    private static final String TAG_SAFE_DISTRIBUTIONLIST_NAME = "name";
    private static final String TAG_SAFE_DISTRIBUTIONLIST_CREATED_AT = "createdAt";
    private static final String TAG_SAFE_DISTRIBUTIONLIST_MEMBERS = "members";
    private static final String TAG_SAFE_DISTRIBUTIONLIST_LAST_UPDATE = "lastUpdate";
    private static final String TAG_SAFE_DISTRIBUTIONLIST_PRIVATE = "private";

    private static final String TAG_SAFE_SETTINGS = "settings";
    private static final String TAG_SAFE_SETTINGS_SYNC_CONTACTS = "syncContacts";
    private static final String TAG_SAFE_SETTINGS_BLOCK_UNKNOWN = "blockUnknown";
    private static final String TAG_SAFE_SETTINGS_READ_RECEIPTS = "readReceipts";
    private static final String TAG_SAFE_SETTINGS_SEND_TYPING = "sendTyping";
    private static final String TAG_SAFE_SETTINGS_BLOCKED_CONTACTS = "blockedContacts";
    private static final String TAG_SAFE_SETTINGS_THREEMA_CALLS = "threemaCalls";
    private static final String TAG_SAFE_SETTINGS_RELAY_THREEMA_CALLS = "relayThreemaCalls";
    private static final String TAG_SAFE_SETTINGS_DISABLE_SCREENSHOTS = "disableScreenshots";
    private static final String TAG_SAFE_SETTINGS_INCOGNITO_KEYBOARD = "incognitoKeyboard";
    private static final String TAG_SAFE_SETTINGS_SYNC_EXCLUDED_IDS = "syncExcludedIds";
    private static final String TAG_SAFE_SETTINGS_RECENT_EMOJIS = "recentEmojis";

    private final Context context;
    @NonNull
    private final PreferenceService preferenceService;
    @NonNull
    private final SynchronizedSettingsService synchronizedSettingsService;
    private final UserService userService;
    private final IdentityStore identityStore;
    @NonNull
    private final APIConnector apiConnector;
    private final LocaleService localeService;
    private final ContactService contactService;
    private final GroupService groupService;
    private final DistributionListService distributionListService;
    private final FileService fileService;
    private final BlockedIdentitiesService blockedIdentitiesService;
    @NonNull
    private final ExcludedSyncIdentitiesService excludedSyncIdentitiesService;
    private final ProfilePictureRecipientsService profilePictureRecipientsService;
    private final DatabaseService databaseService;
    @NonNull
    private final ConversationCategoryService conversationCategoryService;
    @NonNull
    private final ServerAddressProvider serverAddressProvider;
    @NonNull
    private final EncryptedPreferenceStore encryptedPreferenceStore;
    @NonNull
    private final ContactModelRepository contactModelRepository;
    @NonNull
    private final ThreemaSafeApiClient threemaSafeApiClient;
    @NonNull
    private final AppRestrictions appRestrictions;
    @NonNull
    private final ValidContactsLookupSteps validContactsLookupSteps;

    public ThreemaSafeServiceImpl(
        Context context,
        @NonNull PreferenceService preferenceService,
        @NonNull SynchronizedSettingsService synchronizedSettingsService,
        UserService userService,
        ContactService contactService,
        GroupService groupService,
        DistributionListService distributionListService,
        LocaleService localeService,
        FileService fileService,
        @NonNull BlockedIdentitiesService blockedIdentitiesService,
        @NonNull ExcludedSyncIdentitiesService excludedSyncIdentitiesService,
        ProfilePictureRecipientsService profilePictureRecipientsService,
        DatabaseService databaseService,
        IdentityStore identityStore,
        @NonNull ApiService apiService,
        @NonNull APIConnector apiConnector,
        @NonNull ConversationCategoryService conversationCategoryService,
        @NonNull ServerAddressProvider serverAddressProvider,
        @NonNull EncryptedPreferenceStore encryptedPreferenceStore,
        @NonNull ContactModelRepository contactModelRepository,
        @NonNull OkHttpClient okHttpClient,
        @NonNull AppRestrictions appRestrictions,
        @NonNull ValidContactsLookupSteps validContactsLookupSteps
    ) {
        this.context = context;
        this.preferenceService = preferenceService;
        this.synchronizedSettingsService = synchronizedSettingsService;
        this.userService = userService;
        this.contactService = contactService;
        this.groupService = groupService;
        this.distributionListService = distributionListService;
        this.identityStore = identityStore;
        this.apiConnector = apiConnector;
        this.localeService = localeService;
        this.databaseService = databaseService;
        this.fileService = fileService;
        this.blockedIdentitiesService = blockedIdentitiesService;
        this.excludedSyncIdentitiesService = excludedSyncIdentitiesService;
        this.profilePictureRecipientsService = profilePictureRecipientsService;
        this.conversationCategoryService = conversationCategoryService;
        this.serverAddressProvider = serverAddressProvider;
        this.encryptedPreferenceStore = encryptedPreferenceStore;
        this.contactModelRepository = contactModelRepository;
        this.appRestrictions = appRestrictions;
        this.validContactsLookupSteps = validContactsLookupSteps;

        this.threemaSafeApiClient = new ThreemaSafeApiClient(
            prepareOkHttpClient(okHttpClient, apiService)
        );
    }

    private static OkHttpClient prepareOkHttpClient(OkHttpClient okHttpClient, ApiService apiService) {
        if (ConfigUtils.isOnPremBuild()) {
            // TODO(ANDR-4201): Handling auth errors should be done at app level, not at feature level
            return okHttpClient.newBuilder()
                .addInterceptor(chain -> {
                    var response = chain.proceed(chain.request());
                    if (response.code() == Http.StatusCode.UNAUTHORIZED) {
                        apiService.invalidateAuthToken();
                    }
                    return response;
                })
                .build();
        }
        return okHttpClient;
    }

    @Override
    @Nullable
    public byte[] deriveMasterKey(@Nullable String password, @Nullable String identity) {
        if (!TextUtils.isEmpty(password) && !TextUtils.isEmpty(identity)) {
            try {
                return scrypt(
                    password.getBytes(StandardCharsets.UTF_8),
                    identity.getBytes(StandardCharsets.UTF_8),
                    LibthreemaJavaBridge.createScryptParameters(
                        (byte) MASTERKEY_LENGTH
                    )
                );
            } catch (Exception e) {
                logger.error("Could not derive master key", e);
            }
        }
        return null;
    }

    @Override
    public boolean storeMasterKey(byte[] masterKey) {
        if (masterKey != null) {
            preferenceService.setThreemaSafeMasterKey(masterKey);
        }
        return false;
    }

    @Override
    @Nullable
    public byte[] getThreemaSafeBackupId() {
        byte[] masterKey = preferenceService.getThreemaSafeMasterKey();

        if (masterKey != null && masterKey.length == MASTERKEY_LENGTH) {
            return Arrays.copyOfRange(masterKey, 0, BACKUP_ID_LENGTH);
        }
        return null;
    }

    @Override
    @Nullable
    public byte[] getThreemaSafeEncryptionKey() {
        byte[] masterKey = preferenceService.getThreemaSafeMasterKey();

        if (masterKey != null && masterKey.length == MASTERKEY_LENGTH) {
            return Arrays.copyOfRange(masterKey, BACKUP_ID_LENGTH, BACKUP_ID_LENGTH + ENCRYPTION_KEY_LENGTH);
        }
        return null;
    }

    @Override
    @Nullable
    public byte[] getThreemaSafeMasterKey() {
        return preferenceService.getThreemaSafeMasterKey();
    }

    @Override
    public ThreemaSafeServerTestResponse testServer(ThreemaSafeServerInfo serverInfo) throws ThreemaException {
        try {
            var response = threemaSafeApiClient.testServer(serverInfo, getThreemaSafeBackupId());
            preferenceService.setThreemaSafeServerMaxUploadSize(response.maxBackupBytes);
            preferenceService.setThreemaSafeServerRetention(response.retentionDays);
            return response;
        } catch (IOException e) {
            throw new ThreemaException(e.getMessage(), e);
        } catch (JSONException e) {
            throw new ThreemaException("Malformed server response", e);
        } catch (IllegalArgumentException e) {
            throw new ThreemaException(e.getMessage());
        }
    }

    @Override
    public boolean schedulePeriodicUpload() {
        logger.info("Scheduling Threema Safe upload");
        WorkManager workManager = WorkManager.getInstance(context);
        boolean reschedule = WorkManagerUtil.shouldScheduleNewWorkManagerInstance(
            workManager,
            WorkerNames.WORKER_PERIODIC_THREEMA_SAFE_UPLOAD,
            SCHEDULE_PERIOD
        );
        return schedulePeriodicWork(reschedule);
    }

    @Override
    public boolean reschedulePeriodicUpload() {
        logger.info("Rescheduling Threema Safe upload");
        return schedulePeriodicWork(true);
    }

    @Override
    public void unschedulePeriodicUpload() {
        logger.info("Unscheduling Threema Safe upload");

        WorkManager workManager = WorkManager.getInstance(context);
        workManager.cancelUniqueWork(WorkerNames.WORKER_PERIODIC_THREEMA_SAFE_UPLOAD);
    }

    @Override
    public void setEnabled(boolean enabled) {
        if (ConfigUtils.isWorkRestricted() && ThreemaSafeMDMConfig.getInstance().isBackupDisabled()) {
            enabled = false;
        }

        preferenceService.setThreemaSafeEnabled(enabled);
        if (enabled) {
            schedulePeriodicUpload();
        } else {
            // disable Safe
            unschedulePeriodicUpload();
            preferenceService.setThreemaSafeEnabled(false);
            preferenceService.setThreemaSafeMasterKey(new byte[0]);
            preferenceService.setThreemaSafeServerInfo(null);
            preferenceService.setThreemaSafeUploadTimestamp(null);
            preferenceService.setThreemaSafeBackupTimestamp(null);
            preferenceService.setThreemaSafeHashString("");
            preferenceService.setThreemaSafeErrorCode(ERROR_CODE_OK);
        }
    }

    @Override
    public void uploadNow() {
        try {
            WorkManager workManager = WorkManager.getInstance(context);

            // If the periodic safe upload is scheduled, we can reschedule it so that it does not
            // get executed within the next schedule period.
            if (WorkManagerUtil.isWorkManagerInstanceScheduled(workManager, WorkerNames.WORKER_PERIODIC_THREEMA_SAFE_UPLOAD)) {
                reschedulePeriodicUpload();
            }

            // Upload the threema safe once
            OneTimeWorkRequest workRequest = ThreemaSafeUploadWorker.buildRunNowWorkRequest();
            workManager.enqueueUniqueWork(WorkerNames.WORKER_THREEMA_SAFE_UPLOAD, ExistingWorkPolicy.REPLACE, workRequest);
        } catch (IllegalStateException e) {
            logger.error("Unable to schedule safe upload one time work", e);
        }
    }

    @Override
    public void createBackup(boolean force) throws ThreemaSafeUploadException {
        logger.info("Starting Threema Safe backup");

        if (!preferenceService.getThreemaSafeEnabled()) {
            throw new ThreemaSafeUploadException("Disabled", false);
        }

        ThreemaSafeServerInfo serverInfo = preferenceService.getThreemaSafeServerInfo();

        // test server to update configuration
        final ThreemaSafeServerTestResponse serverTestResponse;
        try {
            serverTestResponse = testServer(serverInfo);
        } catch (ThreemaException e) {
            preferenceService.setThreemaSafeErrorCode(ERROR_CODE_SERVER_FAIL);
            throw new ThreemaSafeUploadException("Server test failed. " + e.getMessage(), true);
        }

        String safeJson = getSafeJson();
        if (safeJson == null) {
            preferenceService.setThreemaSafeErrorCode(ERROR_CODE_JSON_FAIL);
            throw new ThreemaSafeUploadException("Json failed", true);
        }

        // get a hash of the json to determine if there are any changes
        String hashString;
        try {
            MessageDigest messageDigest = MessageDigest.getInstance("SHA-1");
            messageDigest.update(safeJson.getBytes(StandardCharsets.UTF_8));
            hashString = new String(messageDigest.digest());
        } catch (NoSuchAlgorithmException e) {
            preferenceService.setThreemaSafeErrorCode(ERROR_CODE_HASH_FAIL);
            throw new ThreemaSafeUploadException("Hash calculation failed", true);
        }

        if (!force) {
            if (hashString.equals(preferenceService.getThreemaSafeHashString())) {
                long halfRetentionTimeMillis = (serverTestResponse.retentionDays == 0 ? 180 : serverTestResponse.retentionDays) * DateUtils.DAY_IN_MILLIS / 2;
                Instant reUploadThreshold = Instant.now().minusMillis(halfRetentionTimeMillis);
                Instant threemaSafeUploadDate = preferenceService.getThreemaSafeUploadTimestamp();
                if (preferenceService.getThreemaSafeErrorCode() == ERROR_CODE_OK &&
                    threemaSafeUploadDate != null &&
                    reUploadThreshold.isBefore(threemaSafeUploadDate)) {
                    logger.info("Threema Safe contents unchanged. NOT uploaded");
                    return;
                }
            } else {
                Instant reUploadThreshold = Instant.now().minus(23, ChronoUnit.HOURS);
                Instant threemaSafeUploadDate = preferenceService.getThreemaSafeUploadTimestamp();
                if (preferenceService.getThreemaSafeErrorCode() == ERROR_CODE_OK &&
                    threemaSafeUploadDate != null &&
                    reUploadThreshold.isBefore(threemaSafeUploadDate)) {
                    throw new ThreemaSafeUploadException("Grace time not yet reached. NOT uploaded", false);
                }
            }
        }

        byte[] gzippedPlaintext = gZipCompress(safeJson.getBytes());
        if (gzippedPlaintext == null || gzippedPlaintext.length <= 0) {
            preferenceService.setThreemaSafeErrorCode(ERROR_CODE_GZIP_FAIL);
            throw new ThreemaSafeUploadException("Compression failed", true);
        }

        if (getThreemaSafeEncryptionKey() == null) {
            throw new ThreemaSafeUploadException("No key", true);
        }

        byte[] nonce = generateRandomBytes(secureRandom(), NaCl.NONCE_BYTES);

        try {
            byte[] encdata = NaCl.symmetricEncryptData(gzippedPlaintext, getThreemaSafeEncryptionKey(), nonce);

            byte[] threemaSafeEncryptedBackup = new byte[nonce.length + encdata.length];
            System.arraycopy(nonce, 0, threemaSafeEncryptedBackup, 0, nonce.length);
            System.arraycopy(encdata, 0, threemaSafeEncryptedBackup, nonce.length, encdata.length);

            if (threemaSafeEncryptedBackup.length <= serverTestResponse.maxBackupBytes) {
                uploadData(serverInfo, threemaSafeEncryptedBackup);
                preferenceService.setThreemaSafeBackupSize(threemaSafeEncryptedBackup.length);
                preferenceService.setThreemaSafeUploadTimestamp(Instant.now());
                preferenceService.setThreemaSafeBackupTimestamp(Instant.now());
                preferenceService.setThreemaSafeHashString(hashString);
                preferenceService.setThreemaSafeErrorCode(ERROR_CODE_OK);
            } else {
                preferenceService.setThreemaSafeBackupSize(threemaSafeEncryptedBackup.length);
                throw new UploadSizeExceedException("Upload size exceeded");
            }
        } catch (UploadSizeExceedException e) {
            logger.error("Exception", e);
            preferenceService.setThreemaSafeErrorCode(ERROR_CODE_SIZE_EXCEEDED);
            throw new ThreemaSafeUploadException(e.getMessage(), true);
        } catch (Exception e) {
            logger.error("Exception", e);
            preferenceService.setThreemaSafeErrorCode(ERROR_CODE_UPLOAD_FAIL);
            throw new ThreemaSafeUploadException("Upload failed", true);
        }

        // This is only required as for some users the default server name has been stored in the
        // preferences. Default server names should not be stored in preferences as they prevent us
        // from changing the default server name easily.
        // TODO(ANDR-2968): Remove this after the specified time period
        removeStoredDefaultServerName();

        logger.info("Threema Safe backup successfully created and uploaded");
    }

    @Override
    public void deleteBackup() throws ThreemaException {
        ThreemaSafeServerInfo serverInfo = preferenceService.getThreemaSafeServerInfo();
        try {
            threemaSafeApiClient.deleteBackup(serverInfo, getThreemaSafeBackupId());
        } catch (IOException e) {
            throw new ThreemaException("deleting backup failed", e);
        } catch (IllegalArgumentException e) {
            throw new ThreemaException(e.getMessage());
        }
    }

    @Override
    public void restoreBackup(String identity, String password, ThreemaSafeServerInfo serverInfo) throws ThreemaException, IOException {
        if (isNullOrEmpty(password) || serverInfo == null || isNullOrEmpty(identity) || identity.length() != ProtocolDefines.IDENTITY_LEN) {
            throw new ThreemaException("Illegal arguments");
        }

        byte[] masterKey = deriveMasterKey(password, identity);
        if (masterKey == null) {
            throw new ThreemaException("Unable to derive master key");
        }

        preferenceService.setThreemaSafeMasterKey(masterKey);
        preferenceService.setThreemaSafeServerInfo(serverInfo);

        byte[] threemaSafeEncryptedBackup = threemaSafeApiClient.downloadEncryptedBackup(serverInfo, getThreemaSafeBackupId());

        byte[] nonce = new byte[NaCl.NONCE_BYTES];
        byte[] gzippedData = new byte[threemaSafeEncryptedBackup.length - NaCl.NONCE_BYTES];
        System.arraycopy(threemaSafeEncryptedBackup, 0, nonce, 0, NaCl.NONCE_BYTES);
        System.arraycopy(threemaSafeEncryptedBackup, NaCl.NONCE_BYTES, gzippedData, 0, threemaSafeEncryptedBackup.length - NaCl.NONCE_BYTES);

        final @Nullable byte[] threemaSafeEncryptionKey = getThreemaSafeEncryptionKey();
        if (threemaSafeEncryptionKey == null) {
            throw new ThreemaException("Threema Safe encryption key must not be null");
        }

        try {
            NaCl.symmetricDecryptDataInPlace(gzippedData, threemaSafeEncryptionKey, nonce);
        } catch (IllegalArgumentException | CryptoException exception) {
            throw new ThreemaException("Unable to decrypt", exception);
        }

        byte[] uncompressedData = gZipUncompress(gzippedData);
        if (uncompressedData == null) {
            throw new ThreemaException("Uncompress failed");
        }

        String json = new String(uncompressedData, StandardCharsets.UTF_8);

        restoreJson(identity, json);

        OverrideOneTimeHintsUseCase overrideOneTimeHintsUseCase = KoinJavaComponent.get(OverrideOneTimeHintsUseCase.class);
        overrideOneTimeHintsUseCase.call();

        // successfully restored - update mdm settings config
        ThreemaSafeMDMConfig.getInstance().saveConfig(preferenceService);
    }

    private boolean schedulePeriodicWork(boolean reschedule) {
        if (preferenceService.getThreemaSafeEnabled()) {
            ExistingPeriodicWorkPolicy policy = reschedule ? ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE : ExistingPeriodicWorkPolicy.KEEP;

            try {
                // Schedule the start of the worker every 24 hours
                WorkManager workManager = WorkManager.getInstance(context);
                PeriodicWorkRequest workRequest = ThreemaSafeUploadWorker.buildPeriodicWorkRequest(SCHEDULE_PERIOD);
                workManager.enqueueUniquePeriodicWork(
                    WorkerNames.WORKER_PERIODIC_THREEMA_SAFE_UPLOAD,
                    policy,
                    workRequest
                );

                return true;
            } catch (IllegalStateException e) {
                logger.error("Unable to schedule periodic safe upload", e);
            }
        } else {
            logger.info("Threema Safe disabled");
        }
        return false;
    }

    private void restoreJson(String identity, String json) throws ThreemaException {
        JSONObject jsonObject;

        try {
            jsonObject = new JSONObject(json);
            parseInfo(jsonObject.getJSONObject(TAG_SAFE_INFO));
        } catch (JSONException e) {
            throw new ThreemaException("Missing Info object or version mismatch");
        }

        try {
            restoreUser(identity, jsonObject.getJSONObject(TAG_SAFE_USER));
        } catch (IOException | JSONException e) {
            throw new ThreemaException("Unable to restore user");
        }

        try {
            restoreSettings(jsonObject.getJSONObject(TAG_SAFE_SETTINGS));
        } catch (JSONException e) {
            // no settings - ignore and continue
        }

        try {
            restoreContacts(jsonObject.getJSONArray(TAG_SAFE_CONTACTS));
        } catch (JSONException e) {
            // no contacts - stop here as groups and distributions lists are of no use without contacts
            return;
        }

        try {
            restoreGroups(jsonObject.getJSONArray(TAG_SAFE_GROUPS));
        } catch (JSONException e) {
            // no groups - ignore and continue
        }

        try {
            restoreDistributionLists(jsonObject.getJSONArray(TAG_SAFE_DISTRIBUTIONLISTS));
        } catch (JSONException e) {
            // no distribution lists - ignore and continue
        }
    }

    private void restoreUser(String identity, @NonNull JSONObject user) throws ThreemaException, IOException, JSONException {
        byte[] privateKey;

        String encodedPrivateKey = user.getString(TAG_SAFE_USER_PRIVATE_KEY);
        if (isNullOrEmpty(encodedPrivateKey)) {
            throw new ThreemaException("Invalid JSON");
        }
        privateKey = Base64.decode(encodedPrivateKey);

        try {
            userService.restoreIdentity(identity, privateKey);
        } catch (Exception e) {
            throw new ThreemaException(context.getString(R.string.unable_to_restore_identity_because, e.getMessage()));
        }

        String nickname = user.optString(TAG_SAFE_USER_NICKNAME, identity);

        ContactModel contactModel = contactService.getByIdentity(userService.getIdentity());
        if (contactModel != null) {
            userService.setPublicNickname(nickname, TriggerSource.LOCAL);

            boolean isLinksRestricted = false;
            if (ConfigUtils.isWorkRestricted()) {
                // if links have been set do not restore links if readonly profile is set to true and the user is unable to change or remove links
                String stringPreset;
                stringPreset = appRestrictions.getLinkedEmail();
                if (stringPreset != null) {
                    isLinksRestricted = true;
                    addUserLink(TAG_SAFE_USER_LINK_TYPE_EMAIL, stringPreset);
                }
                stringPreset = appRestrictions.getLinkedPhone();
                if (stringPreset != null) {
                    isLinksRestricted = true;
                    addUserLink(TAG_SAFE_USER_LINK_TYPE_MOBILE, stringPreset);
                }
                // do not restore links if readonly profile is set to true and the user is unable to change or remove links later
                if (appRestrictions.isReadOnlyProfile()) {
                    isLinksRestricted = true;
                }
            }

            if (!isLinksRestricted) {
                restoreLinks(user.optJSONArray(TAG_SAFE_USER_LINKS));
            }

            final @Nullable String profilePic = user.optString(TAG_SAFE_USER_PROFILE_PIC, null);
            if (profilePic != null) {
                try {
                    contactService.setUserDefinedProfilePicture(identity, Base64.decode(profilePic), TriggerSource.LOCAL);
                } catch (Exception e) {
                    // base 64 decoding or avatar setting failed - forget about the pic
                }
            }

            JSONArray profilePicRelease = user.optJSONArray(TAG_SAFE_USER_PROFILE_PIC_RELEASE);
            if (profilePicRelease != null) {
                preferenceService.setProfilePicRelease(PROFILEPIC_RELEASE_ALLOW_LIST);

                for (int i = 0; i < profilePicRelease.length(); i++) {
                    final @Nullable String identityShareWith = profilePicRelease.optString(i, null);
                    if (identityShareWith == null || identityShareWith.equalsIgnoreCase(JSONObject.NULL.toString())) {
                        preferenceService.setProfilePicRelease(PROFILEPIC_RELEASE_NOBODY);
                        break;
                    }
                    if (PROFILE_PIC_RELEASE_ALL_PLACEHOLDER.equals(identityShareWith)) {
                        preferenceService.setProfilePicRelease(PROFILEPIC_RELEASE_EVERYONE);
                        break;
                    }
                    if (identityShareWith.length() == ProtocolDefines.IDENTITY_LEN) {
                        profilePictureRecipientsService.add(identityShareWith);
                    }
                }
            }
        }
    }

    private void addUserLink(@NonNull String type, @Nullable String value) {
        if (value == null || value.isEmpty()) {
            return;
        }

        switch (type) {
            case TAG_SAFE_USER_LINK_TYPE_EMAIL:
                try {
                    userService.linkWithEmail(value, TriggerSource.LOCAL);
                } catch (Exception e) {
                    // ignore "already linked" exceptions
                }
                break;
            case TAG_SAFE_USER_LINK_TYPE_MOBILE:
                try {
                    // should always be a fully qualified phone number starting with a "+"
                    userService.linkWithMobileNumber(value.startsWith("+") ? value : "+" + value, TriggerSource.LOCAL);
                } catch (Exception e) {
                    // ignore "already linked" exceptions
                }
                break;
            default:
                break;
        }
    }

    private void restoreLink(@NonNull JSONObject link) {
        String type = link.optString(TAG_SAFE_USER_LINK_TYPE);
        String value = link.optString(TAG_SAFE_USER_LINK_VALUE);

        addUserLink(type, value);
    }

    private void restoreLinks(JSONArray links) {
        if (links == null) return;

        for (int i = 0; i < links.length(); i++) {
            JSONObject link = links.optJSONObject(i);
            if (link != null) {
                restoreLink(link);
            }
        }
    }

    private void restoreContacts(JSONArray contacts) {
        if (contacts == null) return;
        if (databaseService == null) return;

        final @Nullable String myIdentity = identityStore.getIdentityString();
        if (myIdentity == null) {
            throw new IllegalStateException("Cannot restore contacts before user identity");
        }

        ContactModelFactory contactModelFactory = databaseService.getContactModelFactory();

        ArrayList<String> identities = new ArrayList<>();
        for (int i = 0; i < contacts.length(); i++) {
            try {
                String identity = contacts.getJSONObject(i).getString(TAG_SAFE_CONTACT_IDENTITY);
                if (!myIdentity.equals(identity)) {
                    identities.add(identity);
                }
            } catch (JSONException e) {
                // ignore & continue with next contact
            }
        }

        if (identities.isEmpty()) {
            return;
        }

        List<APIConnector.FetchIdentityResult> results;
        try {
            results = this.apiConnector.fetchIdentities(identities);
        } catch (Exception e) {
            return;
        }

        for (int i = 0; i < contacts.length(); i++) {
            try {
                JSONObject contact = contacts.getJSONObject(i);
                String identity = contact.getString(TAG_SAFE_CONTACT_IDENTITY);
                String publicKey = contact.optString(TAG_SAFE_CONTACT_PUBLIC_KEY);
                VerificationLevel verificationLevel = VerificationLevel.from(contact.optInt(TAG_SAFE_CONTACT_VERIFICATION_LEVEL, VerificationLevel.UNVERIFIED.getCode()));

                APIConnector.FetchIdentityResult result = apiConnector.getFetchResultByIdentity(results, identity);

                if (result != null) {
                    ContactModel contactModel = contactService.getByIdentity(result.identity);
                    if (contactModel == null) {
                        // create a new contact
                        if (verificationLevel == VerificationLevel.FULLY_VERIFIED && !isNullOrEmpty(publicKey)) {
                            // use the public key from the backup
                            contactModel = ContactModel.create(identity, Base64.decode(publicKey));
                            contactModel.verificationLevel = verificationLevel;
                        } else {
                            // use the fetched key
                            contactModel = ContactModel.create(result.identity, result.publicKey);
                            contactModel.verificationLevel = VerificationLevel.UNVERIFIED;
                        }
                        PredefinedContact predefinedContact = PredefinedContact.getPredefinedContact(contactModel.getIdentity());
                        if (predefinedContact != null && !Arrays.equals(predefinedContact.getPublicKey(), contactModel.getPublicKey())) {
                            logger.error("Cannot create predefined contact with different public key ({})", predefinedContact.getIdentity());
                            continue;
                        }
                        if (predefinedContact != null) {
                            contactModel.verificationLevel = VerificationLevel.FULLY_VERIFIED;
                        }
                        contactModel.setFeatureMask(result.featureMask);
                        switch (result.type) {
                            case 0:
                                contactModel.setIdentityType(IdentityType.REGULAR);
                                break;
                            case 1:
                                contactModel.setIdentityType(IdentityType.WORK);
                                break;
                            default:
                                logger.warn("Identity fetch returned invalid identity type: {}", result.type);
                        }
                        if (result.state == IdentityState.ACTIVE.getValue()) {
                            contactModel.setState(IdentityState.ACTIVE);
                        } else if (result.state == IdentityState.INACTIVE.getValue()) {
                            contactModel.setState(IdentityState.INACTIVE);
                        } else if (result.state == IdentityState.INVALID.getValue()) {
                            contactModel.setState(IdentityState.INVALID);
                        }
                        contactModel.setIsWork(contact.optBoolean(TAG_SAFE_CONTACT_WORK_VERIFIED));
                        contactModel.setFirstName(contact.optString(TAG_SAFE_CONTACT_FIRST_NAME));
                        contactModel.setLastName(contact.optString(TAG_SAFE_CONTACT_LAST_NAME));
                        if (!contact.isNull(TAG_SAFE_CONTACT_NICKNAME)) {
                            contactModel.setPublicNickName(contact.getString(TAG_SAFE_CONTACT_NICKNAME));
                        }
                        contactModel.setAcquaintanceLevel(
                            contact.optBoolean(TAG_SAFE_CONTACT_HIDDEN, false)
                                ? AcquaintanceLevel.GROUP_OR_DELETED
                                : AcquaintanceLevel.DIRECT
                        );
                        contactModel.setDateCreated(Instant.ofEpochMilli(contact.optLong(TAG_SAFE_CONTACT_CREATED_AT, System.currentTimeMillis())));
                        contactModel.setIsRestored(true);
                        contactModel.setTypingIndicators(contact.optInt(TAG_SAFE_CONTACT_TYPING_INDICATORS, ContactModel.DEFAULT));
                        contactModel.setReadReceipts(contact.optInt(TAG_SAFE_CONTACT_READ_RECEIPTS, ContactModel.DEFAULT));
                        if (!contact.isNull(TAG_SAFE_CONTACT_LAST_UPDATE)) {
                            final long lastUpdate = contact.getLong("lastUpdate");
                            contactModel.setLastUpdate(Instant.ofEpochMilli(lastUpdate));
                        }
                        contactModelFactory.create(contactModel);

                        if (contact.optBoolean(TAG_SAFE_CONTACT_PRIVATE, false)) {
                            conversationCategoryService.persistAddPrivateMark(
                                new ContactConversationId(contactModel.getIdentity())
                            );
                        }
                    }
                }
            } catch (JSONException | IllegalArgumentException e) {
                logger.error("Failed to restore contact", e);
            }
        }
    }

    private void restoreGroups(JSONArray groups) {
        if (groups == null) return;
        if (databaseService == null) return;

        GroupModelFactory groupModelFactory = databaseService.getGroupModelFactory();
        GroupMemberModelFactory groupMemberModelFactory = databaseService.getGroupMemberModelFactory();

        for (int i = 0; i < groups.length(); i++) {
            try {
                JSONObject group = groups.getJSONObject(i);
                if (group.optBoolean(TAG_SAFE_GROUP_DELETED, false)) {
                    // do not restore groups that have the legacy 'deleted' flag
                    continue;
                }

                String creatorIdentity = group.getString(TAG_SAFE_GROUP_CREATOR);

                // do not create group if creator no longer exists (i.e. was revoked)
                if (contactService.getByIdentity(creatorIdentity) != null) {
                    GroupModelOld groupModel = new GroupModelOld();

                    long createdAt = group.optLong(TAG_SAFE_GROUP_CREATED_AT, 0L);
                    if (createdAt < 0) {
                        // Due to a bug in older versions, safe backups with a negative createdAt timestamp might exist
                        createdAt = 0;
                    }

                    groupModel.setApiGroupId(new GroupId(group.getString(TAG_SAFE_GROUP_ID).toLowerCase()));
                    groupModel.setCreatorIdentity(creatorIdentity);
                    groupModel.setName(group.optString(TAG_SAFE_GROUP_NAME, ""));
                    groupModel.setCreatedAt(Instant.ofEpochMilli(createdAt));
                    groupModel.setSynchronizedAt(Instant.EPOCH);
                    if (!group.isNull(TAG_SAFE_GROUP_LAST_UPDATE)) {
                        long lastUpdate = group.getLong(TAG_SAFE_GROUP_LAST_UPDATE);
                        if (lastUpdate < 0) {
                            // Due to a bug in older versions, safe backups with a negative lastUpdateAt timestamp might exist
                            lastUpdate = 0;
                        }
                        groupModel.setLastUpdate(Instant.ofEpochMilli(lastUpdate));
                    }

                    if (groupModelFactory.create(groupModel)) {
                        if (group.optBoolean(TAG_SAFE_GROUP_PRIVATE, false)) {
                            conversationCategoryService.persistAddPrivateMark(
                                new GroupConversationId(groupModel.getId())
                            );
                        }

                        String myIdentity = userService.getIdentity();
                        boolean userIsMember = false;
                        boolean creatorIsMember = false;

                        JSONArray members = group.getJSONArray(TAG_SAFE_GROUP_MEMBERS);
                        for (int j = 0; j < members.length(); j++) {
                            String identity = members.getString(j);
                            if (!isNullOrEmpty(identity)) {
                                if (contactService.getByIdentity(identity) == null) {
                                    // Fetch group contact if not in contact list. Note that we do
                                    // not add the contact to the group if it couldn't be created.
                                    ContactResult result = new BasicAddOrUpdateContactBackgroundTask(
                                        identity,
                                        AcquaintanceLevel.GROUP_OR_DELETED,
                                        validContactsLookupSteps,
                                        contactModelRepository,
                                        AddContactRestrictionPolicy.IGNORE,
                                        appRestrictions,
                                        null
                                    ).runSynchronously();

                                    if (!(result instanceof ContactAvailable)) {
                                        logger.error("Contact {} could not be created", identity);
                                        continue;
                                    }
                                }

                                if (identity.equals(myIdentity)) {
                                    userIsMember = true;
                                } else if (identity.equals(groupModel.getCreatorIdentity())) {
                                    creatorIsMember = true;
                                } else {
                                    // Only create a group member model if it is not the user or creator
                                    GroupMemberModel groupMemberModel = new GroupMemberModel();
                                    groupMemberModel.setGroupId(groupModel.getId());
                                    groupMemberModel.setIdentity(identity);

                                    groupMemberModelFactory.create(groupMemberModel);
                                }
                            }
                        }

                        if (userIsMember && !myIdentity.equals(groupModel.getCreatorIdentity()) && !creatorIsMember) {
                            // If the creator is not a member, the group is considered to be 'orphaned'. This is not allowed, so instead we treat
                            // the group as if we had been kicked from it.
                            groupModel.setUserState(UserState.KICKED);
                        } else {
                            groupModel.setUserState(userIsMember ? UserState.MEMBER : UserState.LEFT);
                        }
                        groupModelFactory.update(groupModel);
                    }
                }
            } catch (JSONException | NullPointerException e) {
                // ignore and continue with next group
            }
        }
    }

    private void restoreDistributionLists(JSONArray distributionLists) {
        if (distributionLists == null) return;
        if (databaseService == null) return;

        DistributionListMemberModelFactory distributionListMemberModelFactory = databaseService.getDistributionListMemberModelFactory();

        for (int i = 0; i < distributionLists.length(); i++) {
            try {
                JSONObject distributionList = distributionLists.getJSONObject(i);
                DistributionListModel distributionListModel = new DistributionListModel();

                long createdAt = distributionList.optLong(TAG_SAFE_DISTRIBUTIONLIST_CREATED_AT, 0L);
                if (createdAt < 0) {
                    // Due to a bug in older versions, safe backups with a negative createdAt timestamp might exist
                    createdAt = 0;
                }

                String id = distributionList.optString(TAG_SAFE_DISTRIBUTIONLIST_ID);
                if (id.length() == 16) {
                    final byte[] distributionListIdBytes = hexToByteArray(id);
                    distributionListModel.setId(toLong(distributionListIdBytes, ByteOrder.LITTLE_ENDIAN));
                }
                distributionListModel.setName(distributionList.getString(TAG_SAFE_DISTRIBUTIONLIST_NAME));
                distributionListModel.setCreatedAt(Instant.ofEpochMilli(createdAt));
                if (!distributionList.isNull(TAG_SAFE_DISTRIBUTIONLIST_LAST_UPDATE)) {
                    long lastUpdate = distributionList.getLong(TAG_SAFE_DISTRIBUTIONLIST_LAST_UPDATE);
                    if (lastUpdate < 0) {
                        // Due to a bug in older versions, safe backups with a negative lastUpdateAt timestamp might exist
                        lastUpdate = 0;
                    }
                    distributionListModel.setLastUpdate(Instant.ofEpochMilli(lastUpdate));
                }

                databaseService.getDistributionListModelFactory().create(distributionListModel);

                if (distributionList.optBoolean(TAG_SAFE_DISTRIBUTIONLIST_PRIVATE, false)) {
                    conversationCategoryService.persistAddPrivateMark(
                        new DistributionListConversationId(distributionListModel.getId())
                    );
                }

                JSONArray members = distributionList.getJSONArray(TAG_SAFE_DISTRIBUTIONLIST_MEMBERS);
                for (int j = 0; j < members.length(); j++) {
                    String identity = members.getString(j);
                    if (!isNullOrEmpty(identity)) {
                        if (contactService.getByIdentity(identity) == null) {
                            // Fetch contact if not in contact list. Note that we do not add the
                            // contact to the distribution list if it couldn't be created.
                            ContactResult result = new BasicAddOrUpdateContactBackgroundTask(
                                identity,
                                AcquaintanceLevel.DIRECT,
                                validContactsLookupSteps,
                                contactModelRepository,
                                AddContactRestrictionPolicy.IGNORE,
                                appRestrictions,
                                null
                            ).runSynchronously();

                            if (!(result instanceof ContactAvailable)) {
                                logger.error("Contact {} could not be created", identity);
                                continue;
                            }
                        }

                        DistributionListMemberModel distributionListMemberModel = new DistributionListMemberModel();
                        distributionListMemberModel.setIdentity(identity);
                        distributionListMemberModel.setDistributionListId(distributionListModel.getId());
                        distributionListMemberModel.setActive(true);
                        distributionListMemberModelFactory.create(distributionListMemberModel);
                    }
                }
            } catch (JSONException | NullPointerException e) {
                // ignore and continue with next distribution list
            }
        }
    }

    private void restoreSettings(JSONObject settings) {
        boolean syncContactsRestricted = false;
        if (ConfigUtils.isWorkRestricted()) {
            Boolean booleanPreset = appRestrictions.isContactSyncEnabledOrNull();
            if (booleanPreset != null) {
                synchronizedSettingsService.getContactSyncPolicySetting().setFromLocal(booleanPreset);
                syncContactsRestricted = true;
            }
        }

        if (!syncContactsRestricted) {
            synchronizedSettingsService.getContactSyncPolicySetting().setFromLocal(settings.optBoolean(TAG_SAFE_SETTINGS_SYNC_CONTACTS, false));
        }

        synchronizedSettingsService.getUnknownContactPolicySetting().setFromLocal(settings.optBoolean(TAG_SAFE_SETTINGS_BLOCK_UNKNOWN, false));
        synchronizedSettingsService.getTypingIndicatorPolicySetting().setFromLocal(settings.optBoolean(TAG_SAFE_SETTINGS_SEND_TYPING, true));
        synchronizedSettingsService.getReadReceiptPolicySetting().setFromLocal(settings.optBoolean(TAG_SAFE_SETTINGS_READ_RECEIPTS, true));
        synchronizedSettingsService.getO2oCallPolicySetting().setFromLocal(settings.optBoolean(TAG_SAFE_SETTINGS_THREEMA_CALLS, true));
        synchronizedSettingsService.getO2oCallConnectionPolicySetting().setFromLocal(settings.optBoolean(TAG_SAFE_SETTINGS_RELAY_THREEMA_CALLS, false));
        synchronizedSettingsService.getScreenshotPolicySetting().setFromLocal(settings.optBoolean(TAG_SAFE_SETTINGS_DISABLE_SCREENSHOTS, false));
        synchronizedSettingsService.getKeyboardDataCollectionPolicySetting().setFromLocal(settings.optBoolean(TAG_SAFE_SETTINGS_INCOGNITO_KEYBOARD, false));

        setSettingsBlockedContacts(settings.optJSONArray(TAG_SAFE_SETTINGS_BLOCKED_CONTACTS));
        setSettingsSyncExcluded(settings.optJSONArray(TAG_SAFE_SETTINGS_SYNC_EXCLUDED_IDS));
        setSettingsRecentEmojis(settings.optJSONArray(TAG_SAFE_SETTINGS_RECENT_EMOJIS));
    }

    private void parseInfo(@NonNull JSONObject info) throws ThreemaException, JSONException {
        int version = info.getInt(TAG_SAFE_INFO_VERSION);
        if (version > PROTOCOL_VERSION) {
            throw new ThreemaException(context.getResources().getString(R.string.backup_version_mismatch));
        }
    }

    @Override
    @Nullable
    public ArrayList<String> searchID(String phone, String email) {
        if (phone != null || email != null) {
            Map<String, Object> phoneMap = new HashMap<>();
            phoneMap.put(phone, null);

            Map<String, Object> emailMap = new HashMap<>();
            emailMap.put(email, null);

            try {
                Map<String, APIConnector.MatchIdentityResult> results = apiConnector.matchIdentities(emailMap, phoneMap, localeService.getCountryIsoCode(), true, identityStore, null);
                if (!results.isEmpty()) {
                    return new ArrayList<>(results.keySet());
                }
            } catch (Exception e) {
                logger.error("Exception", e);
            }
        }
        return null;
    }

    @Override
    public void launchForcedPasswordDialog(Activity activity, boolean openHomeActivity) {
        // ask user for a new password
        Intent intent = new Intent(activity, ThreemaSafeConfigureActivity.class);
        intent.putExtra(EXTRA_WORK_FORCE_PASSWORD, true);
        intent.putExtra(EXTRA_OPEN_HOME_ACTIVITY, openHomeActivity);
        activity.startActivity(intent);
        activity.overridePendingTransition(R.anim.slide_in_right_short, R.anim.slide_out_left_short);
    }

    private void uploadData(ThreemaSafeServerInfo serverInfo, byte[] data) throws ThreemaException {
        try {
            threemaSafeApiClient.uploadEncryptedBackup(serverInfo, getThreemaSafeBackupId(), data);
        } catch (IOException e) {
            throw new ThreemaException("Failed to upload backup", e);
        } catch (IllegalArgumentException e) {
            throw new ThreemaException(e.getMessage());
        }
    }

    @Nullable
    private byte[] gZipCompress(byte[] uncompressedBytes) {
        try {
            ByteArrayOutputStream outputStream = new ByteArrayOutputStream(uncompressedBytes.length);
            // Use gzip for backward compatibility, but don't actually compress (to avoid compression oracles)
            GzipOutputStream gzipOutputStream = new GzipOutputStream(outputStream, Deflater.NO_COMPRESSION, 512, false);
            gzipOutputStream.write(uncompressedBytes);
            gzipOutputStream.close();
            byte[] compressedBytes = outputStream.toByteArray();
            outputStream.close();
            return compressedBytes;
        } catch (Exception e) {
            logger.error("Error compressing", e);
            return null;
        }
    }

    @Nullable
    private byte[] gZipUncompress(byte[] compressedBytes) {
        byte[] buffer = new byte[16384];
        try {
            GZIPInputStream gzipInputStream = new GZIPInputStream(new ByteArrayInputStream(compressedBytes));
            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
            int len;
            while ((len = gzipInputStream.read(buffer)) > 0) {
                outputStream.write(buffer, 0, len);
            }
            gzipInputStream.close();
            byte[] uncompressedBytes = outputStream.toByteArray();
            outputStream.close();
            return uncompressedBytes;
        } catch (Exception e) {
            logger.error("Error uncompressing", e);
            return null;
        }
    }

    @NonNull
    private JSONObject getLink(String type, String value) throws JSONException {
        JSONObject link = new JSONObject();

        link.put(TAG_SAFE_USER_LINK_TYPE, type);
        link.put(TAG_SAFE_USER_LINK_VALUE, value);

        return link;
    }

    @NonNull
    private JSONArray getLinks() throws JSONException {
        JSONArray linksArray = new JSONArray();

        // currently, there's only one set of links
        if (userService.getMobileLinkingState() == UserService.LinkingState_LINKED) {
            String linkedMobile = userService.getLinkedMobileE164();
            if (linkedMobile != null) {
                // make sure + is stripped from number
                linksArray.put(getLink(TAG_SAFE_USER_LINK_TYPE_MOBILE, linkedMobile.length() > 1 && linkedMobile.startsWith("+") ? linkedMobile.substring(1) : linkedMobile));
            }
        }
        if (userService.getEmailLinkingState() == UserService.LinkingState_LINKED) {
            String linkedEmail = userService.getLinkedEmail();
            if (linkedEmail != null) {
                linksArray.put(getLink(TAG_SAFE_USER_LINK_TYPE_EMAIL, linkedEmail));
            }
        }

        return linksArray;
    }

    @NonNull
    private JSONObject convertContactModelToJSON(@NonNull ContactModel contactModel) throws JSONException {
        JSONObject contact = new JSONObject();

        contact.put(TAG_SAFE_CONTACT_IDENTITY, contactModel.getIdentity());
        boolean contactIsVerified = contactModel.verificationLevel == VerificationLevel.FULLY_VERIFIED;
        boolean contactIsRevoked = contactModel.getState() == IdentityState.INVALID;
        if (contactIsVerified || contactIsRevoked) {
            // Back up the public key if the contact is verified, or if it's revoked.
            //
            // Rationale: If the contact is unverified, then it doesn't really matter and the
            // public key can be re-fetched. By not including the key in the backup, we can reduce
            // its size. On the other hand, if a contact is revoked, then the public key cannot be
            // re-fetched, so it must be included.
            contact.put(TAG_SAFE_CONTACT_PUBLIC_KEY, Base64.encode(contactModel.getPublicKey()));
        }
        Instant dateCreated = contactModel.getDateCreated();
        if (dateCreated != null) {
            contact.put(TAG_SAFE_CONTACT_CREATED_AT, toNonNegativeTimestamp(dateCreated.toEpochMilli()));
        } else {
            contact.put(TAG_SAFE_CONTACT_CREATED_AT, 0);
        }
        contact.put(TAG_SAFE_CONTACT_VERIFICATION_LEVEL, contactModel.verificationLevel.getCode());
        contact.put(TAG_SAFE_CONTACT_WORK_VERIFIED, contactModel.isWorkVerified());
        contact.put(TAG_SAFE_CONTACT_HIDDEN, contactModel.isHidden());
        contact.put(TAG_SAFE_CONTACT_FIRST_NAME, contactModel.getFirstName());
        contact.put(TAG_SAFE_CONTACT_LAST_NAME, contactModel.getLastName());
        contact.put(TAG_SAFE_CONTACT_NICKNAME, contactModel.getPublicNickName());
        if (contactModel.getLastUpdate() != null) {
            contact.put(TAG_SAFE_CONTACT_LAST_UPDATE, contactModel.getLastUpdate().toEpochMilli());
        }
        contact.put(TAG_SAFE_CONTACT_HIDDEN, contactModel.getAcquaintanceLevel() == AcquaintanceLevel.GROUP_OR_DELETED);
        contact.put(TAG_SAFE_CONTACT_TYPING_INDICATORS, contactModel.getTypingIndicators());
        contact.put(TAG_SAFE_CONTACT_READ_RECEIPTS, contactModel.getReadReceipts());
        contact.put(
            TAG_SAFE_CONTACT_PRIVATE,
            conversationCategoryService.isMarkedAsPrivate(
                new ContactConversationId(contactModel.getIdentity())
            )
        );

        return contact;
    }

    /**
     * @return A list of all contacts that should be included in the Threema Safe backup.
     * Note that contacts that are removed (see {@code ContactService.getRemovedContacts}) are excluded.
     */
    @NonNull
    private JSONArray getContacts() throws JSONException {
        final @Nullable String myIdentity = identityStore.getIdentityString();
        final @NonNull List<ContactModel> contactModels = contactService.getAll();
        if (contactModels.isEmpty()) {
            return new JSONArray();
        }
        final @NonNull Set<String> removedContacts = contactService.getRemovedContacts();
        final @NonNull JSONArray contactsJsonArray = new JSONArray();
        for (final @Nullable ContactModel contactModel : contactModels) {
            if (contactModel != null && !removedContacts.contains(contactModel.getIdentity()) && !contactModel.getIdentity().equals(myIdentity)) {
                contactsJsonArray.put(convertContactModelToJSON(contactModel));
            }
        }
        return contactsJsonArray;
    }

    @NonNull
    private JSONArray getGroupMembers(@NonNull String[] groupMembers, @Nullable String ignoreIdentity) {
        JSONArray membersArray = new JSONArray();

        for (final String groupMember : groupMembers) {
            if (!groupMember.equals(ignoreIdentity)) {
                membersArray.put(groupMember);
            }
        }

        return membersArray;
    }

    @NonNull
    private JSONObject getGroup(@NonNull GroupModelOld groupModel) throws JSONException {
        JSONObject group = new JSONObject();

        group.put(TAG_SAFE_GROUP_ID, groupModel.getApiGroupId());
        group.put(TAG_SAFE_GROUP_CREATOR, groupModel.getCreatorIdentity());
        if (groupModel.getName() != null) {
            group.put(TAG_SAFE_GROUP_NAME, groupModel.getName());
        }
        Instant createdAt = groupModel.getCreatedAt();
        if (createdAt != null) {
            group.put(TAG_SAFE_GROUP_CREATED_AT, createdAt.toEpochMilli());
        } else {
            group.put(TAG_SAFE_GROUP_CREATED_AT, 0);
        }
        JSONArray groupMembers;
        if (groupModel.getUserState() == UserState.MEMBER) {
            // In case the user is a member, we should include the user in the list
            groupMembers = getGroupMembers(this.groupService.getGroupMemberIdentities(groupModel), null);
        } else {
            // If the user is no member, we do not include the user's identity
            groupMembers = getGroupMembers(this.groupService.getGroupMemberIdentities(groupModel), userService.getIdentity());
        }
        group.put(TAG_SAFE_GROUP_MEMBERS, groupMembers);
        group.put(TAG_SAFE_GROUP_DELETED, false);
        if (groupModel.getLastUpdate() != null) {
            group.put(TAG_SAFE_GROUP_LAST_UPDATE, groupModel.getLastUpdate().toEpochMilli());
        }
        group.put(
            TAG_SAFE_GROUP_PRIVATE,
            conversationCategoryService.isMarkedAsPrivate(
                new GroupConversationId(groupModel.getId())
            )
        );

        return group;
    }

    @NonNull
    private JSONArray getGroups() throws JSONException {
        JSONArray groupsArray = new JSONArray();

        for (final GroupModelOld groupModel : this.groupService.getAll(new GroupService.GroupFilter() {
            @Override
            public boolean sortByDate() {
                return false;
            }

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

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

            @Override
            public boolean includeLeftGroups() {
                return true;
            }
        })) {
            groupsArray.put(getGroup(groupModel));
        }

        return groupsArray;
    }

    @NonNull
    private JSONArray getDistributionListMembers(@NonNull String[] distributionlistMembers) {
        JSONArray membersArray = new JSONArray();

        for (final String distributionListMember : distributionlistMembers) {
            membersArray.put(distributionListMember);
        }

        return membersArray;
    }

    @NonNull
    private JSONObject getDistributionList(@NonNull DistributionListModel distributionListModel) throws JSONException {
        JSONObject distributionList = new JSONObject();
        distributionList.put(TAG_SAFE_DISTRIBUTIONLIST_ID, toHexString(toByteArray(distributionListModel.getId(), ByteOrder.LITTLE_ENDIAN)));
        distributionList.put(TAG_SAFE_DISTRIBUTIONLIST_NAME, distributionListModel.getName());
        Instant createdAt = distributionListModel.getCreatedAt();
        if (createdAt != null) {
            distributionList.put(TAG_SAFE_DISTRIBUTIONLIST_CREATED_AT, createdAt.toEpochMilli());
        } else {
            distributionList.put(TAG_SAFE_DISTRIBUTIONLIST_CREATED_AT, 0);
        }
        distributionList.put(
            TAG_SAFE_DISTRIBUTIONLIST_MEMBERS,
            getDistributionListMembers(distributionListService.getDistributionListIdentities(distributionListModel))
        );
        if (distributionListModel.getLastUpdate() != null) {
            distributionList.put(TAG_SAFE_DISTRIBUTIONLIST_LAST_UPDATE, distributionListModel.getLastUpdate().toEpochMilli());
        }
        distributionList.put(
            TAG_SAFE_DISTRIBUTIONLIST_PRIVATE,
            conversationCategoryService.isMarkedAsPrivate(
                new DistributionListConversationId(distributionListModel.getId())
            )
        );

        return distributionList;
    }

    @NonNull
    private JSONArray getDistributionLists() throws JSONException {
        JSONArray distributionListsArray = new JSONArray();

        for (final DistributionListModel distributionListModel : distributionListService.getAll(new DistributionListService.DistributionListFilter() {
            @Override
            public boolean sortingByDate() {
                return false;
            }

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

            @Override
            public boolean showHidden() {
                return false;
            }
        })) {
            distributionListsArray.put(getDistributionList(distributionListModel));
        }

        return distributionListsArray;
    }

    @NonNull
    private JSONObject getInfo() throws JSONException {
        JSONObject info = new JSONObject();

        info.put(TAG_SAFE_INFO_VERSION, PROTOCOL_VERSION);
        info.put(TAG_SAFE_INFO_DEVICE, ConfigUtils.getAppVersion() + "A/" + Locale.getDefault());

        return info;
    }

    @NonNull
    private JSONObject getUser() throws JSONException {
        JSONObject user = new JSONObject();

        user.put(TAG_SAFE_USER_PRIVATE_KEY, Base64.encode(identityStore.getPrivateKey()));
        user.put(TAG_SAFE_USER_NICKNAME, userService.getPublicNickname());

        String myIdentity = identityStore.getIdentityString();
        if (myIdentity == null) {
            throw new IllegalStateException("Identity is null");
        }

        try {
            Bitmap image = fileService.getUserDefinedProfilePicture(myIdentity);
            if (image != null) {
                // scale image - assume profile pics are always square
                if (Math.max(image.getWidth(), image.getHeight()) > PROFILEPIC_MAX_WIDTH) {
                    image = BitmapUtil.resizeBitmap(image, PROFILEPIC_MAX_WIDTH, PROFILEPIC_MAX_WIDTH);
                }
                user.put(TAG_SAFE_USER_PROFILE_PIC, Base64.encode(BitmapUtil.bitmapToByteArray(image, Bitmap.CompressFormat.JPEG, PROFILEPIC_QUALITY)));
                JSONArray profilePicRelease = new JSONArray();
                switch (preferenceService.getProfilePicRelease()) {
                    case PROFILEPIC_RELEASE_EVERYONE:
                        profilePicRelease.put(PROFILE_PIC_RELEASE_ALL_PLACEHOLDER);
                        break;
                    case PROFILEPIC_RELEASE_ALLOW_LIST:
                        for (String id : profilePictureRecipientsService.getAll()) {
                            profilePicRelease.put(id);
                        }
                        break;
                    default:
                        profilePicRelease.put(null);
                        break;
                }
                user.put(TAG_SAFE_USER_PROFILE_PIC_RELEASE, profilePicRelease);
            }
        } catch (Exception e) {
            // Profile picture seems to be missing
            // Log error, but continue.
            logger.error("Exception while getting profile picture", e);
        }

        user.put(TAG_SAFE_USER_LINKS, getLinks());

        return user;
    }

    @NonNull
    private JSONArray getSettingsBlockedContacts() {
        JSONArray blockedContactsArray = new JSONArray();

        for (final String id : blockedIdentitiesService.getAllBlockedIdentities()) {
            blockedContactsArray.put(id);
        }

        return blockedContactsArray;
    }

    private void setSettingsBlockedContacts(JSONArray blockedContacts) {
        if (blockedContacts == null) return;

        for (int i = 0; i < blockedContacts.length(); i++) {
            try {
                blockedIdentitiesService.blockIdentity(blockedContacts.getString(i), null);
            } catch (JSONException e) {
                // ignore invalid entry
            }
        }
    }

    @NonNull
    private JSONArray getSettingsSyncExcludedContacts() {
        JSONArray excludedSyncIds = new JSONArray();

        for (final String id : excludedSyncIdentitiesService.getExcludedIdentities()) {
            excludedSyncIds.put(id);
        }

        return excludedSyncIds;
    }

    private void setSettingsSyncExcluded(JSONArray excludedIdentities) {
        if (excludedIdentities == null) return;

        for (int i = 0; i < excludedIdentities.length(); i++) {
            try {
                excludedSyncIdentitiesService.excludeFromSync(
                    excludedIdentities.getString(i),
                    TriggerSource.LOCAL
                );
            } catch (JSONException e) {
                // ignore invalid entry
            }
        }
    }

    @NonNull
    private JSONArray getSettingsRecentEmojis() {
        JSONArray recentEmojis = new JSONArray();

        for (final String emoji : preferenceService.getRecentEmojis()) {
            recentEmojis.put(emoji);
        }

        return recentEmojis;
    }

    private void setSettingsRecentEmojis(JSONArray recentEmojis) {
        if (recentEmojis == null) return;

        LinkedList<String> emojiList = new LinkedList<>();

        for (int i = 0; i < recentEmojis.length(); i++) {
            try {
                emojiList.add(recentEmojis.getString(i));
            } catch (JSONException e) {
                // ignore invalid entry
            }
        }

        if (!emojiList.isEmpty()) {
            preferenceService.setRecentEmojis(emojiList);
        }
    }

    @NonNull
    private JSONObject getSettings() throws JSONException {
        JSONObject settings = new JSONObject();

        settings.put(TAG_SAFE_SETTINGS_SYNC_CONTACTS, synchronizedSettingsService.isSyncContacts());
        settings.put(TAG_SAFE_SETTINGS_BLOCK_UNKNOWN, synchronizedSettingsService.isBlockUnknown());
        settings.put(TAG_SAFE_SETTINGS_SEND_TYPING, synchronizedSettingsService.isTypingIndicatorEnabled());
        settings.put(TAG_SAFE_SETTINGS_READ_RECEIPTS, synchronizedSettingsService.areReadReceiptsEnabled());
        settings.put(TAG_SAFE_SETTINGS_THREEMA_CALLS, synchronizedSettingsService.isVoipEnabled());
        settings.put(TAG_SAFE_SETTINGS_RELAY_THREEMA_CALLS, synchronizedSettingsService.isForceTURN());
        settings.put(TAG_SAFE_SETTINGS_DISABLE_SCREENSHOTS, synchronizedSettingsService.areScreenshotsDisabled());
        settings.put(TAG_SAFE_SETTINGS_INCOGNITO_KEYBOARD, synchronizedSettingsService.isIncognitoKeyboardRequested());
        settings.put(TAG_SAFE_SETTINGS_BLOCKED_CONTACTS, getSettingsBlockedContacts());
        settings.put(TAG_SAFE_SETTINGS_SYNC_EXCLUDED_IDS, getSettingsSyncExcludedContacts());
        settings.put(TAG_SAFE_SETTINGS_RECENT_EMOJIS, getSettingsRecentEmojis());

        return settings;
    }

    /**
     * Generate and return the Threema Safe JSON string.
     * <p>
     * If an error occurs, the error is logged and `null` is returned.
     */
    @Nullable
    String getSafeJson() {
        final JSONObject jsonObject = new JSONObject();
        try {
            jsonObject.put(TAG_SAFE_INFO, getInfo());
            jsonObject.put(TAG_SAFE_USER, getUser());
            jsonObject.put(TAG_SAFE_CONTACTS, getContacts());
            jsonObject.put(TAG_SAFE_GROUPS, getGroups());
            jsonObject.put(TAG_SAFE_DISTRIBUTIONLISTS, getDistributionLists());
            jsonObject.put(TAG_SAFE_SETTINGS, getSettings());
            final int indentSpaces = BuildConfig.DEBUG ? 4 : 0;
            return jsonObject.toString(indentSpaces);
        } catch (JSONException e) {
            logger.error("Could not create Threema Safe JSON", e);
            return null;
        }
    }

    public static class UploadSizeExceedException extends Exception {
        UploadSizeExceedException(String e) {
            super(e);
        }
    }

    private void removeStoredDefaultServerName() {
        try {
            String defaultServerName = serverAddressProvider.getSafeServerUrl();
            String legacyDefaultServerName = "safe-%h.threema.ch";
            String prefKey = context.getString(R.string.preferences__threema_safe_server_name);
            String customServerName = encryptedPreferenceStore.getString(prefKey);

            if (defaultServerName.equals(customServerName) || legacyDefaultServerName.equals(customServerName)) {
                // In case the custom server name is the same as the default server name, we should
                // remove the server name from preferences.
                encryptedPreferenceStore.save(prefKey, (String) null);
                logger.info("Removed 'custom' server name: {}", customServerName);
            }
        } catch (ThreemaException e) {
            logger.warn("Could not remove default server name", e);
        }
    }
}
