فهرست منبع

Version 5.8.2

Threema 8 ماه پیش
والد
کامیت
ab2430972e
100فایلهای تغییر یافته به همراه7073 افزوده شده و 5165 حذف شده
  1. 2 2
      app/build.gradle
  2. 0 2
      app/src/androidTest/java/ch/threema/app/backuprestore/csv/BackupServiceTest.java
  3. 4 0
      app/src/hms/AndroidManifest.xml
  4. 15 4
      app/src/hms_services_based/java/ch/threema/app/push/HmsTokenUtil.kt
  5. 4 0
      app/src/hms_work/AndroidManifest.xml
  6. 2 2
      app/src/libre/play/release-notes/de/default.txt
  7. 2 2
      app/src/libre/play/release-notes/en-US/default.txt
  8. 8 2
      app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java
  9. 9 3
      app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java
  10. 1 1
      app/src/main/java/ch/threema/app/activities/ExportIDResultActivity.java
  11. 7 3
      app/src/main/java/ch/threema/app/activities/PermissionRequestActivity.kt
  12. 1 5
      app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java
  13. 2 2
      app/src/main/java/ch/threema/app/backuprestore/BackupChatService.java
  14. 160 162
      app/src/main/java/ch/threema/app/backuprestore/BackupChatServiceImpl.java
  15. 91 91
      app/src/main/java/ch/threema/app/backuprestore/BackupRestoreDataConfig.java
  16. 0 79
      app/src/main/java/ch/threema/app/backuprestore/BackupRestoreDataService.java
  17. 75 0
      app/src/main/java/ch/threema/app/backuprestore/MessageIdCache.kt
  18. 0 128
      app/src/main/java/ch/threema/app/backuprestore/csv/BackupRestoreDataServiceImpl.java
  19. 1670 1513
      app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java
  20. 2195 1928
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java
  21. 50 49
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreSettings.java
  22. 122 110
      app/src/main/java/ch/threema/app/backuprestore/csv/Tags.java
  23. 1 1
      app/src/main/java/ch/threema/app/dialogs/RingtoneSelectorDialog.java
  24. 16 1
      app/src/main/java/ch/threema/app/dialogs/SimpleStringAlertDialog.java
  25. 196 0
      app/src/main/java/ch/threema/app/emojireactions/EmojiHintPopupManager.kt
  26. 12 4
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionGroup.kt
  27. 5 6
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewActivity.kt
  28. 18 15
      app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsPopup.kt
  29. 1 1
      app/src/main/java/ch/threema/app/emojis/EmojiImageSpan.java
  30. 19 8
      app/src/main/java/ch/threema/app/emojis/EmojiMarkupUtil.java
  31. 3 11
      app/src/main/java/ch/threema/app/emojis/EmojiTextView.java
  32. 14 14
      app/src/main/java/ch/threema/app/emojis/EmojiUtil.java
  33. 331 334
      app/src/main/java/ch/threema/app/fragments/BackupDataFragment.java
  34. 30 3
      app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java
  35. 0 15
      app/src/main/java/ch/threema/app/managers/ServiceManager.java
  36. 31 0
      app/src/main/java/ch/threema/app/messagereceiver/MessageReceiverExtensions.kt
  37. 4 1
      app/src/main/java/ch/threema/app/notifications/NotificationChannels.kt
  38. 4 1
      app/src/main/java/ch/threema/app/preference/SettingsAppearanceFragment.kt
  39. 53 8
      app/src/main/java/ch/threema/app/preference/SettingsDeveloperFragment.java
  40. 357 0
      app/src/main/java/ch/threema/app/preference/developer/ContentCreator.kt
  41. 10 36
      app/src/main/java/ch/threema/app/services/FileServiceImpl.java
  42. 0 3
      app/src/main/java/ch/threema/app/services/MessageServiceImpl.java
  43. 87 84
      app/src/main/java/ch/threema/app/services/notification/NotificationServiceImpl.java
  44. 7 6
      app/src/main/java/ch/threema/app/tasks/SendPushTokenTask.kt
  45. 11 0
      app/src/main/java/ch/threema/app/ui/MentionSpan.java
  46. 0 273
      app/src/main/java/ch/threema/app/ui/TooltipPopup.java
  47. 302 0
      app/src/main/java/ch/threema/app/ui/TooltipPopup.kt
  48. 41 0
      app/src/main/java/ch/threema/app/ui/ViewExtensions.kt
  49. 1 1
      app/src/main/java/ch/threema/app/utils/CSVWriter.java
  50. 87 0
      app/src/main/java/ch/threema/app/utils/Counter.kt
  51. 156 0
      app/src/main/java/ch/threema/app/utils/FileHandlingZipOutputStream.kt
  52. 0 7
      app/src/main/java/ch/threema/app/utils/IntentDataUtil.java
  53. 96 0
      app/src/main/java/ch/threema/app/utils/RingtoneChecker.kt
  54. 27 0
      app/src/main/java/ch/threema/app/utils/ThrowingConsumer.kt
  55. 0 113
      app/src/main/java/ch/threema/app/utils/ZipUtil.java
  56. 6 6
      app/src/main/java/ch/threema/app/voip/activities/CallActivity.java
  57. 6 6
      app/src/main/java/ch/threema/app/voip/activities/GroupCallActivity.kt
  58. 17 23
      app/src/main/java/ch/threema/app/workers/WorkSyncWorker.kt
  59. 42 0
      app/src/main/java/ch/threema/data/repositories/EmojiReactionsRepository.kt
  60. 50 0
      app/src/main/java/ch/threema/data/storage/EmojiReactionsDao.kt
  61. 181 8
      app/src/main/java/ch/threema/data/storage/EmojiReactionsDaoImpl.kt
  62. 4 14
      app/src/main/java/ch/threema/logging/backend/DebugLogFileBackend.java
  63. 1 2
      app/src/main/java/ch/threema/storage/factories/GroupMessageModelFactory.java
  64. 1 2
      app/src/main/java/ch/threema/storage/factories/MessageModelFactory.java
  65. 3 0
      app/src/main/java/ch/threema/storage/models/GroupMessageModel.java
  66. 2 1
      app/src/main/res/layout/activity_emojireactions_overview.xml
  67. 25 6
      app/src/main/res/layout/popup_tooltip.xml
  68. 0 3
      app/src/main/res/values-be-rBY/strings.xml
  69. 5 3
      app/src/main/res/values-ca/strings.xml
  70. 10 2
      app/src/main/res/values-cs/strings.xml
  71. 8 2
      app/src/main/res/values-de/strings.xml
  72. 10 2
      app/src/main/res/values-es/strings.xml
  73. 12 4
      app/src/main/res/values-fr/strings.xml
  74. 10 2
      app/src/main/res/values-gsw/strings.xml
  75. 5 3
      app/src/main/res/values-hu/strings.xml
  76. 5 3
      app/src/main/res/values-it/strings.xml
  77. 2 0
      app/src/main/res/values-ja/colorpicker_strings.xml
  78. 7 5
      app/src/main/res/values-ja/strings.xml
  79. 10 2
      app/src/main/res/values-nl-rNL/strings.xml
  80. 5 3
      app/src/main/res/values-no/strings.xml
  81. 10 2
      app/src/main/res/values-pl/strings.xml
  82. 11 3
      app/src/main/res/values-pt-rBR/strings.xml
  83. 5 3
      app/src/main/res/values-rm/strings.xml
  84. 10 2
      app/src/main/res/values-ru/strings.xml
  85. 5 3
      app/src/main/res/values-sk/strings.xml
  86. 10 2
      app/src/main/res/values-tr/strings.xml
  87. 10 2
      app/src/main/res/values-uk/strings.xml
  88. 10 2
      app/src/main/res/values-zh-rCN/strings.xml
  89. 10 2
      app/src/main/res/values-zh-rTW/strings.xml
  90. 1 0
      app/src/main/res/values/dimens.xml
  91. 5 0
      app/src/main/res/values/preferences_strings.xml
  92. 8 2
      app/src/main/res/values/strings.xml
  93. 16 0
      app/src/main/res/xml/preference_developers.xml
  94. 1 1
      app/src/onprem/res/values-fr/strings.xml
  95. 1 1
      app/src/onprem/res/values-pt-rBR/strings.xml
  96. 1 1
      app/src/store_google_work/res/values-fr/strings.xml
  97. 1 1
      app/src/store_google_work/res/values-pt-rBR/strings.xml
  98. 67 0
      app/src/test/java/ch/threema/app/messagereceiver/MessageReceiverExtensionsTest.kt
  99. 122 0
      app/src/test/java/ch/threema/app/utils/CounterTest.kt
  100. 2 2
      app/src/test/java/ch/threema/architecture/LayerDependenciesTest.java

+ 2 - 2
app/build.gradle

@@ -19,14 +19,14 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 // version codes
 
 // Only use the scheme "<major>.<minor>.<patch>" for the app_version
-def app_version = "5.8.1"
+def app_version = "5.8.2"
 
 // beta_suffix with leading dash (e.g. `-beta1`)
 // should be one of (alpha|beta|rc) and an increasing number or empty for a regular release.
 // Note: in nightly builds this will be overwritten with a nightly version "-n12345"
 def beta_suffix = ""
 
-def defaultVersionCode = 1050
+def defaultVersionCode = 1052
 
 /**
  * Return the git hash, if git is installed.

+ 0 - 2
app/src/androidTest/java/ch/threema/app/backuprestore/csv/BackupServiceTest.java

@@ -237,7 +237,6 @@ public class BackupServiceTest {
 			.setBackupAvatars(false)
 			.setBackupMedia(false)
 			.setBackupThumbnails(false)
-			.setBackupVideoAndFiles(false)
 			.setBackupNonces(false));
 
 		try {
@@ -291,7 +290,6 @@ public class BackupServiceTest {
             .setBackupAvatars(false)
             .setBackupMedia(false)
             .setBackupThumbnails(false)
-            .setBackupVideoAndFiles(false)
 	        .setBackupNonces(false));
 
         try {

+ 4 - 0
app/src/hms/AndroidManifest.xml

@@ -37,6 +37,10 @@
 			</intent-filter>
 		</service>
 
+        <meta-data
+            android:name="com.huawei.hms.client.appid"
+            android:value="103713829"/>
+
 	</application>
 
 </manifest>

+ 15 - 4
app/src/hms_services_based/java/ch/threema/app/push/HmsTokenUtil.kt

@@ -22,6 +22,7 @@
 package ch.threema.app.push
 
 import android.content.Context
+import ch.threema.app.BuildFlavor
 import ch.threema.base.utils.LoggingUtil
 import com.huawei.agconnect.AGConnectOptionsBuilder
 
@@ -33,10 +34,19 @@ object HmsTokenUtil {
 
     private const val APP_ID_CONFIG_FIELD = "client/app_id"
 
+    // TODO(ANDR-3192): Remove hardcoded app-ids when plugin can read them from json config again
+    private val appIdHardcoded: String?
+        get() = when (BuildFlavor.current) {
+            is BuildFlavor.Hms -> "103713829"
+            is BuildFlavor.HmsWork -> "103858571"
+            else -> null
+        }
+
     /**
-     * Obtain the app ID from the agconnect-services.json file.
+     * Obtain the app ID from the `agconnect-services.json` file.
      *
-     * @return The app id or null if it could not be obtained
+     * @return The app id from json config file, or hardcoded value if
+     * it could not be obtained from file.
      */
     @JvmStatic
     fun getHmsAppId(context: Context): String? {
@@ -44,9 +54,10 @@ object HmsTokenUtil {
             AGConnectOptionsBuilder()
                 .build(context)
                 .getString(APP_ID_CONFIG_FIELD)
+                ?: appIdHardcoded
         } catch (e: Exception) {
-            logger.error("Could not obtain HMS app id", e)
-            null
+            logger.error("Could not obtain HMS-App-ID from config file. Fallback to hardcoded ID.", e)
+            appIdHardcoded
         }
     }
 

+ 4 - 0
app/src/hms_work/AndroidManifest.xml

@@ -35,6 +35,10 @@
 			</intent-filter>
 		</service>
 
+        <meta-data
+            android:name="com.huawei.hms.client.appid"
+            android:value="103858571"/>
+
 	</application>
 
 </manifest>

+ 2 - 2
app/src/libre/play/release-notes/de/default.txt

@@ -1,2 +1,2 @@
-- Neu: Auf Nachrichten kann mit Emoji reagiert werden
-- Behebung eines Fehlers bezüglich Profilbildern in Gruppenanrufen
+- Emoji-Reaktionen sind nun im Daten-Backup enthalten
+- Behebung eines Fehlers, wodurch eigene Klingeltöne beim Öffnen der App zurückgesetzt wurden

+ 2 - 2
app/src/libre/play/release-notes/en-US/default.txt

@@ -1,2 +1,2 @@
-- New: You can now react to messages with emoji
-- Fixed a bug concerning profile pictures in group calls
+- Emoji reactions are now included in the data backup
+- Fixed a bug that caused custom ringtones to be reset when starting the app

+ 8 - 2
app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java

@@ -498,8 +498,14 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 			location[0] += workIcon.getWidth() / 2;
 			location[1] += workIcon.getHeight();
 
-			final TooltipPopup workTooltipPopup = new TooltipPopup(this, R.string.preferences__tooltip_work_hint_shown, this, new Intent(this, WorkExplainActivity.class), R.drawable.ic_badge_work_24dp);
-			workTooltipPopup.show(this, workIcon, getString(R.string.tooltip_work_hint), TooltipPopup.ALIGN_BELOW_ANCHOR_ARROW_LEFT, location, 0);
+			final TooltipPopup workTooltipPopup = new TooltipPopup(this, R.string.preferences__tooltip_work_hint_shown, this, R.drawable.ic_badge_work_24dp);
+			workTooltipPopup.setListener(new TooltipPopup.TooltipPopupListener() {
+				@Override
+				public void onClicked(@NonNull TooltipPopup tooltipPopup) {
+					startActivity(new Intent(ContactDetailActivity.this, WorkExplainActivity.class));
+				}
+			});
+			workTooltipPopup.show(this, workIcon, null, getString(R.string.tooltip_work_hint), TooltipPopup.Alignment.BELOW_ANCHOR_ARROW_LEFT, location, 0);
 
 			final AppBarLayout appBarLayout = findViewById(R.id.appbar);
 			if (appBarLayout != null) {

+ 9 - 3
app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java

@@ -44,6 +44,7 @@ import com.google.android.material.button.MaterialButton;
 import org.slf4j.Logger;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.core.text.HtmlCompat;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
@@ -139,7 +140,7 @@ public class EnterSerialActivity extends ThreemaActivity {
 			setupForWorkBuild();
 		}
 
-		handleUrlIntent();
+		handleUrlIntent(getIntent());
 	}
 
 	private void checkForValidCredentialsInBackground() {
@@ -210,10 +211,9 @@ public class EnterSerialActivity extends ThreemaActivity {
 		this.enableLogin(true);
 	}
 
-	private void handleUrlIntent() {
+	private void handleUrlIntent(@Nullable Intent intent) {
 		String scheme = null;
 		Uri data = null;
-		Intent intent = getIntent();
 		if (intent != null) {
 			data = intent.getData();
 			if (data != null) {
@@ -461,4 +461,10 @@ public class EnterSerialActivity extends ThreemaActivity {
 		// activity when the keyboard is opened or orientation changes
 		super.onConfigurationChanged(newConfig);
 	}
+
+	@Override
+	protected void onNewIntent(@NonNull Intent intent) {
+		super.onNewIntent(intent);
+		handleUrlIntent(intent);
+	}
 }

+ 1 - 1
app/src/main/java/ch/threema/app/activities/ExportIDResultActivity.java

@@ -132,7 +132,7 @@ public class ExportIDResultActivity extends ThreemaToolbarActivity implements Ge
 				location[1] += menuItemView.getHeight();
 
 				tooltipPopup = new TooltipPopup(this, R.string.preferences__tooltip_export_id_shown, this);
-				tooltipPopup.show(this, menuItemView, getString(R.string.tooltip_export_id), TooltipPopup.ALIGN_BELOW_ANCHOR_ARROW_RIGHT, location, 5000);
+				tooltipPopup.show(this, menuItemView, null, getString(R.string.tooltip_export_id), TooltipPopup.Alignment.BELOW_ANCHOR_ARROW_RIGHT, location, 5000);
 			}, 1000);
 		}
 	}

+ 7 - 3
app/src/main/java/ch/threema/app/activities/PermissionRequestActivity.kt

@@ -39,6 +39,7 @@ import android.widget.TextView
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.core.app.ActivityCompat
 import androidx.core.content.ContextCompat
+import androidx.core.content.edit
 import androidx.preference.PreferenceManager
 import ch.threema.app.BuildConfig
 import ch.threema.app.R
@@ -138,8 +139,9 @@ class PermissionRequestActivity : ThreemaActivity() {
             }
 
             logger.info("Save do-not-ask again setting for {}", currentPermissionState.title)
-            preferences.edit().putBoolean(currentPermissionState.ignorePermissionPreference, true)
-                .apply()
+            preferences.edit {
+                putBoolean(currentPermissionState.ignorePermissionPreference, true)
+            }
 
             currentPermissionState.asked = true
 
@@ -366,7 +368,9 @@ class PermissionRequestActivity : ThreemaActivity() {
             // This means that the permission will be requested again, once the user denies the
             // permission.
             if (request.ignorePermissionPreference != null && request.granted) {
-                preferences.edit().putBoolean(request.ignorePermissionPreference, false).apply()
+                preferences.edit {
+                    putBoolean(request.ignorePermissionPreference, false)
+                }
             }
         }
     }

+ 1 - 5
app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java

@@ -601,11 +601,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> im
 			List<EmojiReactionData> reactions = emojiReactionsRepository.safeGetReactionsByMessage(messageModel);
 			if (!reactions.isEmpty()) {
 				final EmojiReactionGroup group = holder.emojiReactionGroup;
-				if (false) {
-					group.setMessageModel(decoratorHelper.getMessageReceiver(), messageModel, reactions);
-				} else {
-					group.post(() -> group.setMessageModel(decoratorHelper.getMessageReceiver(), messageModel, reactions));
-				}
+				group.post(() -> group.setMessageModel(decoratorHelper.getMessageReceiver(), messageModel, reactions));
 				holder.emojiReactionGroup.setOnEmojiReactionGroupClickListener(this);
 				holder.emojiReactionGroup.setVisibility(View.VISIBLE);
 			} else {

+ 2 - 2
app/src/main/java/ch/threema/app/backuprestore/BackupChatService.java

@@ -26,6 +26,6 @@ import java.io.File;
 import ch.threema.storage.models.ConversationModel;
 
 public interface BackupChatService {
-	boolean backupChatToZip(ConversationModel conversationModel, File outputFile, String password, boolean includeMedia);
-	void cancel();
+    boolean backupChatToZip(ConversationModel conversationModel, File outputFile, String password, boolean includeMedia);
+    void cancel();
 }

+ 160 - 162
app/src/main/java/ch/threema/app/backuprestore/BackupChatServiceImpl.java

@@ -24,8 +24,6 @@ package ch.threema.app.backuprestore;
 import android.content.Context;
 import android.text.format.DateUtils;
 
-import net.lingala.zip4j.io.outputstream.ZipOutputStream;
-
 import org.apache.commons.io.IOUtils;
 import org.slf4j.Logger;
 
@@ -39,12 +37,12 @@ import ch.threema.app.R;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.MessageService;
+import ch.threema.app.utils.FileHandlingZipOutputStream;
 import ch.threema.app.utils.FileUtil;
 import ch.threema.app.utils.GeoLocationUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.StringConversionUtil;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.app.utils.ZipUtil;
 import ch.threema.app.voicemessage.VoiceRecorderActivity;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.AbstractMessageModel;
@@ -55,163 +53,163 @@ import ch.threema.storage.models.data.media.FileDataModel;
 import ch.threema.storage.models.data.media.VideoDataModel;
 
 public class BackupChatServiceImpl implements BackupChatService {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("BackupChatServiceImpl");
-
-	private final Context context;
-	private final FileService fileService;
-	private final MessageService messageService;
-	private final ContactService contactService;
-	private boolean isCanceled;
-
-	public BackupChatServiceImpl(Context context, FileService fileService, MessageService messageService, ContactService contactService) {
-		this.context = context;
-		this.fileService = fileService;
-		this.messageService = messageService;
-		this.contactService = contactService;
-	}
-
-	private boolean buildThread(ConversationModel conversationModel, ZipOutputStream zipOutputStream, StringBuilder messageBody, boolean includeMedia) {
-		AbstractMessageModel m;
-
-		isCanceled = false;
-
-		List<AbstractMessageModel> messages = messageService.getMessagesForReceiver(conversationModel.getReceiver());
-		ListIterator<AbstractMessageModel> listIter = messages.listIterator(messages.size());
-		while (listIter.hasPrevious()) {
-			m = listIter.previous();
-
-			if (isCanceled) {
-				break;
-			}
-
-			if (m.isStatusMessage()) {
-				continue;
-			}
-
-			if (m.getType() == MessageType.GROUP_CALL_STATUS || m.getType() == MessageType.FORWARD_SECURITY_STATUS) {
-				continue;
-			}
-
-			String filename = "";
-			String messageLine = "";
-
-			if (!conversationModel.isGroupConversation()) {
-				messageLine = m.isOutbox() ? this.context.getString(R.string.me_myself_and_i) : NameUtil.getDisplayNameOrNickname(this.contactService.getByIdentity(m.getIdentity()), true);
-				messageLine += ": ";
-			}
-
-			messageLine += messageService.getMessageString(m, 0).getRawMessage();
-
-			// add media file to zip
-			try {
-				boolean saveMedia = false;
-				String extension = "";
-
-				switch (m.getType()) {
-					case IMAGE:
-						saveMedia = true;
-						extension = ".jpg";
-						break;
-					case VIDEO:
-						VideoDataModel videoDataModel = m.getVideoData();
-						saveMedia = videoDataModel != null && videoDataModel.isDownloaded();
-						extension = ".mp4";
-						break;
-					case VOICEMESSAGE:
-						AudioDataModel audioDataModel = m.getAudioData();
-						saveMedia = audioDataModel != null && audioDataModel.isDownloaded();
-						extension = VoiceRecorderActivity.VOICEMESSAGE_FILE_EXTENSION;
-						break;
-					case FILE:
-						FileDataModel fileDataModel = m.getFileData();
-						saveMedia = fileDataModel.isDownloaded();
-						filename = TestUtil.isEmptyOrNull(fileDataModel.getFileName()) ?
-							FileUtil.getDefaultFilename(fileDataModel.getMimeType()) :
-							(m.getApiMessageId() != null ? m.getApiMessageId() : m.getId()) +
-							"-" + fileDataModel.getFileName();
-						extension = "";
-						break;
-					case LOCATION:
-						messageLine += " <" + GeoLocationUtil.getLocationUri(m) + ">";
-						break;
-					case VOIP_STATUS:
-						if (m.getVoipStatusData() != null && m.getVoipStatusData().getDuration() != null) {
-							messageLine += " <" + StringConversionUtil.secondsToString(
-								m.getVoipStatusData().getDuration(),
-								false) + ">";
-						}
-						break;
-					default:
-				}
-
-				if (saveMedia) {
-					if (TestUtil.isEmptyOrNull(filename)) {
-						filename = m.getUid() + extension;
-					}
-
-					if (includeMedia) {
-						try (InputStream is = fileService.getDecryptedMessageStream(m)) {
-							if (is != null) {
-								ZipUtil.addZipStream(zipOutputStream, is, filename, false);
-							} else {
-								// if media is missing, try thumbnail
-								try (InputStream tis = fileService.getDecryptedMessageThumbnailStream(m)) {
-									if (tis != null) {
-										ZipUtil.addZipStream(zipOutputStream, tis, filename, false);
-									}
-								}
-							}
-						}
-					}
-				}
-			} catch (Exception e) {
-				//do not abort, its only a media :-)
-				logger.error("Exception", e);
-			}
-
-			if (!TestUtil.isEmptyOrNull(filename)) {
-				messageLine += " <" + filename + ">";
-			}
-
-			String messageDate = DateUtils.formatDateTime(context, m.getPostedAt().getTime(),
-					DateUtils.FORMAT_ABBREV_ALL |
-							DateUtils.FORMAT_SHOW_YEAR |
-							DateUtils.FORMAT_SHOW_DATE |
-							DateUtils.FORMAT_NUMERIC_DATE |
-							DateUtils.FORMAT_SHOW_TIME);
-			if (!TestUtil.isEmptyOrNull(messageLine)) {
-				messageBody.append("[");
-				messageBody.append(messageDate);
-				messageBody.append("] ");
-				messageBody.append(messageLine);
-				messageBody.append("\n");
-			}
-		}
-		return !isCanceled;
-	}
-
-	@Override
-	public boolean backupChatToZip(final ConversationModel conversationModel, final File outputFile, final String password, boolean includeMedia) {
-		StringBuilder messageBody = new StringBuilder();
-
-		try(final ZipOutputStream zipOutputStream = ZipUtil.initializeZipOutputStream(outputFile, password)) {
-			if (buildThread(conversationModel, zipOutputStream, messageBody, includeMedia)) {
-				ZipUtil.addZipStream(zipOutputStream, IOUtils.toInputStream(messageBody, StandardCharsets.UTF_8), "messages.txt", true);
-			}
-			return true;
-
-		} catch (Exception e) {
-			logger.error("Exception", e);
-		} finally {
-			if (isCanceled) {
-				FileUtil.deleteFileOrWarn(outputFile, "output file", logger);
-			}
-		}
-		return false;
-	}
-
-	@Override
-	public void cancel() {
-		isCanceled = true;
-	}
+    private static final Logger logger = LoggingUtil.getThreemaLogger("BackupChatServiceImpl");
+
+    private final Context context;
+    private final FileService fileService;
+    private final MessageService messageService;
+    private final ContactService contactService;
+    private boolean isCanceled;
+
+    public BackupChatServiceImpl(Context context, FileService fileService, MessageService messageService, ContactService contactService) {
+        this.context = context;
+        this.fileService = fileService;
+        this.messageService = messageService;
+        this.contactService = contactService;
+    }
+
+    private boolean buildThread(ConversationModel conversationModel, FileHandlingZipOutputStream zipOutputStream, StringBuilder messageBody, boolean includeMedia) {
+        AbstractMessageModel m;
+
+        isCanceled = false;
+
+        List<AbstractMessageModel> messages = messageService.getMessagesForReceiver(conversationModel.getReceiver());
+        ListIterator<AbstractMessageModel> listIter = messages.listIterator(messages.size());
+        while (listIter.hasPrevious()) {
+            m = listIter.previous();
+
+            if (isCanceled) {
+                break;
+            }
+
+            if (m.isStatusMessage()) {
+                continue;
+            }
+
+            if (m.getType() == MessageType.GROUP_CALL_STATUS || m.getType() == MessageType.FORWARD_SECURITY_STATUS) {
+                continue;
+            }
+
+            String filename = "";
+            String messageLine = "";
+
+            if (!conversationModel.isGroupConversation()) {
+                messageLine = m.isOutbox() ? this.context.getString(R.string.me_myself_and_i) : NameUtil.getDisplayNameOrNickname(this.contactService.getByIdentity(m.getIdentity()), true);
+                messageLine += ": ";
+            }
+
+            messageLine += messageService.getMessageString(m, 0).getRawMessage();
+
+            // add media file to zip
+            try {
+                boolean saveMedia = false;
+                String extension = "";
+
+                switch (m.getType()) {
+                    case IMAGE:
+                        saveMedia = true;
+                        extension = ".jpg";
+                        break;
+                    case VIDEO:
+                        VideoDataModel videoDataModel = m.getVideoData();
+                        saveMedia = videoDataModel.isDownloaded();
+                        extension = ".mp4";
+                        break;
+                    case VOICEMESSAGE:
+                        AudioDataModel audioDataModel = m.getAudioData();
+                        saveMedia = audioDataModel.isDownloaded();
+                        extension = VoiceRecorderActivity.VOICEMESSAGE_FILE_EXTENSION;
+                        break;
+                    case FILE:
+                        FileDataModel fileDataModel = m.getFileData();
+                        saveMedia = fileDataModel.isDownloaded();
+                        filename = TestUtil.isEmptyOrNull(fileDataModel.getFileName()) ?
+                            FileUtil.getDefaultFilename(fileDataModel.getMimeType()) :
+                            (m.getApiMessageId() != null ? m.getApiMessageId() : m.getId()) +
+                                "-" + fileDataModel.getFileName();
+                        extension = "";
+                        break;
+                    case LOCATION:
+                        messageLine += " <" + GeoLocationUtil.getLocationUri(m) + ">";
+                        break;
+                    case VOIP_STATUS:
+                        if (m.getVoipStatusData() != null && m.getVoipStatusData().getDuration() != null) {
+                            messageLine += " <" + StringConversionUtil.secondsToString(
+                                m.getVoipStatusData().getDuration(),
+                                false) + ">";
+                        }
+                        break;
+                    default:
+                }
+
+                if (saveMedia) {
+                    if (TestUtil.isEmptyOrNull(filename)) {
+                        filename = m.getUid() + extension;
+                    }
+
+                    if (includeMedia) {
+                        try (InputStream is = fileService.getDecryptedMessageStream(m)) {
+                            if (is != null) {
+                                zipOutputStream.addFileFromInputStream(is, filename, false);
+                            } else {
+                                // if media is missing, try thumbnail
+                                try (InputStream thumbnailInputStream = fileService.getDecryptedMessageThumbnailStream(m)) {
+                                    if (thumbnailInputStream != null) {
+                                        zipOutputStream.addFileFromInputStream(thumbnailInputStream, filename, false);
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            } catch (Exception e) {
+                //do not abort, its only a media :-)
+                logger.error("Exception", e);
+            }
+
+            if (!TestUtil.isEmptyOrNull(filename)) {
+                messageLine += " <" + filename + ">";
+            }
+
+            String messageDate = DateUtils.formatDateTime(context, m.getPostedAt().getTime(),
+                DateUtils.FORMAT_ABBREV_ALL |
+                    DateUtils.FORMAT_SHOW_YEAR |
+                    DateUtils.FORMAT_SHOW_DATE |
+                    DateUtils.FORMAT_NUMERIC_DATE |
+                    DateUtils.FORMAT_SHOW_TIME);
+            if (!TestUtil.isEmptyOrNull(messageLine)) {
+                messageBody.append("[");
+                messageBody.append(messageDate);
+                messageBody.append("] ");
+                messageBody.append(messageLine);
+                messageBody.append("\n");
+            }
+        }
+        return !isCanceled;
+    }
+
+    @Override
+    public boolean backupChatToZip(final ConversationModel conversationModel, final File outputFile, final String password, boolean includeMedia) {
+        StringBuilder messageBody = new StringBuilder();
+
+        try(final FileHandlingZipOutputStream zipOutputStream = FileHandlingZipOutputStream.initializeZipOutputStream(outputFile, password)) {
+            if (buildThread(conversationModel, zipOutputStream, messageBody, includeMedia)) {
+                zipOutputStream.addFileFromInputStream(IOUtils.toInputStream(messageBody, StandardCharsets.UTF_8), "messages.txt", true);
+            }
+            return true;
+
+        } catch (Exception e) {
+            logger.error("Exception", e);
+        } finally {
+            if (isCanceled) {
+                FileUtil.deleteFileOrWarn(outputFile, "output file", logger);
+            }
+        }
+        return false;
+    }
+
+    @Override
+    public void cancel() {
+        isCanceled = true;
+    }
 }

+ 91 - 91
app/src/main/java/ch/threema/app/backuprestore/BackupRestoreDataConfig.java

@@ -24,95 +24,95 @@ package ch.threema.app.backuprestore;
 import java.io.Serializable;
 
 public class BackupRestoreDataConfig implements Serializable {
-	private final String password;
-	private boolean backupIdentity = true;
-	private boolean backupContactAndMessages = true;
-	private boolean backupMedia = true;
-	private boolean backupAvatars = true;
-	private boolean backupThumbnails = false;
-	private boolean backupVideoAndFiles = false;
-	private boolean backupNonces = true;
-
-	public BackupRestoreDataConfig(String password) {
-		this.password = password;
-	}
-
-	public String getPassword() {
-		return this.password;
-	}
-
-	public boolean backupIdentity() {
-		return backupIdentity;
-	}
-
-	public BackupRestoreDataConfig setBackupIdentity(boolean backupIdentity) {
-		this.backupIdentity = backupIdentity;
-		return this;
-	}
-
-	public boolean backupContactAndMessages() {
-		return this.backupContactAndMessages;
-	}
-
-	public boolean backupGroupsAndMessages() {
-		 return this.backupContactAndMessages();
-	}
-
-	public boolean backupDistributionLists() {
-		return this.backupContactAndMessages();
-	}
-
-	public boolean backupBallots() {
-		return  this.backupContactAndMessages();
-	}
-
-	public BackupRestoreDataConfig setBackupContactAndMessages(boolean backupContactAndMessages) {
-		this.backupContactAndMessages = backupContactAndMessages;
-		return this;
-	}
-
-	public boolean backupMedia() {
-		return this.backupMedia;
-	}
-
-	public boolean backupVideoAndFiles() {
-		return this.backupVideoAndFiles;
-	}
-
-	public boolean backupThumbnails() {
-		return this.backupThumbnails;
-	}
-
-	public boolean backupAvatars() {
-		return this.backupAvatars;
-	}
-
-	public boolean backupNonces() {
-		return this.backupNonces;
-	}
-
-	public BackupRestoreDataConfig setBackupMedia(boolean backupMedia) {
-		this.backupMedia = backupMedia;
-		return this;
-	}
-
-	public BackupRestoreDataConfig setBackupVideoAndFiles(boolean backupVideoAndFiles) {
-		this.backupVideoAndFiles = backupVideoAndFiles;
-		return this;
-	}
-
-	public BackupRestoreDataConfig setBackupThumbnails(boolean backupThumbnails) {
-		this.backupThumbnails = backupThumbnails;
-		return this;
-	}
-
-	public BackupRestoreDataConfig setBackupAvatars(boolean backupAvatars) {
-		this.backupAvatars = backupAvatars;
-		return this;
-	}
-
-	public BackupRestoreDataConfig setBackupNonces(boolean backupNonces) {
-		this.backupNonces = backupNonces;
-		return this;
-	}
+    private final String password;
+    private boolean backupIdentity = true;
+    private boolean backupContactAndMessages = true;
+    private boolean backupMedia = true;
+    private boolean backupAvatars = true;
+    private boolean backupThumbnails = false;
+    private boolean backupNonces = true;
+    private boolean backupReactions = true;
+
+    public BackupRestoreDataConfig(String password) {
+        this.password = password;
+    }
+
+    public String getPassword() {
+        return this.password;
+    }
+
+    public boolean backupIdentity() {
+        return backupIdentity;
+    }
+
+    public BackupRestoreDataConfig setBackupIdentity(boolean backupIdentity) {
+        this.backupIdentity = backupIdentity;
+        return this;
+    }
+
+    public boolean backupContactAndMessages() {
+        return this.backupContactAndMessages;
+    }
+
+    public boolean backupGroupsAndMessages() {
+        return this.backupContactAndMessages();
+    }
+
+    public boolean backupDistributionLists() {
+        return this.backupContactAndMessages();
+    }
+
+    public boolean backupBallots() {
+        return  this.backupContactAndMessages();
+    }
+
+    public BackupRestoreDataConfig setBackupContactAndMessages(boolean backupContactAndMessages) {
+        this.backupContactAndMessages = backupContactAndMessages;
+        return this;
+    }
+
+    public boolean backupMedia() {
+        return this.backupMedia;
+    }
+
+    public boolean backupThumbnails() {
+        return this.backupThumbnails;
+    }
+
+    public boolean backupAvatars() {
+        return this.backupAvatars;
+    }
+
+    public boolean backupNonces() {
+        return this.backupNonces;
+    }
+
+    public boolean backupReactions() {
+        return this.backupReactions;
+    }
+
+    public BackupRestoreDataConfig setBackupMedia(boolean backupMedia) {
+        this.backupMedia = backupMedia;
+        return this;
+    }
+
+    public BackupRestoreDataConfig setBackupThumbnails(boolean backupThumbnails) {
+        this.backupThumbnails = backupThumbnails;
+        return this;
+    }
+
+    public BackupRestoreDataConfig setBackupAvatars(boolean backupAvatars) {
+        this.backupAvatars = backupAvatars;
+        return this;
+    }
+
+    public BackupRestoreDataConfig setBackupNonces(boolean backupNonces) {
+        this.backupNonces = backupNonces;
+        return this;
+    }
+
+    public BackupRestoreDataConfig setBackupReactions(boolean backupReactions) {
+        this.backupReactions = backupReactions;
+        return this;
+    }
 }

+ 0 - 79
app/src/main/java/ch/threema/app/backuprestore/BackupRestoreDataService.java

@@ -1,79 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2013-2024 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.backuprestore;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.Date;
-import java.util.List;
-
-import ch.threema.base.ThreemaException;
-
-public interface BackupRestoreDataService {
-
-	interface BackupData {
-
-		//backup file
-		File getFile();
-
-		/**
-		 * Identity backup
-		 * @return
-		 */
-		String getIdentity();
-
-		/**
-		 * Time of the backup
-		 * @return
-		 */
-		Date getBackupTime();
-
-		/**
-		 * size of the backup file
-		 * @return
-		 */
-		long getFileSize();
-	}
-
-	interface RestoreResult {
-		long getContactSuccess();
-		long getContactFailed();
-		long getMessageSuccess();
-		long getMessageFailed();
-	}
-
-	/**
-	 * Delete Zip File from File System
-	 * @param backup
-	 * @return
-	 * @throws IOException
-	 */
-	boolean deleteBackup(BackupData backup) throws IOException, ThreemaException;
-
-	/**
-	 * list of all available backups
-	 * @return
-	 */
-	List<BackupData> getBackups();
-
-	BackupData getBackupData(File file);
-}

+ 75 - 0
app/src/main/java/ch/threema/app/backuprestore/MessageIdCache.kt

@@ -0,0 +1,75 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.backuprestore
+
+import ch.threema.base.utils.LoggingUtil
+import ch.threema.domain.models.MessageId
+
+private val logger = LoggingUtil.getThreemaLogger("MessageIdCache")
+
+/**
+ * A cache for message ids as used in our database but can be retrieved by other infos (see MessageKey).
+ * This is used when a data backup is restored, as we must not store database ids in a backup and must
+ * therefore use another format for data that is related by the id.
+ *
+ * Note that this cache only retains a single entry. It is therefore only usable when the ids are
+ * requested in a sorted manner (aka all entries referencing the same id are queried successively)
+ * and must therefore not be computed over and over again.
+ */
+class MessageIdCache<K : MessageIdCache.MessageKey>(val computeIfAbsent: (key: K) -> Int) {
+    private var entry: Pair<K, Int>? = null
+
+    @Throws(NoSuchElementException::class)
+    fun get(key: K): Int {
+        val currentEntry = entry
+        return if (currentEntry?.first?.equals(key) == true) {
+            currentEntry.second
+        } else {
+            try {
+                computeIfAbsent(key).also { computedValue ->
+                    entry = key to computedValue
+                }
+            } catch (exception: Exception) {
+                logger.warn("Could not compute value", exception)
+                throw NoSuchElementException()
+            }
+        }
+    }
+
+    sealed interface MessageKey {
+        val apiMessageId: String
+
+        val messageId: MessageId
+            get() = MessageId.fromString(apiMessageId)
+    }
+
+    data class ContactMessageKey(
+        val contactIdentity: String,
+        override val apiMessageId: String,
+    ) : MessageKey
+
+    data class GroupMessageKey(
+        val apiGroupId: String,
+        val groupCreatorIdentity: String,
+        override val apiMessageId: String,
+    ) : MessageKey
+}

+ 0 - 128
app/src/main/java/ch/threema/app/backuprestore/csv/BackupRestoreDataServiceImpl.java

@@ -1,128 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2014-2024 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.backuprestore.csv;
-
-import org.slf4j.Logger;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Date;
-import java.util.List;
-
-import androidx.annotation.NonNull;
-import ch.threema.app.backuprestore.BackupRestoreDataService;
-import ch.threema.app.services.FileService;
-import ch.threema.base.ThreemaException;
-import ch.threema.base.utils.LoggingUtil;
-
-public class BackupRestoreDataServiceImpl implements BackupRestoreDataService {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("BackupRestoreDataServiceImpl");
-
-	private final @NonNull FileService fileService;
-
-	public BackupRestoreDataServiceImpl(@NonNull FileService fileService) {
-		this.fileService = fileService;
-	}
-
-	@Override
-	public boolean deleteBackup(BackupData backupData) throws IOException, ThreemaException {
-		logger.info("Deleting backup");
-		this.fileService.remove(backupData.getFile(), true);
-		return true;
-	}
-
-	@Override
-	public List<BackupData> getBackups() {
-		File[] files = this.fileService
-			.getBackupPath()
-			.listFiles((dir, filename) -> filename.endsWith(".zip"));
-
-		List<BackupData> result = new ArrayList<>();
-
-		if (files != null) {
-			for (final File f : files) {
-				BackupData data = this.getBackupData(f);
-				if (data != null && data.getIdentity() != null) {
-					result.add(data);
-				}
-			}
-		}
-
-		Collections.sort(
-			result,
-			(lhs, rhs) -> rhs.getBackupTime().compareTo(lhs.getBackupTime())
-		);
-		return result;
-	}
-
-	@Override
-	public BackupData getBackupData(final File file) {
-		if (file != null && file.exists()) {
-			String[] pieces = file.getName().split("_");
-			String idPart = null;
-			Date datePart = null;
-
-			if (pieces.length > 2 && pieces[0].equals("threema-backup")) {
-				idPart = pieces[1];
-
-				try {
-					datePart = new Date();
-					datePart.setTime(Long.parseLong(pieces[2]));
-				} catch (NumberFormatException e) {
-					idPart = null;
-					datePart = null;
-					logger.error("Exception", e);
-				}
-			}
-
-			final String identity = idPart;
-			final Date time = datePart;
-			final long size = file.length();
-
-			return new BackupData() {
-				@Override
-				public File getFile() {
-						return file;
-					}
-
-				@Override
-				public String getIdentity() {
-						return identity;
-					}
-
-				@Override
-				public Date getBackupTime() {
-						return time;
-					}
-
-				@Override
-				public long getFileSize() {
-						return size;
-					}
-			};
-
-		}
-		return null;
-	}
-}

+ 1670 - 1513
app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java

@@ -48,8 +48,6 @@ import androidx.core.app.NotificationManagerCompat;
 import androidx.core.app.ServiceCompat;
 import androidx.documentfile.provider.DocumentFile;
 
-import net.lingala.zip4j.io.outputstream.ZipOutputStream;
-
 import org.apache.commons.io.IOUtils;
 import org.apache.commons.io.output.ByteArrayOutputStream;
 import org.json.JSONObject;
@@ -59,6 +57,7 @@ import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.util.ArrayList;
 import java.util.Date;
@@ -77,7 +76,6 @@ import ch.threema.app.activities.HomeActivity;
 import ch.threema.app.backuprestore.BackupRestoreDataConfig;
 import ch.threema.app.backuprestore.RandomUtil;
 import ch.threema.app.collections.Functional;
-import ch.threema.app.collections.IPredicateNonNull;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.notifications.NotificationChannels;
@@ -90,18 +88,20 @@ import ch.threema.app.services.UserService;
 import ch.threema.app.services.ballot.BallotService;
 import ch.threema.app.utils.BackupUtils;
 import ch.threema.app.utils.CSVWriter;
+import ch.threema.app.utils.Counter;
+import ch.threema.app.utils.FileHandlingZipOutputStream;
 import ch.threema.app.utils.MessageUtil;
 import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.StringConversionUtil;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.app.utils.ZipUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.crypto.HashedNonce;
 import ch.threema.base.crypto.NonceFactory;
 import ch.threema.base.crypto.NonceScope;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.Utils;
+import ch.threema.data.repositories.EmojiReactionsRepository;
 import ch.threema.domain.identitybackup.IdentityBackupGenerator;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.models.AbstractMessageModel;
@@ -123,1518 +123,1675 @@ import ch.threema.storage.models.data.media.FileDataModel;
 import ch.threema.storage.models.data.media.VideoDataModel;
 
 public class BackupService extends Service {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("BackupService");
-
-	public static final String BACKUP_PROGRESS_INTENT = "backup_progress_intent";
-	public static final String BACKUP_PROGRESS = "backup_progress";
-	public static final String BACKUP_PROGRESS_STEPS = "backup_progress_steps";
-	public static final String BACKUP_PROGRESS_MESSAGE = "backup_progress_message";
-	public static final String BACKUP_PROGRESS_ERROR_MESSAGE = "backup_progress_error_message";
-
-	private static final int MEDIA_STEP_FACTOR = 9;
-	private static final int MEDIA_STEP_FACTOR_VIDEOS_AND_FILES = 12;
-	private static final int MEDIA_STEP_FACTOR_THUMBNAILS = 3;
-	private static final int NONCES_PER_STEP = 50;
-	private static final int NONCES_CHUNK_SIZE = 2500;
-
-	private static final String EXTRA_ID_CANCEL = "cnc";
-	public static final String EXTRA_BACKUP_RESTORE_DATA_CONFIG = "ebrdc";
-
-	private static final int BACKUP_NOTIFICATION_ID = 991772;
-	public static final int BACKUP_COMPLETION_NOTIFICATION_ID = 991773;
-	private static final long FILE_SETTLE_DELAY = 5000;
-
-	private static final String INCOMPLETE_BACKUP_FILENAME_PREFIX = "INCOMPLETE-";
-
-	private static final int FG_SERVICE_TYPE = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? FOREGROUND_SERVICE_TYPE_DATA_SYNC : 0;
-
-	private int currentProgressStep = 0;
-	private long processSteps = 0;
-
-	private static boolean backupSuccess = false;
-	private static boolean isCanceled = false;
-	private static boolean isRunning = false;
-
-	private ServiceManager serviceManager;
-	private ContactService contactService;
-	private FileService fileService;
-	private UserService userService;
-	private GroupService groupService;
-	private BallotService ballotService;
-	private DistributionListService distributionListService;
-	private DatabaseServiceNew databaseServiceNew;
-	private PreferenceService preferenceService;
-	private PowerManager.WakeLock wakeLock;
-	private NotificationManagerCompat notificationManagerCompat;
-	private NonceFactory nonceFactory;
-
-	private NotificationCompat.Builder notificationBuilder;
-
-	private int latestPercentStep = -1;
-	private long startTime = 0;
-
-	private static DocumentFile backupFile = null;
-	private BackupRestoreDataConfig config = null;
-	private final HashMap<Integer, String> groupUidMap = new HashMap<>();
-	private final Iterator<Integer> randomIterator = RandomUtil.getDistinctRandomIterator();
-
-	public static boolean isRunning() {
-		return isRunning;
-	}
-
-	@Nullable
-	@Override
-	public IBinder onBind(Intent intent) {
-		return null;
-	}
-
-	@Override
-	public int onStartCommand(Intent intent, int flags, int startId) {
-		if (intent != null) {
-			isCanceled = intent.getBooleanExtra(EXTRA_ID_CANCEL, false);
-
-			if (!isCanceled) {
-				config = (BackupRestoreDataConfig) intent.getSerializableExtra(EXTRA_BACKUP_RESTORE_DATA_CONFIG);
-
-				if (config == null || userService.getIdentity() == null || userService.getIdentity().isEmpty()) {
-					safeStopSelf();
-					return START_NOT_STICKY;
-				}
-
-				// acquire wake locks
-				logger.debug("Acquiring wakelock");
-				PowerManager powerManager = (PowerManager) getApplicationContext().getSystemService(Context.POWER_SERVICE);
-				if (powerManager != null) {
-					String tag = BuildConfig.APPLICATION_ID + ":backup";
-					if (Build.VERSION.SDK_INT == Build.VERSION_CODES.M && Build.MANUFACTURER.equals("Huawei")) {
-						// Huawei will not kill your app if your Wakelock has a well known tag
-						// see https://dontkillmyapp.com/huawei
-						tag = "LocationManagerService";
-					}
-					wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag);
-					if (wakeLock != null) {
-						wakeLock.acquire(DateUtils.DAY_IN_MILLIS);
-					}
-				}
-
-				boolean success = false;
-				Date now = new Date();
-				DocumentFile zipFile = null;
-				Uri backupUri = this.fileService.getBackupUri();
-
-				if (backupUri == null) {
-					showBackupErrorNotification("Destination directory has not been selected yet");
-					safeStopSelf();
-					return START_NOT_STICKY;
-				}
-
-				String filename = "threema-backup_" + now.getTime() + "_1";
-
-				if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(backupUri.getScheme())) {
-					zipFile = DocumentFile.fromFile(new File(backupUri.getPath(), INCOMPLETE_BACKUP_FILENAME_PREFIX + filename + ".zip"));
-					success = true;
-				} else {
-					DocumentFile directory = DocumentFile.fromTreeUri(getApplicationContext(), backupUri);
-					if (directory != null && directory.exists()) {
-						try {
-							zipFile = directory.createFile(MimeUtil.MIME_TYPE_ZIP, INCOMPLETE_BACKUP_FILENAME_PREFIX + filename);
-							if (zipFile != null && zipFile.canWrite()) {
-								success = true;
-							}
-						} catch (Exception e) {
-							logger.debug("Exception", e);
-						}
-					}
-				}
-
-				if (zipFile == null || !success) {
-					showBackupErrorNotification(getString(R.string.backup_data_no_permission));
-					safeStopSelf();
-					return START_NOT_STICKY;
-				}
-
-				backupFile = zipFile;
-
-				showPersistentNotification();
-
-				// close connection
-				try {
-					serviceManager.stopConnection();
-				} catch (InterruptedException e) {
-					showBackupErrorNotification("BackupService interrupted");
-					stopSelf();
-					return START_NOT_STICKY;
-				}
-
-				new AsyncTask<Void, Void, Boolean>() {
-					@Override
-					protected Boolean doInBackground(Void... params) {
-						return backup();
-					}
-
-					@Override
-					protected void onPostExecute(Boolean success) {
-						stopSelf();
-					}
-				}.execute();
-
-				return START_STICKY;
-			} else {
-				Toast.makeText(this, R.string.backup_data_cancelled, Toast.LENGTH_LONG).show();
-			}
-		} else {
-			logger.debug("onStartCommand intent == null");
-
-			onFinished(null);
-		}
-		return START_NOT_STICKY;
-	}
-
-	@Override
-	public void onCreate() {
-		logger.info("onCreate");
-
-		super.onCreate();
-
-		isRunning = true;
-
-		serviceManager = ThreemaApplication.getServiceManager();
-		if (serviceManager == null) {
-			safeStopSelf();
-			return;
-		}
-
-		try {
-			fileService = serviceManager.getFileService();
-			databaseServiceNew = serviceManager.getDatabaseServiceNew();
-			contactService = serviceManager.getContactService();
-			groupService = serviceManager.getGroupService();
-			distributionListService = serviceManager.getDistributionListService();
-			userService = serviceManager.getUserService();
-			ballotService = serviceManager.getBallotService();
-			preferenceService = serviceManager.getPreferenceService();
-			nonceFactory = serviceManager.getNonceFactory();
-		} catch (Exception e) {
-			logger.error("Exception", e);
-			safeStopSelf();
-			return;
-		}
-
-		notificationManagerCompat = NotificationManagerCompat.from(this);
-	}
-
-	@Override
-	public void onDestroy() {
-		logger.info("onDestroy success={} canceled={}", backupSuccess, isCanceled);
-
-		if (isCanceled) {
-			onFinished(getString(R.string.backup_data_cancelled));
-		}
-		super.onDestroy();
-	}
-
-	@Override
-	public void onLowMemory() {
-		logger.info("onLowMemory");
-		super.onLowMemory();
-	}
-
-	@Override
-	public void onTaskRemoved(Intent rootIntent) {
-		logger.debug("onTaskRemoved");
-
-		Intent intent = new Intent(this, DummyActivity.class);
-		intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-		startActivity(intent);
-	}
-
-	private int getStepFactor() {
-		return this.config.backupVideoAndFiles() ?
-				MEDIA_STEP_FACTOR_VIDEOS_AND_FILES : (this.config.backupMedia() ? MEDIA_STEP_FACTOR :
-				(this.config.backupThumbnails() ? MEDIA_STEP_FACTOR_THUMBNAILS : 1));
-	}
-
-	private boolean backup() {
-		String identity = userService.getIdentity();
-		try(final ZipOutputStream zipOutputStream = ZipUtil.initializeZipOutputStream(getContentResolver(), backupFile.getUri(), config.getPassword())) {
-			logger.debug("Creating zip file {}", backupFile.getUri());
-
-			//save settings
-			RestoreSettings settings = new RestoreSettings(RestoreSettings.CURRENT_VERSION);
-			ByteArrayOutputStream settingsBuffer = null;
-			try {
-				settingsBuffer = new ByteArrayOutputStream();
-				CSVWriter settingsCsv = new CSVWriter(new OutputStreamWriter(settingsBuffer));
-				settingsCsv.writeAll(settings.toList());
-				settingsCsv.close();
-			}
-			finally {
-				if (settingsBuffer != null) {
-					try {
-						settingsBuffer.close();
-					} catch (IOException e) { /**/ }
-				}
-			}
-
-			long progressContactsAndMessages = this.databaseServiceNew.getContactModelFactory().count()
-					+ this.databaseServiceNew.getMessageModelFactory().count()
-					+ this.databaseServiceNew.getGroupModelFactory().count()
-					+ this.databaseServiceNew.getGroupMessageModelFactory().count();
-
-			long progressDistributionLists = this.databaseServiceNew.getDistributionListModelFactory().count()
-					+ this.databaseServiceNew.getDistributionListMessageModelFactory().count();
-
-			long progressBallots = this.databaseServiceNew.getBallotModelFactory().count();
-
-			long progress = (this.config.backupIdentity() ? 1 : 0)
-					+ (this.config.backupContactAndMessages() ?
-					progressContactsAndMessages : 0)
-					+ (this.config.backupDistributionLists() ?
-					progressDistributionLists : 0)
-					+ (this.config.backupBallots() ?
-					progressBallots : 0);
-
-			if (this.config.backupMedia() || this.config.backupThumbnails()) {
-				try {
-					Set<MessageType> fileTypes = this.config.backupVideoAndFiles() ? MessageUtil.getFileTypes() : MessageUtil.getLowProfileMessageModelTypes();
-					MessageType[] fileTypesArray = fileTypes.toArray(new MessageType[fileTypes.size()]);
-
-					long mediaProgress = this.databaseServiceNew.getMessageModelFactory().countByTypes(fileTypesArray);
-					mediaProgress += this.databaseServiceNew.getGroupMessageModelFactory().countByTypes(fileTypesArray);
-
-					if (this.config.backupDistributionLists()) {
-						mediaProgress += this.databaseServiceNew.getDistributionListMessageModelFactory().countByTypes(fileTypesArray);
-					}
-
-					progress += (mediaProgress * getStepFactor());
-				} catch (Exception x) {
-					logger.error("Exception", x);
-				}
-			}
-
-			if (this.config.backupNonces()) {
-				progress += 1;
-				long nonceCount = nonceFactory.getCount(NonceScope.CSP) + nonceFactory.getCount(NonceScope.D2D);
-				long nonceProgress = (long) Math.ceil((double) nonceCount / NONCES_PER_STEP);
-				progress += nonceProgress;
-			}
-
-			logger.debug("Calculated steps " + progress);
-			this.initProgress(progress);
-
-			ZipUtil.addZipStream(zipOutputStream, new ByteArrayInputStream(settingsBuffer.toByteArray()), Tags.SETTINGS_FILE_NAME, true);
-
-			if (this.config.backupIdentity()) {
-				if (!this.next("backup identity")) {
-					return this.cancelBackup(backupFile);
-				}
-
-				byte[] privateKey = this.userService.getPrivateKey();
-				IdentityBackupGenerator identityBackupGenerator = new IdentityBackupGenerator(identity, privateKey);
-				String backupData = identityBackupGenerator.generateBackup(this.config.getPassword());
-
-				ZipUtil.addZipStream(zipOutputStream, IOUtils.toInputStream(backupData), Tags.IDENTITY_FILE_NAME, false);
-			}
-
-			//backup contacts and messages
-			if (this.config.backupContactAndMessages()) {
-				if (!this.backupContactsAndMessages(config, zipOutputStream)) {
-					return this.cancelBackup(backupFile);
-				}
-			}
-
-			//backup groups and messages
-			if (this.config.backupGroupsAndMessages()) {
-				if (!this.backupGroupsAndMessages(config, zipOutputStream)) {
-					return this.cancelBackup(backupFile);
-				}
-			}
-
-			//backup distribution lists and messages
-			if (this.config.backupDistributionLists()) {
-				if (!this.backupDistributionListsAndMessages(config, zipOutputStream)) {
-					return this.cancelBackup(backupFile);
-				}
-			}
-
-			if (this.config.backupBallots()) {
-				if (!this.backupBallots(config, zipOutputStream)) {
-					return this.cancelBackup(backupFile);
-				}
-			}
-
-			// Backup nonces
-			if (this.config.backupNonces()) {
-				if (!this.backupNonces(zipOutputStream)) {
-					return this.cancelBackup(backupFile);
-				}
-			}
-
-			backupSuccess = true;
-			onFinished("");
-		} catch (final Exception e) {
-			removeBackupFile(backupFile);
-
-			backupSuccess = false;
-			onFinished("Error: " + e.getMessage());
-
-			logger.error("Exception", e);
-		}
-		return backupSuccess;
-	}
-
-	private boolean next(String subject) {
-		return this.next(subject, 1);
-	}
-
-	private boolean next(String subject, int increment) {
-		logger.debug("step [{}]", subject);
-		this.currentProgressStep += (this.currentProgressStep < this.processSteps ? increment : 0);
-		this.handleProgress();
-		return !isCanceled;
-	}
-
-	/**
-	 * only call progress on 100 steps
-	 */
-	private void handleProgress() {
-		int p = (int) (100d / (double) this.processSteps * (double) this.currentProgressStep);
-		if (p > this.latestPercentStep) {
-			this.latestPercentStep = p;
-			String timeRemaining = getRemainingTimeText(latestPercentStep, 100);
-			updatePersistentNotification(latestPercentStep, 100, timeRemaining);
-			LocalBroadcastManager.getInstance(ThreemaApplication.getAppContext())
-				.sendBroadcast(new Intent(BACKUP_PROGRESS_INTENT)
-					.putExtra(BACKUP_PROGRESS, latestPercentStep)
-					.putExtra(BACKUP_PROGRESS_STEPS, 100)
-					.putExtra(BACKUP_PROGRESS_MESSAGE, timeRemaining)
-				);
-		}
-	}
-
-	private void removeBackupFile(DocumentFile zipFile) {
-		//remove zip file
-		if (zipFile != null && zipFile.exists()) {
-			logger.debug("remove {}", zipFile.getUri());
-			zipFile.delete();
-		}
-	}
-
-	private boolean cancelBackup(DocumentFile zipFile) {
-		removeBackupFile(zipFile);
-		backupSuccess = false;
-		onFinished(null);
-
-		return false;
-	}
-
-	private void initProgress(long steps) {
-		this.currentProgressStep = 0;
-		this.processSteps = steps;
-		this.latestPercentStep = 0;
-		this.startTime = System.currentTimeMillis();
-		this.handleProgress();
-	}
-
-	/**
-	 * Create a Backup of all contacts and messages.
-	 * Backup media if configured.
-	 */
-	private boolean backupContactsAndMessages(
-		@NonNull BackupRestoreDataConfig config,
-		@NonNull ZipOutputStream zipOutputStream
-	) throws ThreemaException, IOException {
-		// first, save my own profile pic
-		if (this.config.backupAvatars()) {
-			try {
-				ZipUtil.addZipStream(
-					zipOutputStream,
-					this.fileService.getUserDefinedProfilePictureStream(contactService.getMe().getIdentity()),
-					Tags.CONTACT_AVATAR_FILE_PREFIX + Tags.CONTACT_AVATAR_FILE_SUFFIX_ME,
-					false
-				);
-			} catch (IOException e) {
-				logger.warn("Could not back up own avatar: {}", e.getMessage());
-			}
-		}
-
-		final String[] contactCsvHeader = {
-			Tags.TAG_CONTACT_IDENTITY,
-			Tags.TAG_CONTACT_PUBLIC_KEY,
-			Tags.TAG_CONTACT_VERIFICATION_LEVEL,
-			Tags.TAG_CONTACT_ANDROID_CONTACT_ID,
-			Tags.TAG_CONTACT_FIRST_NAME,
-			Tags.TAG_CONTACT_LAST_NAME,
-			Tags.TAG_CONTACT_NICK_NAME,
-			Tags.TAG_CONTACT_LAST_UPDATE,
-			Tags.TAG_CONTACT_HIDDEN,
-			Tags.TAG_CONTACT_ARCHIVED,
-			Tags.TAG_CONTACT_IDENTITY_ID,
-		};
-		final String[] messageCsvHeader = {
-			Tags.TAG_MESSAGE_API_MESSAGE_ID,
-			Tags.TAG_MESSAGE_UID,
-			Tags.TAG_MESSAGE_IS_OUTBOX,
-			Tags.TAG_MESSAGE_IS_READ,
-			Tags.TAG_MESSAGE_IS_SAVED,
-			Tags.TAG_MESSAGE_MESSAGE_STATE,
-			Tags.TAG_MESSAGE_POSTED_AT,
-			Tags.TAG_MESSAGE_CREATED_AT,
-			Tags.TAG_MESSAGE_MODIFIED_AT,
-			Tags.TAG_MESSAGE_TYPE,
-			Tags.TAG_MESSAGE_BODY,
-			Tags.TAG_MESSAGE_IS_STATUS_MESSAGE,
-			Tags.TAG_MESSAGE_CAPTION,
-			Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID,
-			Tags.TAG_MESSAGE_DELIVERED_AT,
-			Tags.TAG_MESSAGE_READ_AT,
-			Tags.TAG_GROUP_MESSAGE_STATES,
-			Tags.TAG_MESSAGE_DISPLAY_TAGS,
-			Tags.TAG_MESSAGE_EDITED_AT,
-			Tags.TAG_MESSAGE_DELETED_AT
-		};
-
-		// Iterate over all contacts. Then backup every contact with the corresponding messages.
-		try (final ByteArrayOutputStream contactBuffer = new ByteArrayOutputStream()) {
-			try (final CSVWriter contactCsv = new CSVWriter(new OutputStreamWriter(contactBuffer), contactCsvHeader)) {
-				for (final ContactModel contactModel : contactService.find(null)) {
-					if (!this.next("backup contact " + contactModel.getIdentity())) {
-						return false;
-					}
-
-					String identityId = getFormattedUniqueId();
-
-					// Write contact
-					contactCsv.createRow()
-						.write(Tags.TAG_CONTACT_IDENTITY, contactModel.getIdentity())
-						.write(Tags.TAG_CONTACT_PUBLIC_KEY, Utils.byteArrayToHexString(contactModel.getPublicKey()))
-						.write(Tags.TAG_CONTACT_VERIFICATION_LEVEL, contactModel.verificationLevel.toString())
-						.write(Tags.TAG_CONTACT_ANDROID_CONTACT_ID, contactModel.getAndroidContactLookupKey())
-						.write(Tags.TAG_CONTACT_FIRST_NAME, contactModel.getFirstName())
-						.write(Tags.TAG_CONTACT_LAST_NAME, contactModel.getLastName())
-						.write(Tags.TAG_CONTACT_NICK_NAME, contactModel.getPublicNickName())
-						.write(Tags.TAG_CONTACT_LAST_UPDATE, contactModel.getLastUpdate())
-						.write(Tags.TAG_CONTACT_HIDDEN, contactModel.getAcquaintanceLevel() == ContactModel.AcquaintanceLevel.GROUP)
-						.write(Tags.TAG_CONTACT_ARCHIVED, contactModel.isArchived())
-						.write(Tags.TAG_CONTACT_IDENTITY_ID, identityId)
-						.write();
-
-					// Back up contact profile pictures
-					if (this.config.backupAvatars()) {
-						try {
-							if (!userService.getIdentity().equals(contactModel.getIdentity())) {
-								ZipUtil.addZipStream(
-									zipOutputStream,
-									this.fileService.getUserDefinedProfilePictureStream(contactModel.getIdentity()),
-									Tags.CONTACT_AVATAR_FILE_PREFIX + identityId,
-									false
-								);
-							}
-						} catch (IOException e) {
-							// avatars are not THAT important, so we don't care if adding them fails
-							logger.warn("Could not back up avatar for contact {}: {}", contactModel.getIdentity(), e.getMessage());
-						}
-
-						try {
-							ZipUtil.addZipStream(
-								zipOutputStream,
-								this.fileService.getContactDefinedProfilePictureStream(contactModel.getIdentity()),
-								Tags.CONTACT_PROFILE_PIC_FILE_PREFIX + identityId,
-								false
-							);
-						} catch (IOException e) {
-							// profile pics are not THAT important, so we don't care if adding them fails
-							logger.warn("Could not back up profile pic for contact {}: {}", contactModel.getIdentity(), e.getMessage());
-						}
-					}
-
-					// Back up conversations
-					try (final ByteArrayOutputStream messageBuffer = new ByteArrayOutputStream()) {
-						try (final CSVWriter messageCsv = new CSVWriter(new OutputStreamWriter(messageBuffer), messageCsvHeader)) {
-
-							List<MessageModel> messageModels = this.databaseServiceNew
-								.getMessageModelFactory()
-								.getByIdentityUnsorted(contactModel.getIdentity());
-
-							for (MessageModel messageModel : messageModels) {
-								if (!this.next("backup message " + messageModel.getId())) {
-									return false;
-								}
-
-								String apiMessageId = messageModel.getApiMessageId();
-
-								if ((apiMessageId != null && apiMessageId.length() > 0) || messageModel.getType() == MessageType.VOIP_STATUS) {
-									messageCsv.createRow()
-										.write(Tags.TAG_MESSAGE_API_MESSAGE_ID, messageModel.getApiMessageId())
-										.write(Tags.TAG_MESSAGE_UID, messageModel.getUid())
-										.write(Tags.TAG_MESSAGE_IS_OUTBOX, messageModel.isOutbox())
-										.write(Tags.TAG_MESSAGE_IS_READ, messageModel.isRead())
-										.write(Tags.TAG_MESSAGE_IS_SAVED, messageModel.isSaved())
-										.write(Tags.TAG_MESSAGE_MESSAGE_STATE, messageModel.getState())
-										.write(Tags.TAG_MESSAGE_POSTED_AT, messageModel.getPostedAt())
-										.write(Tags.TAG_MESSAGE_CREATED_AT, messageModel.getCreatedAt())
-										.write(Tags.TAG_MESSAGE_MODIFIED_AT, messageModel.getModifiedAt())
-										.write(Tags.TAG_MESSAGE_TYPE, messageModel.getType().toString())
-										.write(Tags.TAG_MESSAGE_BODY, messageModel.getBody())
-										.write(Tags.TAG_MESSAGE_IS_STATUS_MESSAGE, messageModel.isStatusMessage())
-										.write(Tags.TAG_MESSAGE_CAPTION, messageModel.getCaption())
-										.write(Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID, messageModel.getQuotedMessageId())
-										.write(Tags.TAG_MESSAGE_DELIVERED_AT, messageModel.getDeliveredAt())
-										.write(Tags.TAG_MESSAGE_READ_AT, messageModel.getReadAt())
-										.write(Tags.TAG_MESSAGE_DISPLAY_TAGS, messageModel.getDisplayTags())
-										.write(Tags.TAG_MESSAGE_EDITED_AT, messageModel.getEditedAt())
-										.write(Tags.TAG_MESSAGE_DELETED_AT, messageModel.getDeletedAt())
-										.write();
-								}
-
-								this.backupMediaFile(
-									config,
-									zipOutputStream,
-									Tags.MESSAGE_MEDIA_FILE_PREFIX,
-									Tags.MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX,
-									messageModel);
-							}
-						}
-
-						ZipUtil.addZipStream(
-							zipOutputStream,
-							new ByteArrayInputStream(messageBuffer.toByteArray()),
-							Tags.MESSAGE_FILE_PREFIX + identityId + Tags.CSV_FILE_POSTFIX,
-							true
-						);
-					}
-				}
-			}
-
-			ZipUtil.addZipStream(
-				zipOutputStream,
-				new ByteArrayInputStream(contactBuffer.toByteArray()),
-				Tags.CONTACTS_FILE_NAME + Tags.CSV_FILE_POSTFIX,
-				true
-			);
-		}
-
-		return true;
-	}
-
-	/**
-	 * Backup all groups with messages and media (if configured).
-	 */
-	private boolean backupGroupsAndMessages(
-		@NonNull BackupRestoreDataConfig config,
-		@NonNull ZipOutputStream zipOutputStream
-	) throws ThreemaException, IOException {
-		final String[] groupCsvHeader = {
-			Tags.TAG_GROUP_ID,
-			Tags.TAG_GROUP_CREATOR,
-			Tags.TAG_GROUP_NAME,
-			Tags.TAG_GROUP_CREATED_AT,
-			Tags.TAG_GROUP_LAST_UPDATE,
-			Tags.TAG_GROUP_MEMBERS,
-			Tags.TAG_GROUP_DELETED,
-			Tags.TAG_GROUP_ARCHIVED,
-			Tags.TAG_GROUP_DESC,
-			Tags.TAG_GROUP_DESC_TIMESTAMP,
-			Tags.TAG_GROUP_UID,
-			Tags.TAG_GROUP_USER_STATE,
-		};
-		final String[] groupMessageCsvHeader = {
-			Tags.TAG_MESSAGE_API_MESSAGE_ID,
-			Tags.TAG_MESSAGE_UID,
-			Tags.TAG_MESSAGE_IDENTITY,
-			Tags.TAG_MESSAGE_IS_OUTBOX,
-			Tags.TAG_MESSAGE_IS_READ,
-			Tags.TAG_MESSAGE_IS_SAVED,
-			Tags.TAG_MESSAGE_MESSAGE_STATE,
-			Tags.TAG_MESSAGE_POSTED_AT,
-			Tags.TAG_MESSAGE_CREATED_AT,
-			Tags.TAG_MESSAGE_MODIFIED_AT,
-			Tags.TAG_MESSAGE_TYPE,
-			Tags.TAG_MESSAGE_BODY,
-			Tags.TAG_MESSAGE_IS_STATUS_MESSAGE,
-			Tags.TAG_MESSAGE_CAPTION,
-			Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID,
-			Tags.TAG_MESSAGE_DELIVERED_AT,
-			Tags.TAG_MESSAGE_READ_AT,
-			Tags.TAG_GROUP_MESSAGE_STATES,
-			Tags.TAG_MESSAGE_DISPLAY_TAGS,
-			Tags.TAG_MESSAGE_EDITED_AT,
-			Tags.TAG_MESSAGE_DELETED_AT
-		};
-
-		final GroupService.GroupFilter groupFilter = new GroupService.GroupFilter() {
-			@Override
-			public boolean sortByDate() {
-				return false;
-			}
-
-			@Override
-			public boolean sortByName() {
-				return false;
-			}
-
-			@Override
-			public boolean sortAscending() {
-				return false;
-			}
-
-			@Override
-			public boolean includeDeletedGroups() {
-				return true;
-			}
-
-			@Override
-			public boolean includeLeftGroups() {
-				return true;
-			}
-		};
-
-		// Iterate over all groups
-		try (final ByteArrayOutputStream groupBuffer = new ByteArrayOutputStream()) {
-			try (final CSVWriter groupCsv = new CSVWriter(new OutputStreamWriter(groupBuffer), groupCsvHeader)) {
-				for (final GroupModel groupModel : this.groupService.getAll(groupFilter)) {
-					String groupUid = getFormattedUniqueId();
-					groupUidMap.put(groupModel.getId(), groupUid);
-
-					if (!this.next("backup group " + groupModel.getApiGroupId())) {
-						return false;
-					}
-
-					groupCsv.createRow()
-						.write(Tags.TAG_GROUP_ID, groupModel.getApiGroupId())
-						.write(Tags.TAG_GROUP_CREATOR, groupModel.getCreatorIdentity())
-						.write(Tags.TAG_GROUP_NAME, groupModel.getName())
-						.write(Tags.TAG_GROUP_CREATED_AT, groupModel.getCreatedAt())
-						.write(Tags.TAG_GROUP_LAST_UPDATE, groupModel.getLastUpdate())
-						.write(Tags.TAG_GROUP_MEMBERS, this.groupService.getGroupIdentities(groupModel))
-						.write(Tags.TAG_GROUP_DELETED, groupModel.isDeleted())
-						.write(Tags.TAG_GROUP_ARCHIVED, groupModel.isArchived())
-						.write(Tags.TAG_GROUP_DESC, groupModel.getGroupDesc())
-						.write(Tags.TAG_GROUP_DESC_TIMESTAMP, groupModel.getGroupDescTimestamp())
-						.write(Tags.TAG_GROUP_UID, groupUid)
-						.write(Tags.TAG_GROUP_USER_STATE, groupModel.getUserState() != null ? groupModel.getUserState().value : 0)
-						.write();
-
-					//check if the group have a photo
-					if (this.config.backupAvatars()) {
-						try {
-							ZipUtil.addZipStream(zipOutputStream, this.fileService.getGroupAvatarStream(groupModel), Tags.GROUP_AVATAR_PREFIX + groupUid, false);
-						} catch (Exception e) {
-							logger.warn("Could not back up group avatar: {}", e.getMessage());
-						}
-					}
-
-					// Back up group messages
-					try (final ByteArrayOutputStream groupMessageBuffer = new ByteArrayOutputStream()) {
-						try (final CSVWriter groupMessageCsv = new CSVWriter(new OutputStreamWriter(groupMessageBuffer), groupMessageCsvHeader)) {
-							List<GroupMessageModel> groupMessageModels = this.databaseServiceNew
-								.getGroupMessageModelFactory()
-								.getByGroupIdUnsorted(groupModel.getId());
-
-							for (GroupMessageModel groupMessageModel : groupMessageModels) {
-								if (!this.next("backup group message " + groupMessageModel.getUid())) {
-									return false;
-								}
-
-								String groupMessageStates = "";
-								if (groupMessageModel.getGroupMessageStates() != null) {
-									groupMessageStates = new JSONObject(groupMessageModel.getGroupMessageStates()).toString();
-								}
-
-								groupMessageCsv.createRow()
-									.write(Tags.TAG_MESSAGE_API_MESSAGE_ID, groupMessageModel.getApiMessageId())
-									.write(Tags.TAG_MESSAGE_UID, groupMessageModel.getUid())
-									.write(Tags.TAG_MESSAGE_IDENTITY, groupMessageModel.getIdentity())
-									.write(Tags.TAG_MESSAGE_IS_OUTBOX, groupMessageModel.isOutbox())
-									.write(Tags.TAG_MESSAGE_IS_READ, groupMessageModel.isRead())
-									.write(Tags.TAG_MESSAGE_IS_SAVED, groupMessageModel.isSaved())
-									.write(Tags.TAG_MESSAGE_MESSAGE_STATE, groupMessageModel.getState())
-									.write(Tags.TAG_MESSAGE_POSTED_AT, groupMessageModel.getPostedAt())
-									.write(Tags.TAG_MESSAGE_CREATED_AT, groupMessageModel.getCreatedAt())
-									.write(Tags.TAG_MESSAGE_MODIFIED_AT, groupMessageModel.getModifiedAt())
-									.write(Tags.TAG_MESSAGE_TYPE, groupMessageModel.getType())
-									.write(Tags.TAG_MESSAGE_BODY, groupMessageModel.getBody())
-									.write(Tags.TAG_MESSAGE_IS_STATUS_MESSAGE, groupMessageModel.isStatusMessage())
-									.write(Tags.TAG_MESSAGE_CAPTION, groupMessageModel.getCaption())
-									.write(Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID, groupMessageModel.getQuotedMessageId())
-									.write(Tags.TAG_MESSAGE_DELIVERED_AT, groupMessageModel.getDeliveredAt())
-									.write(Tags.TAG_MESSAGE_READ_AT, groupMessageModel.getReadAt())
-									.write(Tags.TAG_GROUP_MESSAGE_STATES, groupMessageStates)
-									.write(Tags.TAG_MESSAGE_DISPLAY_TAGS, groupMessageModel.getDisplayTags())
-									.write(Tags.TAG_MESSAGE_EDITED_AT, groupMessageModel.getEditedAt())
-									.write(Tags.TAG_MESSAGE_DELETED_AT, groupMessageModel.getDeletedAt())
-									.write();
-
-								this.backupMediaFile(
-									config,
-									zipOutputStream,
-									Tags.GROUP_MESSAGE_MEDIA_FILE_PREFIX,
-									Tags.GROUP_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX,
-									groupMessageModel
-								);
-							}
-						}
-
-						ZipUtil.addZipStream(
-							zipOutputStream,
-							new ByteArrayInputStream(groupMessageBuffer.toByteArray()),
-							Tags.GROUP_MESSAGE_FILE_PREFIX + groupUid + Tags.CSV_FILE_POSTFIX,
-							true
-						);
-					}
-				}
-			}
-
-			ZipUtil.addZipStream(zipOutputStream, new ByteArrayInputStream(
-				groupBuffer.toByteArray()),
-				Tags.GROUPS_FILE_NAME + Tags.CSV_FILE_POSTFIX,
-				true
-			);
-		}
-
-		return true;
-	}
-
-	/**
-	 * backup all ballots with votes and choices!
-	 */
-	private boolean backupBallots(
-		@NonNull BackupRestoreDataConfig config,
-		@NonNull ZipOutputStream zipOutputStream
-	) throws ThreemaException, IOException {
-
-		final String[] ballotCsvHeader = {
-			Tags.TAG_BALLOT_ID,
-			Tags.TAG_BALLOT_API_ID,
-			Tags.TAG_BALLOT_API_CREATOR,
-			Tags.TAG_BALLOT_REF,
-			Tags.TAG_BALLOT_REF_ID,
-			Tags.TAG_BALLOT_NAME,
-			Tags.TAG_BALLOT_STATE,
-			Tags.TAG_BALLOT_ASSESSMENT,
-			Tags.TAG_BALLOT_TYPE,
-			Tags.TAG_BALLOT_C_TYPE,
-			Tags.TAG_BALLOT_LAST_VIEWED_AT,
-			Tags.TAG_BALLOT_CREATED_AT,
-			Tags.TAG_BALLOT_MODIFIED_AT,
-		};
-		final String[] ballotChoiceCsvHeader = {
-			Tags.TAG_BALLOT_CHOICE_ID,
-			Tags.TAG_BALLOT_CHOICE_BALLOT_UID,
-			Tags.TAG_BALLOT_CHOICE_API_ID,
-			Tags.TAG_BALLOT_CHOICE_TYPE,
-			Tags.TAG_BALLOT_CHOICE_NAME,
-			Tags.TAG_BALLOT_CHOICE_VOTE_COUNT,
-			Tags.TAG_BALLOT_CHOICE_ORDER,
-			Tags.TAG_BALLOT_CHOICE_CREATED_AT,
-			Tags.TAG_BALLOT_CHOICE_MODIFIED_AT,
-		};
-		final String[] ballotVoteCsvHeader = {
-			Tags.TAG_BALLOT_VOTE_ID,
-			Tags.TAG_BALLOT_VOTE_BALLOT_UID,
-			Tags.TAG_BALLOT_VOTE_CHOICE_UID,
-			Tags.TAG_BALLOT_VOTE_IDENTITY,
-			Tags.TAG_BALLOT_VOTE_CHOICE,
-			Tags.TAG_BALLOT_VOTE_CREATED_AT,
-			Tags.TAG_BALLOT_VOTE_MODIFIED_AT,
-		};
-
-		try (
-			final ByteArrayOutputStream ballotCsvBuffer = new ByteArrayOutputStream();
-			final ByteArrayOutputStream ballotChoiceCsvBuffer = new ByteArrayOutputStream();
-			final ByteArrayOutputStream ballotVoteCsvBuffer = new ByteArrayOutputStream()
-		) {
-			try (
-				final OutputStreamWriter ballotOsw = new OutputStreamWriter(ballotCsvBuffer);
-				final OutputStreamWriter ballotChoiceOsw = new OutputStreamWriter(ballotChoiceCsvBuffer);
-				final OutputStreamWriter ballotVoteOsw = new OutputStreamWriter(ballotVoteCsvBuffer);
-				final CSVWriter ballotCsv = new CSVWriter(ballotOsw, ballotCsvHeader);
-				final CSVWriter ballotChoiceCsv = new CSVWriter(ballotChoiceOsw, ballotChoiceCsvHeader);
-				final CSVWriter ballotVoteCsv = new CSVWriter(ballotVoteOsw, ballotVoteCsvHeader)
-			) {
-
-				List<BallotModel> ballots = ballotService.getBallots(new BallotService.BallotFilter() {
-					@Override
-					public MessageReceiver getReceiver() {
-						return null;
-					}
-
-					@Override
-					public BallotModel.State[] getStates() {
-						return new BallotModel.State[]{BallotModel.State.OPEN, BallotModel.State.CLOSED};
-					}
-
-					@Override
-					public boolean filter(BallotModel ballotModel) {
-						return true;
-					}
-				});
-
-				if (ballots != null) {
-					for (BallotModel ballotModel : ballots) {
-						if (!this.next("ballot " + ballotModel.getId())) {
-							return false;
-						}
-
-						LinkBallotModel link = ballotService.getLinkedBallotModel(ballotModel);
-						if (link == null) {
-							continue;
-						}
-
-						String ref;
-						String refId;
-						if (link instanceof GroupBallotModel) {
-							GroupModel groupModel = groupService
-								.getById(((GroupBallotModel) link).getGroupId());
-
-							if (groupModel == null) {
-								logger.error("invalid group for a ballot");
-								continue;
-							}
-
-							ref = "GroupBallotModel";
-							refId = groupUidMap.get(groupModel.getId());
-						} else if (link instanceof IdentityBallotModel) {
-							ref = "IdentityBallotModel";
-							refId = ((IdentityBallotModel) link).getIdentity();
-						} else {
-							continue;
-						}
-
-						ballotCsv.createRow()
-							.write(Tags.TAG_BALLOT_ID, ballotModel.getId())
-							.write(Tags.TAG_BALLOT_API_ID, ballotModel.getApiBallotId())
-							.write(Tags.TAG_BALLOT_API_CREATOR, ballotModel.getCreatorIdentity())
-							.write(Tags.TAG_BALLOT_REF, ref)
-							.write(Tags.TAG_BALLOT_REF_ID, refId)
-							.write(Tags.TAG_BALLOT_NAME, ballotModel.getName())
-							.write(Tags.TAG_BALLOT_STATE, ballotModel.getState())
-							.write(Tags.TAG_BALLOT_ASSESSMENT, ballotModel.getAssessment())
-							.write(Tags.TAG_BALLOT_TYPE, ballotModel.getType())
-							.write(Tags.TAG_BALLOT_C_TYPE, ballotModel.getChoiceType())
-							.write(Tags.TAG_BALLOT_LAST_VIEWED_AT, ballotModel.getLastViewedAt())
-							.write(Tags.TAG_BALLOT_CREATED_AT, ballotModel.getCreatedAt())
-							.write(Tags.TAG_BALLOT_MODIFIED_AT, ballotModel.getModifiedAt())
-							.write();
-
-
-						final List<BallotChoiceModel> ballotChoiceModels = this.databaseServiceNew
-							.getBallotChoiceModelFactory()
-							.getByBallotId(ballotModel.getId());
-						for (BallotChoiceModel ballotChoiceModel : ballotChoiceModels) {
-							ballotChoiceCsv.createRow()
-								.write(Tags.TAG_BALLOT_CHOICE_ID, ballotChoiceModel.getId())
-								.write(Tags.TAG_BALLOT_CHOICE_BALLOT_UID, BackupUtils.buildBallotUid(ballotModel))
-								.write(Tags.TAG_BALLOT_CHOICE_API_ID, ballotChoiceModel.getApiBallotChoiceId())
-								.write(Tags.TAG_BALLOT_CHOICE_TYPE, ballotChoiceModel.getType())
-								.write(Tags.TAG_BALLOT_CHOICE_NAME, ballotChoiceModel.getName())
-								.write(Tags.TAG_BALLOT_CHOICE_VOTE_COUNT, ballotChoiceModel.getVoteCount())
-								.write(Tags.TAG_BALLOT_CHOICE_ORDER, ballotChoiceModel.getOrder())
-								.write(Tags.TAG_BALLOT_CHOICE_CREATED_AT, ballotChoiceModel.getCreatedAt())
-								.write(Tags.TAG_BALLOT_CHOICE_MODIFIED_AT, ballotChoiceModel.getModifiedAt())
-								.write();
-
-						}
-
-						final List<BallotVoteModel> ballotVoteModels = this.databaseServiceNew
-							.getBallotVoteModelFactory()
-							.getByBallotId(ballotModel.getId());
-						for (final BallotVoteModel ballotVoteModel : ballotVoteModels) {
-							BallotChoiceModel ballotChoiceModel = Functional.select(ballotChoiceModels, new IPredicateNonNull<BallotChoiceModel>() {
-								@Override
-								public boolean apply(@NonNull BallotChoiceModel type) {
-									return type.getId() == ballotVoteModel.getBallotChoiceId();
-								}
-							});
-
-							if (ballotChoiceModel == null) {
-								continue;
-							}
-
-							ballotVoteCsv.createRow()
-								.write(Tags.TAG_BALLOT_VOTE_ID, ballotVoteModel.getId())
-								.write(Tags.TAG_BALLOT_VOTE_BALLOT_UID, BackupUtils.buildBallotUid(ballotModel))
-								.write(Tags.TAG_BALLOT_VOTE_CHOICE_UID, BackupUtils.buildBallotChoiceUid(ballotChoiceModel))
-								.write(Tags.TAG_BALLOT_VOTE_IDENTITY, ballotVoteModel.getVotingIdentity())
-								.write(Tags.TAG_BALLOT_VOTE_CHOICE, ballotVoteModel.getChoice())
-								.write(Tags.TAG_BALLOT_VOTE_CREATED_AT, ballotVoteModel.getCreatedAt())
-								.write(Tags.TAG_BALLOT_VOTE_MODIFIED_AT, ballotVoteModel.getModifiedAt())
-								.write();
-
-						}
-					}
-				}
-			}
-
-			ZipUtil.addZipStream(
-				zipOutputStream,
-				new ByteArrayInputStream(ballotCsvBuffer.toByteArray()),
-				Tags.BALLOT_FILE_NAME + Tags.CSV_FILE_POSTFIX,
-				true
-			);
-			ZipUtil.addZipStream(
-				zipOutputStream,
-				new ByteArrayInputStream(ballotChoiceCsvBuffer.toByteArray()),
-				Tags.BALLOT_CHOICE_FILE_NAME + Tags.CSV_FILE_POSTFIX,
-				true
-			);
-			ZipUtil.addZipStream(
-				zipOutputStream,
-				new ByteArrayInputStream(ballotVoteCsvBuffer.toByteArray()),
-				Tags.BALLOT_VOTE_FILE_NAME + Tags.CSV_FILE_POSTFIX,
-				true
-			);
+    private static final Logger logger = LoggingUtil.getThreemaLogger("BackupService");
+
+    public static final String BACKUP_PROGRESS_INTENT = "backup_progress_intent";
+    public static final String BACKUP_PROGRESS = "backup_progress";
+    public static final String BACKUP_PROGRESS_STEPS = "backup_progress_steps";
+    public static final String BACKUP_PROGRESS_MESSAGE = "backup_progress_message";
+    public static final String BACKUP_PROGRESS_ERROR_MESSAGE = "backup_progress_error_message";
+
+    private static final int MEDIA_STEP_FACTOR_VIDEOS_AND_FILES = 12;
+    private static final int MEDIA_STEP_FACTOR_THUMBNAILS = 3;
+    private static final int NONCES_PER_STEP = 50;
+    private static final int NONCES_CHUNK_SIZE = 2500;
+    private static final int REACTIONS_PER_STEP = 50;
+    private static final int REACTION_STEP_THRESHOLD = 500;
+
+    private static final String EXTRA_ID_CANCEL = "cnc";
+    public static final String EXTRA_BACKUP_RESTORE_DATA_CONFIG = "ebrdc";
+
+    private static final int BACKUP_NOTIFICATION_ID = 991772;
+    public static final int BACKUP_COMPLETION_NOTIFICATION_ID = 991773;
+    private static final long FILE_SETTLE_DELAY = 5000;
+
+    private static final String INCOMPLETE_BACKUP_FILENAME_PREFIX = "INCOMPLETE-";
+
+    private static final int FG_SERVICE_TYPE = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? FOREGROUND_SERVICE_TYPE_DATA_SYNC : 0;
+
+    private long currentProgressStep = 0;
+    private long processSteps = 0;
+
+    private static boolean backupSuccess = false;
+    private static boolean isCanceled = false;
+    private static boolean isRunning = false;
+
+    private ServiceManager serviceManager;
+    private ContactService contactService;
+    private FileService fileService;
+    private UserService userService;
+    private GroupService groupService;
+    private BallotService ballotService;
+    private DistributionListService distributionListService;
+    private DatabaseServiceNew databaseServiceNew;
+    private PreferenceService preferenceService;
+    private PowerManager.WakeLock wakeLock;
+    private NotificationManagerCompat notificationManagerCompat;
+    private NonceFactory nonceFactory;
+    private EmojiReactionsRepository reactionsRepository;
+
+    private NotificationCompat.Builder notificationBuilder;
+
+    private int latestPercentStep = -1;
+    private long startTime = 0;
+
+    private static DocumentFile backupFile = null;
+    private BackupRestoreDataConfig config = null;
+    private final HashMap<Integer, String> groupUidMap = new HashMap<>();
+    private final Iterator<Integer> randomIterator = RandomUtil.getDistinctRandomIterator();
+
+    public static boolean isRunning() {
+        return isRunning;
+    }
+
+    @Nullable
+    @Override
+    public IBinder onBind(Intent intent) {
+        return null;
+    }
+
+    @Override
+    public int onStartCommand(Intent intent, int flags, int startId) {
+        if (intent != null) {
+            isCanceled = intent.getBooleanExtra(EXTRA_ID_CANCEL, false);
+
+            if (!isCanceled) {
+                config = (BackupRestoreDataConfig) intent.getSerializableExtra(EXTRA_BACKUP_RESTORE_DATA_CONFIG);
+
+                if (config == null || userService.getIdentity() == null || userService.getIdentity().isEmpty()) {
+                    safeStopSelf();
+                    return START_NOT_STICKY;
+                }
+
+                logger.info("Starting backup (backupMedia={})", config.backupMedia());
+
+                // acquire wake locks
+                logger.debug("Acquiring wakelock");
+                PowerManager powerManager = (PowerManager) getApplicationContext().getSystemService(Context.POWER_SERVICE);
+                if (powerManager != null) {
+                    String tag = BuildConfig.APPLICATION_ID + ":backup";
+                    if (Build.VERSION.SDK_INT == Build.VERSION_CODES.M && Build.MANUFACTURER.equals("Huawei")) {
+                        // Huawei will not kill your app if your Wakelock has a well known tag
+                        // see https://dontkillmyapp.com/huawei
+                        tag = "LocationManagerService";
+                    }
+                    wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag);
+                    if (wakeLock != null) {
+                        wakeLock.acquire(DateUtils.DAY_IN_MILLIS);
+                    }
+                }
+                logger.info("Acquiring wakelock success={}", wakeLock != null && wakeLock.isHeld());
+
+                boolean success = false;
+                Date now = new Date();
+                DocumentFile zipFile = null;
+                Uri backupUri = this.fileService.getBackupUri();
+
+                if (backupUri == null) {
+                    showBackupErrorNotification("Destination directory has not been selected yet");
+                    safeStopSelf();
+                    return START_NOT_STICKY;
+                }
+
+                String filename = "threema-backup_" + now.getTime() + "_1";
+
+                if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(backupUri.getScheme())) {
+                    zipFile = DocumentFile.fromFile(new File(backupUri.getPath(), INCOMPLETE_BACKUP_FILENAME_PREFIX + filename + ".zip"));
+                    success = true;
+                } else {
+                    DocumentFile directory = DocumentFile.fromTreeUri(getApplicationContext(), backupUri);
+                    if (directory != null && directory.exists()) {
+                        try {
+                            zipFile = directory.createFile(MimeUtil.MIME_TYPE_ZIP, INCOMPLETE_BACKUP_FILENAME_PREFIX + filename);
+                            if (zipFile != null && zipFile.canWrite()) {
+                                success = true;
+                            }
+                        } catch (Exception e) {
+                            logger.error("Could not create backup file", e);
+                        }
+                    }
+                }
+
+                if (zipFile == null || !success) {
+                    showBackupErrorNotification(getString(R.string.backup_data_no_permission));
+                    safeStopSelf();
+                    return START_NOT_STICKY;
+                }
+
+                backupFile = zipFile;
+
+                showPersistentNotification();
+
+                // close connection
+                try {
+                    serviceManager.stopConnection();
+                } catch (InterruptedException e) {
+                    showBackupErrorNotification("BackupService interrupted");
+                    stopSelf();
+                    return START_NOT_STICKY;
+                }
+
+                new AsyncTask<Void, Void, Boolean>() {
+                    @Override
+                    protected Boolean doInBackground(Void... params) {
+                        return backup();
+                    }
+
+                    @Override
+                    protected void onPostExecute(Boolean success) {
+                        stopSelf();
+                    }
+                }.execute();
+
+                return START_STICKY;
+            } else {
+                Toast.makeText(this, R.string.backup_data_cancelled, Toast.LENGTH_LONG).show();
+            }
+        } else {
+            logger.debug("onStartCommand intent == null");
+
+            onFinished(null);
+        }
+        return START_NOT_STICKY;
+    }
+
+    @Override
+    public void onCreate() {
+        logger.info("onCreate");
+
+        super.onCreate();
+
+        isRunning = true;
+
+        serviceManager = ThreemaApplication.getServiceManager();
+        if (serviceManager == null) {
+            safeStopSelf();
+            return;
+        }
+
+        try {
+            fileService = serviceManager.getFileService();
+            databaseServiceNew = serviceManager.getDatabaseServiceNew();
+            contactService = serviceManager.getContactService();
+            groupService = serviceManager.getGroupService();
+            distributionListService = serviceManager.getDistributionListService();
+            userService = serviceManager.getUserService();
+            ballotService = serviceManager.getBallotService();
+            preferenceService = serviceManager.getPreferenceService();
+            nonceFactory = serviceManager.getNonceFactory();
+            reactionsRepository = serviceManager.getModelRepositories().getEmojiReaction();
+        } catch (Exception e) {
+            logger.error("Exception while setting up backup service", e);
+            safeStopSelf();
+            return;
+        }
+
+        notificationManagerCompat = NotificationManagerCompat.from(this);
+    }
+
+    @Override
+    public void onDestroy() {
+        logger.info("onDestroy success={} canceled={}", backupSuccess, isCanceled);
+
+        if (isCanceled) {
+            onFinished(getString(R.string.backup_data_cancelled));
+        }
+        super.onDestroy();
+    }
+
+    @Override
+    public void onLowMemory() {
+        logger.info("onLowMemory");
+        super.onLowMemory();
+    }
+
+    @Override
+    public void onTaskRemoved(Intent rootIntent) {
+        logger.debug("onTaskRemoved");
+
+        Intent intent = new Intent(this, DummyActivity.class);
+        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        startActivity(intent);
+    }
+
+    private int getStepFactorMedia() {
+        return this.config.backupMedia()
+            ? MEDIA_STEP_FACTOR_VIDEOS_AND_FILES
+            : this.config.backupThumbnails()
+                ? MEDIA_STEP_FACTOR_THUMBNAILS
+                : 1;
+    }
+
+    private boolean backup() {
+        String identity = userService.getIdentity();
+
+        try(final OutputStream outputStream = getContentResolver().openOutputStream(backupFile.getUri());
+            final FileHandlingZipOutputStream zipOutputStream = FileHandlingZipOutputStream.initializeZipOutputStream(outputStream, config.getPassword())) {
+            logger.debug("Creating zip file {}", backupFile.getUri());
+
+            // save settings
+            RestoreSettings settings = new RestoreSettings(RestoreSettings.CURRENT_VERSION);
+            ByteArrayOutputStream settingsBuffer = null;
+            try {
+                settingsBuffer = new ByteArrayOutputStream();
+                CSVWriter settingsCsv = new CSVWriter(new OutputStreamWriter(settingsBuffer));
+                settingsCsv.writeAll(settings.toList());
+                settingsCsv.close();
+            }
+            finally {
+                if (settingsBuffer != null) {
+                    try {
+                        settingsBuffer.close();
+                    } catch (IOException e) { /**/ }
+                }
+            }
+
+            logger.info("Count required steps for backup creation");
+
+            long requiredStepsContactsAndMessages = this.databaseServiceNew.getContactModelFactory().count()
+                + this.databaseServiceNew.getMessageModelFactory().count()
+                + this.databaseServiceNew.getGroupModelFactory().count()
+                + this.databaseServiceNew.getGroupMessageModelFactory().count();
+
+            long requiredStepsDistributionLists = this.databaseServiceNew.getDistributionListModelFactory().count()
+                + this.databaseServiceNew.getDistributionListMessageModelFactory().count();
+
+            long requiredStepsBallots = this.databaseServiceNew.getBallotModelFactory().count();
+
+            long requiredBackupSteps = (this.config.backupIdentity() ? 1 : 0)
+                + (this.config.backupContactAndMessages() ?
+                requiredStepsContactsAndMessages : 0)
+                + (this.config.backupDistributionLists() ?
+                requiredStepsDistributionLists : 0)
+                + (this.config.backupBallots() ?
+                requiredStepsBallots : 0);
+
+            if (this.config.backupMedia() || this.config.backupThumbnails()) {
+                try {
+                    Set<MessageType> fileTypes = this.config.backupMedia() ? MessageUtil.getFileTypes() : MessageUtil.getLowProfileMessageModelTypes();
+                    MessageType[] fileTypesArray = fileTypes.toArray(new MessageType[0]);
+
+                    long requiredStepsMedia = this.databaseServiceNew.getMessageModelFactory().countByTypes(fileTypesArray);
+                    requiredStepsMedia += this.databaseServiceNew.getGroupMessageModelFactory().countByTypes(fileTypesArray);
+
+                    if (this.config.backupDistributionLists()) {
+                        requiredStepsMedia += this.databaseServiceNew.getDistributionListMessageModelFactory().countByTypes(fileTypesArray);
+                    }
+
+                    requiredBackupSteps += (requiredStepsMedia * getStepFactorMedia());
+                } catch (Exception e) {
+                    logger.error("Could not backup media and thumbnails", e);
+                }
+            }
+
+            if (this.config.backupNonces()) {
+                requiredBackupSteps += 1;
+                long nonceCount = nonceFactory.getCount(NonceScope.CSP) + nonceFactory.getCount(NonceScope.D2D);
+                long requiredStepsNonces = (long) Math.ceil((double) nonceCount / NONCES_PER_STEP);
+                requiredBackupSteps += requiredStepsNonces;
+            }
+
+            if (this.config.backupReactions()) {
+                requiredBackupSteps += 2;
+                requiredBackupSteps += reactionsRepository.getContactReactionsCount() / REACTIONS_PER_STEP;
+                requiredBackupSteps += reactionsRepository.getGroupReactionsCount() / REACTIONS_PER_STEP;
+            }
+
+
+            this.initProgress(requiredBackupSteps);
+
+            zipOutputStream.addFileFromInputStream(new ByteArrayInputStream(settingsBuffer.toByteArray()), Tags.SETTINGS_FILE_NAME, true);
+
+            if (this.config.backupIdentity()) {
+                if (!this.backupIdentity(identity, zipOutputStream)) {
+                    return this.cancelBackup(backupFile);
+                }
+            }
+
+            // backup contacts and messages
+            if (this.config.backupContactAndMessages()) {
+                if (!this.backupContactsAndMessages(config, zipOutputStream)) {
+                    return this.cancelBackup(backupFile);
+                }
+            }
+
+            // backup groups and messages
+            if (this.config.backupGroupsAndMessages()) {
+                if (!this.backupGroupsAndMessages(config, zipOutputStream)) {
+                    return this.cancelBackup(backupFile);
+                }
+            }
+
+            // backup distribution lists and messages
+            if (this.config.backupDistributionLists()) {
+                if (!this.backupDistributionListsAndMessages(config, zipOutputStream)) {
+                    return this.cancelBackup(backupFile);
+                }
+            }
+
+            if (this.config.backupBallots()) {
+                if (!this.backupBallots(zipOutputStream)) {
+                    return this.cancelBackup(backupFile);
+                }
+            }
+
+            if (this.config.backupReactions()) {
+                if (!this.backupReactions(zipOutputStream)) {
+                    return this.cancelBackup(backupFile);
+                }
+            }
+
+            // Backup nonces
+            if (this.config.backupNonces()) {
+                if (!this.backupNonces(zipOutputStream)) {
+                    return this.cancelBackup(backupFile);
+                }
+            }
+
+            backupSuccess = true;
+            onFinished("");
+        } catch (final Exception e) {
+            removeBackupFile(backupFile);
+
+            backupSuccess = false;
+            onFinished("Error: " + e.getMessage());
+
+            logger.error("Backup could not be created", e);
+        }
+        return backupSuccess;
+    }
+
+    private boolean backupIdentity(String identity, FileHandlingZipOutputStream zipOutputStream) throws ThreemaException, IOException {
+        logger.info("Backup identity");
+        if (!this.next("backup identity")) {
+            return false;
+        }
+
+        byte[] privateKey = this.userService.getPrivateKey();
+        IdentityBackupGenerator identityBackupGenerator = new IdentityBackupGenerator(identity, privateKey);
+        String backupData = identityBackupGenerator.generateBackup(this.config.getPassword());
+
+        zipOutputStream.addFileFromInputStream(IOUtils.toInputStream(backupData), Tags.IDENTITY_FILE_NAME, false);
+        return true;
+    }
+
+    private boolean next(String subject) {
+        return this.next(subject, 1);
+    }
+
+    private boolean next(String subject, long increment) {
+        logger.debug("step [{}]", subject);
+        this.currentProgressStep += (this.currentProgressStep < this.processSteps ? increment : 0);
+        this.handleProgress();
+        return !isCanceled;
+    }
+
+    /**
+     * only call progress on 100 steps
+     */
+    private void handleProgress() {
+        int p = (int) (100d / (double) this.processSteps * (double) this.currentProgressStep);
+        if (p > this.latestPercentStep) {
+            this.latestPercentStep = p;
+            String timeRemaining = getRemainingTimeText(latestPercentStep, 100);
+            updatePersistentNotification(latestPercentStep, 100, timeRemaining);
+            LocalBroadcastManager.getInstance(ThreemaApplication.getAppContext())
+                .sendBroadcast(new Intent(BACKUP_PROGRESS_INTENT)
+                    .putExtra(BACKUP_PROGRESS, latestPercentStep)
+                    .putExtra(BACKUP_PROGRESS_STEPS, 100)
+                    .putExtra(BACKUP_PROGRESS_MESSAGE, timeRemaining)
+                );
+        }
+    }
+
+    private void removeBackupFile(DocumentFile zipFile) {
+        // remove zip file
+        if (zipFile != null && zipFile.exists()) {
+            logger.info("Remove backup file {}", zipFile.getUri());
+            zipFile.delete();
+        }
+    }
+
+    private boolean cancelBackup(DocumentFile zipFile) {
+        removeBackupFile(zipFile);
+        backupSuccess = false;
+        onFinished(null);
+
+        return false;
+    }
+
+    private void initProgress(long steps) {
+        logger.info("Init progress with {} required steps", steps);
+        this.currentProgressStep = 0;
+        this.processSteps = steps;
+        this.latestPercentStep = 0;
+        this.startTime = System.currentTimeMillis();
+        this.handleProgress();
+    }
+
+    /**
+     * Create a Backup of all contacts and messages.
+     * Backup media if configured.
+     */
+    private boolean backupContactsAndMessages(
+        @NonNull BackupRestoreDataConfig config,
+        @NonNull FileHandlingZipOutputStream zipOutputStream
+    ) throws ThreemaException, IOException {
+        // first, save my own profile pic
+        if (this.config.backupAvatars()) {
+            logger.info("Backup own avatar");
+            try {
+                zipOutputStream.addFileFromInputStream(
+                    this.fileService.getUserDefinedProfilePictureStream(contactService.getMe().getIdentity()),
+                    Tags.CONTACT_AVATAR_FILE_PREFIX + Tags.CONTACT_AVATAR_FILE_SUFFIX_ME,
+                    false
+                );
+            } catch (IOException e) {
+                logger.warn("Could not back up own avatar: {}", e.getMessage());
+            }
+        }
+
+        final String[] contactCsvHeader = {
+            Tags.TAG_CONTACT_IDENTITY,
+            Tags.TAG_CONTACT_PUBLIC_KEY,
+            Tags.TAG_CONTACT_VERIFICATION_LEVEL,
+            Tags.TAG_CONTACT_ANDROID_CONTACT_ID,
+            Tags.TAG_CONTACT_FIRST_NAME,
+            Tags.TAG_CONTACT_LAST_NAME,
+            Tags.TAG_CONTACT_NICK_NAME,
+            Tags.TAG_CONTACT_LAST_UPDATE,
+            Tags.TAG_CONTACT_HIDDEN,
+            Tags.TAG_CONTACT_ARCHIVED,
+            Tags.TAG_CONTACT_IDENTITY_ID,
+        };
+        final String[] messageCsvHeader = {
+            Tags.TAG_MESSAGE_API_MESSAGE_ID,
+            Tags.TAG_MESSAGE_UID,
+            Tags.TAG_MESSAGE_IS_OUTBOX,
+            Tags.TAG_MESSAGE_IS_READ,
+            Tags.TAG_MESSAGE_IS_SAVED,
+            Tags.TAG_MESSAGE_MESSAGE_STATE,
+            Tags.TAG_MESSAGE_POSTED_AT,
+            Tags.TAG_MESSAGE_CREATED_AT,
+            Tags.TAG_MESSAGE_MODIFIED_AT,
+            Tags.TAG_MESSAGE_TYPE,
+            Tags.TAG_MESSAGE_BODY,
+            Tags.TAG_MESSAGE_IS_STATUS_MESSAGE,
+            Tags.TAG_MESSAGE_CAPTION,
+            Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID,
+            Tags.TAG_MESSAGE_DELIVERED_AT,
+            Tags.TAG_MESSAGE_READ_AT,
+            Tags.TAG_GROUP_MESSAGE_STATES,
+            Tags.TAG_MESSAGE_DISPLAY_TAGS,
+            Tags.TAG_MESSAGE_EDITED_AT,
+            Tags.TAG_MESSAGE_DELETED_AT
+        };
+
+        logger.info("Backup contacts, messages, and contact avatars");
+        // Iterate over all contacts. Then backup every contact with the corresponding messages.
+        try (final ByteArrayOutputStream contactBuffer = new ByteArrayOutputStream()) {
+            try (final CSVWriter contactCsv = new CSVWriter(new OutputStreamWriter(contactBuffer), contactCsvHeader)) {
+                for (final ContactModel contactModel : contactService.find(null)) {
+                    if (!this.next("backup contact " + contactModel.getIdentity())) {
+                        return false;
+                    }
+
+                    String identityId = getFormattedUniqueId();
+
+                    // Write contact
+                    contactCsv.createRow()
+                        .write(Tags.TAG_CONTACT_IDENTITY, contactModel.getIdentity())
+                        .write(Tags.TAG_CONTACT_PUBLIC_KEY, Utils.byteArrayToHexString(contactModel.getPublicKey()))
+                        .write(Tags.TAG_CONTACT_VERIFICATION_LEVEL, contactModel.verificationLevel.toString())
+                        .write(Tags.TAG_CONTACT_ANDROID_CONTACT_ID, contactModel.getAndroidContactLookupKey())
+                        .write(Tags.TAG_CONTACT_FIRST_NAME, contactModel.getFirstName())
+                        .write(Tags.TAG_CONTACT_LAST_NAME, contactModel.getLastName())
+                        .write(Tags.TAG_CONTACT_NICK_NAME, contactModel.getPublicNickName())
+                        .write(Tags.TAG_CONTACT_LAST_UPDATE, contactModel.getLastUpdate())
+                        .write(Tags.TAG_CONTACT_HIDDEN, contactModel.getAcquaintanceLevel() == ContactModel.AcquaintanceLevel.GROUP)
+                        .write(Tags.TAG_CONTACT_ARCHIVED, contactModel.isArchived())
+                        .write(Tags.TAG_CONTACT_IDENTITY_ID, identityId)
+                        .write();
+
+                    // Back up contact profile pictures
+                    if (this.config.backupAvatars()) {
+                        try {
+                            if (!userService.getIdentity().equals(contactModel.getIdentity())) {
+                                zipOutputStream.addFileFromInputStream(
+                                    this.fileService.getUserDefinedProfilePictureStream(contactModel.getIdentity()),
+                                    Tags.CONTACT_AVATAR_FILE_PREFIX + identityId,
+                                    false
+                                );
+                            }
+                        } catch (IOException e) {
+                            // avatars are not THAT important, so we don't care if adding them fails
+                            logger.warn("Could not back up avatar for contact {}: {}", contactModel.getIdentity(), e.getMessage());
+                        }
+
+                        try {
+                            zipOutputStream.addFileFromInputStream(
+                                this.fileService.getContactDefinedProfilePictureStream(contactModel.getIdentity()),
+                                Tags.CONTACT_PROFILE_PIC_FILE_PREFIX + identityId,
+                                false
+                            );
+                        } catch (IOException e) {
+                            // profile pics are not THAT important, so we don't care if adding them fails
+                            logger.warn("Could not back up profile pic for contact {}: {}", contactModel.getIdentity(), e.getMessage());
+                        }
+                    }
+
+                    // Back up conversations
+                    try (final ByteArrayOutputStream messageBuffer = new ByteArrayOutputStream()) {
+                        try (final CSVWriter messageCsv = new CSVWriter(new OutputStreamWriter(messageBuffer), messageCsvHeader)) {
+
+                            List<MessageModel> messageModels = this.databaseServiceNew
+                                .getMessageModelFactory()
+                                .getByIdentityUnsorted(contactModel.getIdentity());
+
+                            for (MessageModel messageModel : messageModels) {
+                                if (!this.next("backup message " + messageModel.getId())) {
+                                    return false;
+                                }
+
+                                String apiMessageId = messageModel.getApiMessageId();
+
+                                if ((apiMessageId != null && !apiMessageId.isEmpty()) || messageModel.getType() == MessageType.VOIP_STATUS) {
+                                    messageCsv.createRow()
+                                        .write(Tags.TAG_MESSAGE_API_MESSAGE_ID, messageModel.getApiMessageId())
+                                        .write(Tags.TAG_MESSAGE_UID, messageModel.getUid())
+                                        .write(Tags.TAG_MESSAGE_IS_OUTBOX, messageModel.isOutbox())
+                                        .write(Tags.TAG_MESSAGE_IS_READ, messageModel.isRead())
+                                        .write(Tags.TAG_MESSAGE_IS_SAVED, messageModel.isSaved())
+                                        .write(Tags.TAG_MESSAGE_MESSAGE_STATE, messageModel.getState())
+                                        .write(Tags.TAG_MESSAGE_POSTED_AT, messageModel.getPostedAt())
+                                        .write(Tags.TAG_MESSAGE_CREATED_AT, messageModel.getCreatedAt())
+                                        .write(Tags.TAG_MESSAGE_MODIFIED_AT, messageModel.getModifiedAt())
+                                        .write(Tags.TAG_MESSAGE_TYPE, messageModel.getType().toString())
+                                        .write(Tags.TAG_MESSAGE_BODY, messageModel.getBody())
+                                        .write(Tags.TAG_MESSAGE_IS_STATUS_MESSAGE, messageModel.isStatusMessage())
+                                        .write(Tags.TAG_MESSAGE_CAPTION, messageModel.getCaption())
+                                        .write(Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID, messageModel.getQuotedMessageId())
+                                        .write(Tags.TAG_MESSAGE_DELIVERED_AT, messageModel.getDeliveredAt())
+                                        .write(Tags.TAG_MESSAGE_READ_AT, messageModel.getReadAt())
+                                        .write(Tags.TAG_MESSAGE_DISPLAY_TAGS, messageModel.getDisplayTags())
+                                        .write(Tags.TAG_MESSAGE_EDITED_AT, messageModel.getEditedAt())
+                                        .write(Tags.TAG_MESSAGE_DELETED_AT, messageModel.getDeletedAt())
+                                        .write();
+                                }
+
+                                this.backupMediaFile(
+                                    config,
+                                    zipOutputStream,
+                                    Tags.MESSAGE_MEDIA_FILE_PREFIX,
+                                    Tags.MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX,
+                                    messageModel);
+                            }
+                        }
+
+                        zipOutputStream.addFileFromInputStream(
+                            new ByteArrayInputStream(messageBuffer.toByteArray()),
+                            Tags.MESSAGE_FILE_PREFIX + identityId + Tags.CSV_FILE_POSTFIX,
+                            true
+                        );
+                    }
+                }
+            }
+
+            zipOutputStream.addFileFromInputStream(
+                new ByteArrayInputStream(contactBuffer.toByteArray()),
+                Tags.CONTACTS_FILE_NAME + Tags.CSV_FILE_POSTFIX,
+                true
+            );
+        }
+
+        return true;
+    }
+
+    /**
+     * Backup all groups with messages and media (if configured).
+     */
+    private boolean backupGroupsAndMessages(
+        @NonNull BackupRestoreDataConfig config,
+        @NonNull FileHandlingZipOutputStream zipOutputStream
+    ) throws ThreemaException, IOException {
+        final String[] groupCsvHeader = {
+            Tags.TAG_GROUP_ID,
+            Tags.TAG_GROUP_CREATOR,
+            Tags.TAG_GROUP_NAME,
+            Tags.TAG_GROUP_CREATED_AT,
+            Tags.TAG_GROUP_LAST_UPDATE,
+            Tags.TAG_GROUP_MEMBERS,
+            Tags.TAG_GROUP_DELETED,
+            Tags.TAG_GROUP_ARCHIVED,
+            Tags.TAG_GROUP_DESC,
+            Tags.TAG_GROUP_DESC_TIMESTAMP,
+            Tags.TAG_GROUP_UID,
+            Tags.TAG_GROUP_USER_STATE,
+        };
+        final String[] groupMessageCsvHeader = {
+            Tags.TAG_MESSAGE_API_MESSAGE_ID,
+            Tags.TAG_MESSAGE_UID,
+            Tags.TAG_MESSAGE_IDENTITY,
+            Tags.TAG_MESSAGE_IS_OUTBOX,
+            Tags.TAG_MESSAGE_IS_READ,
+            Tags.TAG_MESSAGE_IS_SAVED,
+            Tags.TAG_MESSAGE_MESSAGE_STATE,
+            Tags.TAG_MESSAGE_POSTED_AT,
+            Tags.TAG_MESSAGE_CREATED_AT,
+            Tags.TAG_MESSAGE_MODIFIED_AT,
+            Tags.TAG_MESSAGE_TYPE,
+            Tags.TAG_MESSAGE_BODY,
+            Tags.TAG_MESSAGE_IS_STATUS_MESSAGE,
+            Tags.TAG_MESSAGE_CAPTION,
+            Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID,
+            Tags.TAG_MESSAGE_DELIVERED_AT,
+            Tags.TAG_MESSAGE_READ_AT,
+            Tags.TAG_GROUP_MESSAGE_STATES,
+            Tags.TAG_MESSAGE_DISPLAY_TAGS,
+            Tags.TAG_MESSAGE_EDITED_AT,
+            Tags.TAG_MESSAGE_DELETED_AT
+        };
+
+        final GroupService.GroupFilter groupFilter = new GroupService.GroupFilter() {
+            @Override
+            public boolean sortByDate() {
+                return false;
+            }
+
+            @Override
+            public boolean sortByName() {
+                return false;
+            }
+
+            @Override
+            public boolean sortAscending() {
+                return false;
+            }
+
+            @Override
+            public boolean includeDeletedGroups() {
+                return true;
+            }
+
+            @Override
+            public boolean includeLeftGroups() {
+                return true;
+            }
+        };
+
+        logger.info("Backup groups, messages and group avatars");
+        try (final ByteArrayOutputStream groupBuffer = new ByteArrayOutputStream()) {
+            try (final CSVWriter groupCsv = new CSVWriter(new OutputStreamWriter(groupBuffer), groupCsvHeader)) {
+                for (final GroupModel groupModel : this.groupService.getAll(groupFilter)) {
+                    String groupUid = getFormattedUniqueId();
+                    groupUidMap.put(groupModel.getId(), groupUid);
+
+                    if (!this.next("backup group " + groupModel.getApiGroupId())) {
+                        return false;
+                    }
+
+                    groupCsv.createRow()
+                        .write(Tags.TAG_GROUP_ID, groupModel.getApiGroupId())
+                        .write(Tags.TAG_GROUP_CREATOR, groupModel.getCreatorIdentity())
+                        .write(Tags.TAG_GROUP_NAME, groupModel.getName())
+                        .write(Tags.TAG_GROUP_CREATED_AT, groupModel.getCreatedAt())
+                        .write(Tags.TAG_GROUP_LAST_UPDATE, groupModel.getLastUpdate())
+                        .write(Tags.TAG_GROUP_MEMBERS, this.groupService.getGroupIdentities(groupModel))
+                        .write(Tags.TAG_GROUP_DELETED, groupModel.isDeleted())
+                        .write(Tags.TAG_GROUP_ARCHIVED, groupModel.isArchived())
+                        .write(Tags.TAG_GROUP_DESC, groupModel.getGroupDesc())
+                        .write(Tags.TAG_GROUP_DESC_TIMESTAMP, groupModel.getGroupDescTimestamp())
+                        .write(Tags.TAG_GROUP_UID, groupUid)
+                        .write(Tags.TAG_GROUP_USER_STATE, groupModel.getUserState() != null ? groupModel.getUserState().value : 0)
+                        .write();
+
+                    // check if the group have a photo
+                    if (this.config.backupAvatars()) {
+                        try {
+                            zipOutputStream.addFileFromInputStream(this.fileService.getGroupAvatarStream(groupModel), Tags.GROUP_AVATAR_PREFIX + groupUid, false);
+                        } catch (Exception e) {
+                            logger.warn("Could not back up group avatar: {}", e.getMessage());
+                        }
+                    }
+
+                    // Back up group messages
+                    try (final ByteArrayOutputStream groupMessageBuffer = new ByteArrayOutputStream()) {
+                        try (final CSVWriter groupMessageCsv = new CSVWriter(new OutputStreamWriter(groupMessageBuffer), groupMessageCsvHeader)) {
+                            List<GroupMessageModel> groupMessageModels = this.databaseServiceNew
+                                .getGroupMessageModelFactory()
+                                .getByGroupIdUnsorted(groupModel.getId());
+
+                            for (GroupMessageModel groupMessageModel : groupMessageModels) {
+                                if (!this.next("backup group message " + groupMessageModel.getUid())) {
+                                    return false;
+                                }
+
+                                String groupMessageStates = "";
+                                if (groupMessageModel.getGroupMessageStates() != null) {
+                                    groupMessageStates = new JSONObject(groupMessageModel.getGroupMessageStates()).toString();
+                                }
+
+                                groupMessageCsv.createRow()
+                                    .write(Tags.TAG_MESSAGE_API_MESSAGE_ID, groupMessageModel.getApiMessageId())
+                                    .write(Tags.TAG_MESSAGE_UID, groupMessageModel.getUid())
+                                    .write(Tags.TAG_MESSAGE_IDENTITY, groupMessageModel.getIdentity())
+                                    .write(Tags.TAG_MESSAGE_IS_OUTBOX, groupMessageModel.isOutbox())
+                                    .write(Tags.TAG_MESSAGE_IS_READ, groupMessageModel.isRead())
+                                    .write(Tags.TAG_MESSAGE_IS_SAVED, groupMessageModel.isSaved())
+                                    .write(Tags.TAG_MESSAGE_MESSAGE_STATE, groupMessageModel.getState())
+                                    .write(Tags.TAG_MESSAGE_POSTED_AT, groupMessageModel.getPostedAt())
+                                    .write(Tags.TAG_MESSAGE_CREATED_AT, groupMessageModel.getCreatedAt())
+                                    .write(Tags.TAG_MESSAGE_MODIFIED_AT, groupMessageModel.getModifiedAt())
+                                    .write(Tags.TAG_MESSAGE_TYPE, groupMessageModel.getType())
+                                    .write(Tags.TAG_MESSAGE_BODY, groupMessageModel.getBody())
+                                    .write(Tags.TAG_MESSAGE_IS_STATUS_MESSAGE, groupMessageModel.isStatusMessage())
+                                    .write(Tags.TAG_MESSAGE_CAPTION, groupMessageModel.getCaption())
+                                    .write(Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID, groupMessageModel.getQuotedMessageId())
+                                    .write(Tags.TAG_MESSAGE_DELIVERED_AT, groupMessageModel.getDeliveredAt())
+                                    .write(Tags.TAG_MESSAGE_READ_AT, groupMessageModel.getReadAt())
+                                    .write(Tags.TAG_GROUP_MESSAGE_STATES, groupMessageStates)
+                                    .write(Tags.TAG_MESSAGE_DISPLAY_TAGS, groupMessageModel.getDisplayTags())
+                                    .write(Tags.TAG_MESSAGE_EDITED_AT, groupMessageModel.getEditedAt())
+                                    .write(Tags.TAG_MESSAGE_DELETED_AT, groupMessageModel.getDeletedAt())
+                                    .write();
+
+                                this.backupMediaFile(
+                                    config,
+                                    zipOutputStream,
+                                    Tags.GROUP_MESSAGE_MEDIA_FILE_PREFIX,
+                                    Tags.GROUP_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX,
+                                    groupMessageModel
+                                );
+                            }
+                        }
+
+                        zipOutputStream.addFileFromInputStream(
+                            new ByteArrayInputStream(groupMessageBuffer.toByteArray()),
+                            Tags.GROUP_MESSAGE_FILE_PREFIX + groupUid + Tags.CSV_FILE_POSTFIX,
+                            true
+                        );
+                    }
+                }
+            }
+
+            zipOutputStream.addFileFromInputStream(
+                new ByteArrayInputStream(groupBuffer.toByteArray()),
+                Tags.GROUPS_FILE_NAME + Tags.CSV_FILE_POSTFIX,
+                true
+            );
+        }
+
+        return true;
+    }
+
+    /**
+     * Backup the reactions. Note that reactions will be sorted by the referenced messages to enable
+     * efficient restoring of the reactions.
+     * The sorting occurs directly in the {@link ch.threema.data.storage.EmojiReactionsDao} when the
+     * reactions are queried for backup creation.
+     */
+    private boolean backupReactions(@NonNull FileHandlingZipOutputStream zipOutputStream) throws ThreemaException {
+        @Nullable Long contactReactionCount = backupContactReactions(zipOutputStream);
+        if (contactReactionCount == null) {
+            return false;
+        }
+
+        @Nullable Long groupReactionCount = backupGroupReactions(zipOutputStream);
+        if (groupReactionCount == null) {
+            return false;
+        }
+
+        boolean success = writeReactionCounts(contactReactionCount, groupReactionCount, zipOutputStream);
+        logger.info("Reaction backup completed");
+        return success;
+    }
+
+    private boolean writeReactionCounts(
+        long contactReactionCount,
+        long groupReactionCount,
+        @NonNull FileHandlingZipOutputStream zipOutputStream
+    ) throws ThreemaException {
+        logger.info("Write reaction counts (contactReactions={}, groupReactions={})", contactReactionCount, groupReactionCount);
+        final String[] reactionCountsHeader = new String[]{ Tags.TAG_REACTION_COUNT_CONTACTS, Tags.TAG_REACTION_COUNT_GROUPS };
+
+        zipOutputStream.addFile(
+            Tags.REACTION_COUNTS_FILE + Tags.CSV_FILE_POSTFIX,
+            /* compress */ true,
+            outputStream -> {
+                try (final CSVWriter csvWriter = new CSVWriter(new OutputStreamWriter(outputStream), reactionCountsHeader)) {
+                    csvWriter.createRow()
+                        .write(Tags.TAG_REACTION_COUNT_CONTACTS, contactReactionCount)
+                        .write(Tags.TAG_REACTION_COUNT_GROUPS, groupReactionCount)
+                        .write();
+                }
+            }
+        );
+        return true;
+    }
+
+    @Nullable
+    private Long backupContactReactions(@NonNull FileHandlingZipOutputStream zipOutputStream) throws ThreemaException {
+        logger.info("Backup contact reactions");
+        if (!next("contact reactions")) {
+            logger.info("Backup of contact reactions cancelled");
+            return null;
+        }
+
+        final String[] contactReactionsCsvHeader = {
+            Tags.TAG_REACTION_CONTACT_IDENTITY,
+            Tags.TAG_REACTION_API_MESSAGE_ID,
+            Tags.TAG_REACTION_SENDER_IDENTITY,
+            Tags.TAG_REACTION_EMOJI_SEQUENCE,
+            Tags.TAG_REACTION_REACTED_AT
+        };
+
+        final Counter rowCounter = new Counter(REACTIONS_PER_STEP);
+        zipOutputStream.addFile(
+            Tags.CONTACT_REACTIONS_FILE_NAME + Tags.CSV_FILE_POSTFIX,
+            true,
+            outputStream -> {
+                try (final CSVWriter csvWriter = new CSVWriter(new OutputStreamWriter(outputStream), contactReactionsCsvHeader)) {
+                    reactionsRepository.iterateAllContactReactionsForBackup(reaction -> {
+                        csvWriter.createRow()
+                            .write(Tags.TAG_REACTION_CONTACT_IDENTITY, reaction.getContactIdentity())
+                            .write(Tags.TAG_REACTION_API_MESSAGE_ID, reaction.getApiMessageId())
+                            .write(Tags.TAG_REACTION_SENDER_IDENTITY, reaction.getSenderIdentity())
+                            .write(Tags.TAG_REACTION_EMOJI_SEQUENCE, reaction.getEmojiSequence())
+                            .write(Tags.TAG_REACTION_REACTED_AT, reaction.getReactedAt())
+                            .write();
+                        rowCounter.count();
+                        long steps = rowCounter.getAndResetSteps(REACTION_STEP_THRESHOLD);
+                        if (steps > 0) {
+                            // Backing up of reactions cannot be cancelled in this scope (with a reasonable
+                            // effort) therefore we only update the progress and ignore the return value.
+                            next("backup contact reactions", steps);
+                        }
+                    });
+                    next("backup contact reactions done", rowCounter.getSteps());
+                }
+            }
+        );
+        return rowCounter.getCount();
+    }
+
+
+    @Nullable
+    private Long backupGroupReactions(@NonNull FileHandlingZipOutputStream zipOutputStream) throws ThreemaException {
+        logger.info("Backup group reactions");
+        if (!next("group reactions")) {
+            logger.info("Backup uf group reactions cancelled");
+            return null;
+        }
+
+        final String[] contactReactionsCsvHeader = {
+            Tags.TAG_REACTION_API_GROUP_ID,
+            Tags.TAG_REACTION_GROUP_CREATOR_IDENTITY,
+            Tags.TAG_REACTION_API_MESSAGE_ID,
+            Tags.TAG_REACTION_SENDER_IDENTITY,
+            Tags.TAG_REACTION_EMOJI_SEQUENCE,
+            Tags.TAG_REACTION_REACTED_AT
+        };
+
+        final Counter rowCounter = new Counter(REACTIONS_PER_STEP);
+        zipOutputStream.addFile(
+            Tags.GROUP_REACTIONS_FILE_NAME + Tags.CSV_FILE_POSTFIX,
+            true,
+            outputStream -> {
+                try (final CSVWriter csvWriter = new CSVWriter(new OutputStreamWriter(outputStream), contactReactionsCsvHeader)) {
+                    reactionsRepository.iterateAllGroupReactionsForBackup(reaction -> {
+                        csvWriter.createRow()
+                            .write(Tags.TAG_REACTION_API_GROUP_ID, reaction.getApiGroupId())
+                            .write(Tags.TAG_REACTION_GROUP_CREATOR_IDENTITY, reaction.getGroupCreatorIdentity())
+                            .write(Tags.TAG_REACTION_API_MESSAGE_ID, reaction.getApiMessageId())
+                            .write(Tags.TAG_REACTION_SENDER_IDENTITY, reaction.getSenderIdentity())
+                            .write(Tags.TAG_REACTION_EMOJI_SEQUENCE, reaction.getEmojiSequence())
+                            .write(Tags.TAG_REACTION_REACTED_AT, reaction.getReactedAt())
+                            .write();
+                        rowCounter.count();
+                        long steps = rowCounter.getAndResetSteps(REACTION_STEP_THRESHOLD);
+                        if (steps > 0) {
+                            // Backing up of reactions cannot be cancelled in this scope (with a reasonable
+                            // effort) therefore we only update the progress and ignore the return value.
+                            next("backup group reactions", steps);
+                        }
+                    });
+                    next("backup group reactions done", rowCounter.getSteps());
+                }
+            }
+        );
+        return rowCounter.getCount();
+    }
+
+
+    /**
+     * backup all ballots with votes and choices!
+     */
+    private boolean backupBallots(
+        @NonNull FileHandlingZipOutputStream zipOutputStream
+    ) throws ThreemaException, IOException {
+        logger.info("Backup polls (formerly known as 'ballots'");
+        final String[] ballotCsvHeader = {
+            Tags.TAG_BALLOT_ID,
+            Tags.TAG_BALLOT_API_ID,
+            Tags.TAG_BALLOT_API_CREATOR,
+            Tags.TAG_BALLOT_REF,
+            Tags.TAG_BALLOT_REF_ID,
+            Tags.TAG_BALLOT_NAME,
+            Tags.TAG_BALLOT_STATE,
+            Tags.TAG_BALLOT_ASSESSMENT,
+            Tags.TAG_BALLOT_TYPE,
+            Tags.TAG_BALLOT_C_TYPE,
+            Tags.TAG_BALLOT_LAST_VIEWED_AT,
+            Tags.TAG_BALLOT_CREATED_AT,
+            Tags.TAG_BALLOT_MODIFIED_AT,
+        };
+        final String[] ballotChoiceCsvHeader = {
+            Tags.TAG_BALLOT_CHOICE_ID,
+            Tags.TAG_BALLOT_CHOICE_BALLOT_UID,
+            Tags.TAG_BALLOT_CHOICE_API_ID,
+            Tags.TAG_BALLOT_CHOICE_TYPE,
+            Tags.TAG_BALLOT_CHOICE_NAME,
+            Tags.TAG_BALLOT_CHOICE_VOTE_COUNT,
+            Tags.TAG_BALLOT_CHOICE_ORDER,
+            Tags.TAG_BALLOT_CHOICE_CREATED_AT,
+            Tags.TAG_BALLOT_CHOICE_MODIFIED_AT,
+        };
+        final String[] ballotVoteCsvHeader = {
+            Tags.TAG_BALLOT_VOTE_ID,
+            Tags.TAG_BALLOT_VOTE_BALLOT_UID,
+            Tags.TAG_BALLOT_VOTE_CHOICE_UID,
+            Tags.TAG_BALLOT_VOTE_IDENTITY,
+            Tags.TAG_BALLOT_VOTE_CHOICE,
+            Tags.TAG_BALLOT_VOTE_CREATED_AT,
+            Tags.TAG_BALLOT_VOTE_MODIFIED_AT,
+        };
+
+        try (
+            final ByteArrayOutputStream ballotCsvBuffer = new ByteArrayOutputStream();
+            final ByteArrayOutputStream ballotChoiceCsvBuffer = new ByteArrayOutputStream();
+            final ByteArrayOutputStream ballotVoteCsvBuffer = new ByteArrayOutputStream()
+        ) {
+            try (
+                final OutputStreamWriter ballotOsw = new OutputStreamWriter(ballotCsvBuffer);
+                final OutputStreamWriter ballotChoiceOsw = new OutputStreamWriter(ballotChoiceCsvBuffer);
+                final OutputStreamWriter ballotVoteOsw = new OutputStreamWriter(ballotVoteCsvBuffer);
+                final CSVWriter ballotCsv = new CSVWriter(ballotOsw, ballotCsvHeader);
+                final CSVWriter ballotChoiceCsv = new CSVWriter(ballotChoiceOsw, ballotChoiceCsvHeader);
+                final CSVWriter ballotVoteCsv = new CSVWriter(ballotVoteOsw, ballotVoteCsvHeader)
+            ) {
+
+                List<BallotModel> ballots = ballotService.getBallots(new BallotService.BallotFilter() {
+                    @Override
+                    public MessageReceiver getReceiver() {
+                        return null;
+                    }
+
+                    @Override
+                    public BallotModel.State[] getStates() {
+                        return new BallotModel.State[]{BallotModel.State.OPEN, BallotModel.State.CLOSED};
+                    }
+
+                    @Override
+                    public boolean filter(BallotModel ballotModel) {
+                        return true;
+                    }
+                });
+
+                if (ballots != null) {
+                    for (BallotModel ballotModel : ballots) {
+                        if (!this.next("ballot " + ballotModel.getId())) {
+                            return false;
+                        }
+
+                        LinkBallotModel link = ballotService.getLinkedBallotModel(ballotModel);
+                        if (link == null) {
+                            continue;
+                        }
+
+                        String ref;
+                        String refId;
+                        if (link instanceof GroupBallotModel) {
+                            GroupModel groupModel = groupService
+                                .getById(((GroupBallotModel) link).getGroupId());
+
+                            if (groupModel == null) {
+                                logger.error("invalid group for a ballot");
+                                continue;
+                            }
+
+                            ref = "GroupBallotModel";
+                            refId = groupUidMap.get(groupModel.getId());
+                        } else if (link instanceof IdentityBallotModel) {
+                            ref = "IdentityBallotModel";
+                            refId = ((IdentityBallotModel) link).getIdentity();
+                        } else {
+                            continue;
+                        }
+
+                        ballotCsv.createRow()
+                            .write(Tags.TAG_BALLOT_ID, ballotModel.getId())
+                            .write(Tags.TAG_BALLOT_API_ID, ballotModel.getApiBallotId())
+                            .write(Tags.TAG_BALLOT_API_CREATOR, ballotModel.getCreatorIdentity())
+                            .write(Tags.TAG_BALLOT_REF, ref)
+                            .write(Tags.TAG_BALLOT_REF_ID, refId)
+                            .write(Tags.TAG_BALLOT_NAME, ballotModel.getName())
+                            .write(Tags.TAG_BALLOT_STATE, ballotModel.getState())
+                            .write(Tags.TAG_BALLOT_ASSESSMENT, ballotModel.getAssessment())
+                            .write(Tags.TAG_BALLOT_TYPE, ballotModel.getType())
+                            .write(Tags.TAG_BALLOT_C_TYPE, ballotModel.getChoiceType())
+                            .write(Tags.TAG_BALLOT_LAST_VIEWED_AT, ballotModel.getLastViewedAt())
+                            .write(Tags.TAG_BALLOT_CREATED_AT, ballotModel.getCreatedAt())
+                            .write(Tags.TAG_BALLOT_MODIFIED_AT, ballotModel.getModifiedAt())
+                            .write();
+
+
+                        final List<BallotChoiceModel> ballotChoiceModels = this.databaseServiceNew
+                            .getBallotChoiceModelFactory()
+                            .getByBallotId(ballotModel.getId());
+                        for (BallotChoiceModel ballotChoiceModel : ballotChoiceModels) {
+                            ballotChoiceCsv.createRow()
+                                .write(Tags.TAG_BALLOT_CHOICE_ID, ballotChoiceModel.getId())
+                                .write(Tags.TAG_BALLOT_CHOICE_BALLOT_UID, BackupUtils.buildBallotUid(ballotModel))
+                                .write(Tags.TAG_BALLOT_CHOICE_API_ID, ballotChoiceModel.getApiBallotChoiceId())
+                                .write(Tags.TAG_BALLOT_CHOICE_TYPE, ballotChoiceModel.getType())
+                                .write(Tags.TAG_BALLOT_CHOICE_NAME, ballotChoiceModel.getName())
+                                .write(Tags.TAG_BALLOT_CHOICE_VOTE_COUNT, ballotChoiceModel.getVoteCount())
+                                .write(Tags.TAG_BALLOT_CHOICE_ORDER, ballotChoiceModel.getOrder())
+                                .write(Tags.TAG_BALLOT_CHOICE_CREATED_AT, ballotChoiceModel.getCreatedAt())
+                                .write(Tags.TAG_BALLOT_CHOICE_MODIFIED_AT, ballotChoiceModel.getModifiedAt())
+                                .write();
+
+                        }
+
+                        final List<BallotVoteModel> ballotVoteModels = this.databaseServiceNew
+                            .getBallotVoteModelFactory()
+                            .getByBallotId(ballotModel.getId());
+                        for (final BallotVoteModel ballotVoteModel : ballotVoteModels) {
+                            BallotChoiceModel ballotChoiceModel = Functional.select(ballotChoiceModels, type -> type.getId() == ballotVoteModel.getBallotChoiceId());
+
+                            if (ballotChoiceModel == null) {
+                                continue;
+                            }
+
+                            ballotVoteCsv.createRow()
+                                .write(Tags.TAG_BALLOT_VOTE_ID, ballotVoteModel.getId())
+                                .write(Tags.TAG_BALLOT_VOTE_BALLOT_UID, BackupUtils.buildBallotUid(ballotModel))
+                                .write(Tags.TAG_BALLOT_VOTE_CHOICE_UID, BackupUtils.buildBallotChoiceUid(ballotChoiceModel))
+                                .write(Tags.TAG_BALLOT_VOTE_IDENTITY, ballotVoteModel.getVotingIdentity())
+                                .write(Tags.TAG_BALLOT_VOTE_CHOICE, ballotVoteModel.getChoice())
+                                .write(Tags.TAG_BALLOT_VOTE_CREATED_AT, ballotVoteModel.getCreatedAt())
+                                .write(Tags.TAG_BALLOT_VOTE_MODIFIED_AT, ballotVoteModel.getModifiedAt())
+                                .write();
+
+                        }
+                    }
+                }
+            }
+
+            zipOutputStream.addFileFromInputStream(
+                new ByteArrayInputStream(ballotCsvBuffer.toByteArray()),
+                Tags.BALLOT_FILE_NAME + Tags.CSV_FILE_POSTFIX,
+                true
+            );
+            zipOutputStream.addFileFromInputStream(
+                new ByteArrayInputStream(ballotChoiceCsvBuffer.toByteArray()),
+                Tags.BALLOT_CHOICE_FILE_NAME + Tags.CSV_FILE_POSTFIX,
+                true
+            );
+            zipOutputStream.addFileFromInputStream(
+                new ByteArrayInputStream(ballotVoteCsvBuffer.toByteArray()),
+                Tags.BALLOT_VOTE_FILE_NAME + Tags.CSV_FILE_POSTFIX,
+                true
+            );
+
+        }
+
+        return true;
+    }
+
+    private boolean backupNonces(@NonNull FileHandlingZipOutputStream zipOutputStream) {
+        logger.info("Backup nonces");
+
+        if (!next("Backup nonces")) {
+            return false;
+        }
+
+        try {
+            int nonceCountCsp = writeNoncesToBackup(
+                NonceScope.CSP,
+                Tags.NONCE_FILE_NAME_CSP + Tags.CSV_FILE_POSTFIX,
+                zipOutputStream
+            );
+
+            int nonceCountD2d = writeNoncesToBackup(
+                NonceScope.D2D,
+                Tags.NONCE_FILE_NAME_D2D + Tags.CSV_FILE_POSTFIX,
+                zipOutputStream
+            );
+
+            writeNonceCounts(nonceCountCsp, nonceCountD2d, zipOutputStream);
+
+            int remainingCsp = BackupUtils.calcRemainingNoncesProgress(NONCES_CHUNK_SIZE, NONCES_PER_STEP, nonceCountCsp);
+            int remainingD2d = BackupUtils.calcRemainingNoncesProgress(NONCES_CHUNK_SIZE, NONCES_PER_STEP, nonceCountD2d);
+            next("Backup nonce", (int) Math.ceil(((double) remainingCsp + remainingD2d) / NONCES_PER_STEP));
+            logger.info("Nonce backup completed");
+        } catch (IOException | ThreemaException e) {
+            logger.error("Error with byte array output stream", e);
+            return false;
+        }
+
+        return true;
+    }
+
+    private void writeNonceCounts(
+        int nonceCountCsp,
+        int nonceCountD2d,
+        @NonNull FileHandlingZipOutputStream zipOutputStream
+    ) throws IOException, ThreemaException {
+        logger.info("Write nonce counts to backup (CSP: {}, D2D: {})", nonceCountCsp, nonceCountD2d);
+        final String[] nonceCountHeader = new String[]{ Tags.TAG_NONCE_COUNT_CSP, Tags.TAG_NONCE_COUNT_D2D };
+        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+            try (
+                OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
+                CSVWriter csvWriter = new CSVWriter(outputStreamWriter, nonceCountHeader)
+            ) {
+                csvWriter.createRow()
+                    .write(Tags.TAG_NONCE_COUNT_CSP, nonceCountCsp)
+                    .write(Tags.TAG_NONCE_COUNT_D2D, nonceCountD2d)
+                    .write();
+            }
+            zipOutputStream.addFileFromInputStream(
+                new ByteArrayInputStream(outputStream.toByteArray()),
+                Tags.NONCE_COUNTS_FILE + Tags.CSV_FILE_POSTFIX,
+                false
+            );
+        }
+    }
+
+    private int writeNoncesToBackup(
+        @NonNull NonceScope scope,
+        @NonNull String fileName,
+        @NonNull FileHandlingZipOutputStream zipOutputStream
+    ) throws ThreemaException, IOException {
+        try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+            int count = writeNonces(scope, outputStream);
+            // Write nonces to zip *after* the CSVWriter has been closed (and therefore flushed)
+            zipOutputStream.addFileFromInputStream(
+                new ByteArrayInputStream(outputStream.toByteArray()),
+                fileName,
+                true
+            );
+            return count;
+        }
+    }
+
+    private int writeNonces(
+        @NonNull NonceScope scope,
+        @NonNull ByteArrayOutputStream outputStream
+    ) throws ThreemaException, IOException {
+        logger.info("Backup {} nonces", scope);
+        final String[] nonceHeader = new String[]{Tags.TAG_NONCES};
+        int backedUpNonceCount = 0;
+        try (
+            OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
+            CSVWriter csvWriter = new CSVWriter(outputStreamWriter, nonceHeader)
+        ) {
+            long start = System.currentTimeMillis();
+            long nonceCount = nonceFactory.getCount(scope);
+            long numChunks = (long) Math.ceil((double) nonceCount / NONCES_CHUNK_SIZE);
+            List<HashedNonce> nonces = new ArrayList<>(NONCES_CHUNK_SIZE);
+            for (int i = 0; i < numChunks; i++) {
+                nonceFactory.addHashedNoncesChunk(
+                    scope,
+                    NONCES_CHUNK_SIZE,
+                    NONCES_CHUNK_SIZE * i,
+                    nonces
+                );
+                for (HashedNonce hashedNonce : nonces) {
+                    String nonce = Utils.byteArrayToHexString(hashedNonce.getBytes());
+                    csvWriter.createRow().write(Tags.TAG_NONCES, nonce).write();
+                }
+                int increment = nonces.size() / NONCES_PER_STEP;
+                backedUpNonceCount += nonces.size();
+                nonces.clear();
+                if (!next("Backup nonce", increment)) {
+                    return backedUpNonceCount;
+                }
+                // Periodically log nonce backup progress for debugging purposes
+                if ((i % 10) == 0 || i == numChunks) {
+                    logger.info("Nonce backup progress: {} of {} chunks backed up", i, numChunks);
+                }
+            }
+            long end = System.currentTimeMillis();
+            logger.info("Created backup for all {} nonces in {} ms", scope, end - start);
+        }
+        return backedUpNonceCount;
+    }
+
+    /**
+     * Create the distribution list zip file.
+     */
+    private boolean backupDistributionListsAndMessages(
+        @NonNull BackupRestoreDataConfig config,
+        @NonNull FileHandlingZipOutputStream zipOutputStream
+    ) throws ThreemaException, IOException {
+        final String[] distributionListCsvHeader = {
+            Tags.TAG_DISTRIBUTION_LIST_ID,
+            Tags.TAG_DISTRIBUTION_LIST_NAME,
+            Tags.TAG_DISTRIBUTION_CREATED_AT,
+            Tags.TAG_DISTRIBUTION_LAST_UPDATE,
+            Tags.TAG_DISTRIBUTION_MEMBERS,
+            Tags.TAG_DISTRIBUTION_LIST_ARCHIVED,
+        };
+        final String[] distributionListMessageCsvHeader = {
+            Tags.TAG_MESSAGE_API_MESSAGE_ID,
+            Tags.TAG_MESSAGE_UID,
+            Tags.TAG_MESSAGE_IDENTITY,
+            Tags.TAG_MESSAGE_IS_OUTBOX,
+            Tags.TAG_MESSAGE_IS_READ,
+            Tags.TAG_MESSAGE_IS_SAVED,
+            Tags.TAG_MESSAGE_MESSAGE_STATE,
+            Tags.TAG_MESSAGE_POSTED_AT,
+            Tags.TAG_MESSAGE_CREATED_AT,
+            Tags.TAG_MESSAGE_MODIFIED_AT,
+            Tags.TAG_MESSAGE_TYPE,
+            Tags.TAG_MESSAGE_BODY,
+            Tags.TAG_MESSAGE_IS_STATUS_MESSAGE,
+            Tags.TAG_MESSAGE_CAPTION,
+            Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID,
+            Tags.TAG_MESSAGE_DELIVERED_AT,
+            Tags.TAG_MESSAGE_READ_AT,
+        };
+
+        logger.info("Backup distribution lists, messages and list avatars");
+        try (final ByteArrayOutputStream distributionListBuffer = new ByteArrayOutputStream()) {
+            try (final CSVWriter distributionListCsv = new CSVWriter(new OutputStreamWriter(distributionListBuffer), distributionListCsvHeader)) {
+
+                for (DistributionListModel distributionListModel : distributionListService.getAll()) {
+                    if (!this.next("distribution list " + distributionListModel.getId())) {
+                        return false;
+                    }
+                    distributionListCsv.createRow()
+                        .write(Tags.TAG_DISTRIBUTION_LIST_ID, distributionListModel.getId())
+                        .write(Tags.TAG_DISTRIBUTION_LIST_NAME, distributionListModel.getName())
+                        .write(Tags.TAG_DISTRIBUTION_CREATED_AT, distributionListModel.getCreatedAt())
+                        .write(Tags.TAG_DISTRIBUTION_LAST_UPDATE, distributionListModel.getLastUpdate())
+                        .write(Tags.TAG_DISTRIBUTION_MEMBERS, distributionListService.getDistributionListIdentities(distributionListModel))
+                        .write(Tags.TAG_DISTRIBUTION_LIST_ARCHIVED, distributionListModel.isArchived())
+                        .write();
+
+                    try (final ByteArrayOutputStream messageBuffer = new ByteArrayOutputStream()) {
+                        try (final CSVWriter distributionListMessageCsv = new CSVWriter(new OutputStreamWriter(messageBuffer), distributionListMessageCsvHeader)) {
+
+                            final List<DistributionListMessageModel> distributionListMessageModels = this.databaseServiceNew
+                                .getDistributionListMessageModelFactory()
+                                .getByDistributionListIdUnsorted(distributionListModel.getId());
+                            for (DistributionListMessageModel distributionListMessageModel : distributionListMessageModels) {
+                                if (!this.next("distribution list message " + distributionListMessageModel.getId())) {
+                                    return false;
+                                }
+                                distributionListMessageCsv.createRow()
+                                    .write(Tags.TAG_MESSAGE_API_MESSAGE_ID, distributionListMessageModel.getApiMessageId())
+                                    .write(Tags.TAG_MESSAGE_UID, distributionListMessageModel.getUid())
+                                    .write(Tags.TAG_MESSAGE_IDENTITY, distributionListMessageModel.getIdentity())
+                                    .write(Tags.TAG_MESSAGE_IS_OUTBOX, distributionListMessageModel.isOutbox())
+                                    .write(Tags.TAG_MESSAGE_IS_READ, distributionListMessageModel.isRead())
+                                    .write(Tags.TAG_MESSAGE_IS_SAVED, distributionListMessageModel.isSaved())
+                                    .write(Tags.TAG_MESSAGE_MESSAGE_STATE, distributionListMessageModel.getState())
+                                    .write(Tags.TAG_MESSAGE_POSTED_AT, distributionListMessageModel.getPostedAt())
+                                    .write(Tags.TAG_MESSAGE_CREATED_AT, distributionListMessageModel.getCreatedAt())
+                                    .write(Tags.TAG_MESSAGE_MODIFIED_AT, distributionListMessageModel.getModifiedAt())
+                                    .write(Tags.TAG_MESSAGE_TYPE, distributionListMessageModel.getType())
+                                    .write(Tags.TAG_MESSAGE_BODY, distributionListMessageModel.getBody())
+                                    .write(Tags.TAG_MESSAGE_IS_STATUS_MESSAGE, distributionListMessageModel.isStatusMessage())
+                                    .write(Tags.TAG_MESSAGE_CAPTION, distributionListMessageModel.getCaption())
+                                    .write(Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID, distributionListMessageModel.getQuotedMessageId())
+                                    .write(Tags.TAG_MESSAGE_DELIVERED_AT, distributionListMessageModel.getDeliveredAt())
+                                    .write(Tags.TAG_MESSAGE_READ_AT, distributionListMessageModel.getReadAt())
+                                    .write();
+
+
+                                this.backupMediaFile(
+                                    config,
+                                    zipOutputStream,
+                                    Tags.DISTRIBUTION_LIST_MESSAGE_MEDIA_FILE_PREFIX,
+                                    Tags.DISTRIBUTION_LIST_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX,
+                                    distributionListMessageModel
+                                );
+                            }
+                        }
+
+                        zipOutputStream.addFileFromInputStream(
+                            new ByteArrayInputStream(messageBuffer.toByteArray()),
+                            Tags.DISTRIBUTION_LIST_MESSAGE_FILE_PREFIX + distributionListModel.getId() + Tags.CSV_FILE_POSTFIX,
+                            true
+                        );
+                    }
+                }
+            }
+
+            zipOutputStream.addFileFromInputStream(
+                new ByteArrayInputStream(distributionListBuffer.toByteArray()),
+                Tags.DISTRIBUTION_LISTS_FILE_NAME + Tags.CSV_FILE_POSTFIX,
+                true
+            );
+        }
+
+        return true;
+    }
+
+
+    /**
+     * Backup all media files of the given AbstractMessageModel, if {@link MessageUtil#hasDataFile}
+     * returns true for the specified {@param messageModel}.
+     */
+    private void backupMediaFile(
+        @NonNull BackupRestoreDataConfig config,
+        @NonNull FileHandlingZipOutputStream zipOutputStream,
+        @NonNull String filePrefix,
+        @NonNull String thumbnailFilePrefix,
+        @NonNull AbstractMessageModel messageModel
+    ) {
+        if (!MessageUtil.hasDataFile(messageModel)) {
+            // its not a message model or a media message model
+            return;
+        }
+
+        if (!this.next("media " + messageModel.getId(), getStepFactorMedia())) {
+            return;
+        }
+
+        try {
+            boolean saveMedia = false;
+            boolean saveThumbnail = true;
+
+            switch (messageModel.getType()) {
+                case IMAGE:
+                    saveMedia = config.backupMedia();
+                    // image thumbnails will be generated again on restore - no need to save
+                    saveThumbnail = !saveMedia;
+                    break;
+                case VIDEO:
+                    if (config.backupMedia()) {
+                        VideoDataModel videoDataModel = messageModel.getVideoData();
+                        saveMedia = videoDataModel.isDownloaded();
+                    }
+                    break;
+                case VOICEMESSAGE:
+                    if (config.backupMedia()) {
+                        AudioDataModel audioDataModel = messageModel.getAudioData();
+                        saveMedia = audioDataModel.isDownloaded();
+                    }
+                    break;
+                case FILE:
+                    if (config.backupMedia()) {
+                        FileDataModel fileDataModel = messageModel.getFileData();
+                        saveMedia = fileDataModel.isDownloaded();
+                    }
+                    break;
+                default:
+                    return;
+            }
+
+            if (saveMedia) {
+                InputStream is = this.fileService.getDecryptedMessageStream(messageModel);
+                if (is != null) {
+                    zipOutputStream.addFileFromInputStream(is, filePrefix + messageModel.getUid(), false);
+                } else {
+                    logger.debug("Can't add media for message {} ({}): missing file", messageModel.getUid(), messageModel.getPostedAt());
+                    // try to save thumbnail if media is missing
+                    saveThumbnail = true;
+                }
+            }
+
+            if (config.backupThumbnails() && saveThumbnail) {
+                // save thumbnail every time (if a thumbnail exists)
+                InputStream is = this.fileService.getDecryptedMessageThumbnailStream(messageModel);
+                if (is != null) {
+                    zipOutputStream.addFileFromInputStream(is, thumbnailFilePrefix + messageModel.getUid(), false);
+                }
+            }
+        } catch (Exception x) {
+            // Don't abort the whole process, errors for media should not prevent the backup from succeeding
+            logger.debug("Can't add media for message {} ({}): {}", messageModel.getUid(), messageModel.getPostedAt(), x.getMessage());
+        }
+    }
+
+    public void onFinished(@Nullable String message) {
+        if (TextUtils.isEmpty(message)) {
+            logger.debug("onFinished (success={})", backupSuccess);
+        } else {
+            logger.debug("onFinished (success={}): {}", backupSuccess, message);
+        }
+
+        cancelPersistentNotification();
+
+        if (backupSuccess) {
+            // hacky, hacky: delay success notification for a few seconds to allow file system to settle.
+            SystemClock.sleep(FILE_SETTLE_DELAY);
+
+            if (backupFile != null) {
+                // Rename to reflect that the backup has been completed successfully
+                final String filename = backupFile.getName();
+                if (filename != null && backupFile.renameTo(filename.replace(INCOMPLETE_BACKUP_FILENAME_PREFIX, ""))) {
+                    // make sure media scanner sees this file
+                    logger.debug("Sending media scanner broadcast");
+                    sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, backupFile.getUri()));
+
+                    // Completed successfully!
+                    preferenceService.setLastDataBackupDate(new Date());
+                    showBackupSuccessNotification();
+                    logger.info("Backup completed");
+                } else {
+                    logger.error("Backup failed: File could not be renamed");
+                    showBackupErrorNotification(null);
+                }
+            } else {
+                logger.error("Backup failed: File does not exist");
+                showBackupErrorNotification(null);
+            }
+        } else {
+            logger.error("Backup failed: {}", message);
+            showBackupErrorNotification(message);
+
+            // Send broadcast so that the BackupRestoreProgressActivity can display the message
+            LocalBroadcastManager.getInstance(this).sendBroadcast(
+                new Intent().putExtra(BACKUP_PROGRESS_ERROR_MESSAGE, message)
+            );
+        }
+
+        // try to reopen connection
+        try {
+            if (serviceManager != null) {
+                serviceManager.startConnection();
+            }
+        } catch (Exception e) {
+            logger.error("Could not start connection", e);
+        }
+
+        if (wakeLock != null && wakeLock.isHeld()) {
+            logger.debug("Releasing wakelock");
+            wakeLock.release();
+        }
+
+        stopForeground(true);
+
+        isRunning = false;
+
+        // Send broadcast to indicate that the backup has been completed
+        LocalBroadcastManager.getInstance(ThreemaApplication.getAppContext())
+            .sendBroadcast(new Intent(BACKUP_PROGRESS_INTENT)
+                .putExtra(BACKUP_PROGRESS, 100)
+                .putExtra(BACKUP_PROGRESS_STEPS, 100)
+            );
+
+        stopSelf();
+    }
+
+    @SuppressLint("ForegroundServiceType")
+    private void showPersistentNotification() {
+        logger.debug("showPersistentNotification");
+
+        Intent cancelIntent = new Intent(this, BackupService.class);
+        cancelIntent.putExtra(EXTRA_ID_CANCEL, true);
+        PendingIntent cancelPendingIntent;
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            cancelPendingIntent = PendingIntent.getForegroundService(this, (int) System.currentTimeMillis(), cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
+        } else {
+            cancelPendingIntent = PendingIntent.getService(this, (int) System.currentTimeMillis(), cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
+        }
+
+        notificationBuilder = new NotificationCompat.Builder(this, NotificationChannels.NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS)
+            .setContentTitle(getString(R.string.backup_in_progress))
+            .setContentText(getString(R.string.please_wait))
+            .setOngoing(true)
+            .setSmallIcon(R.drawable.ic_notification_small)
+            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+            .addAction(R.drawable.ic_close_white_24dp, getString(R.string.cancel), cancelPendingIntent);
+
+        Notification notification = notificationBuilder.build();
+
+        startForeground(notification);
+    }
+
+    private void startForeground(Notification notification) {
+        ServiceCompat.startForeground(
+            this,
+            BACKUP_NOTIFICATION_ID,
+            notification,
+            FG_SERVICE_TYPE);
+    }
+
+    @SuppressLint("MissingPermission")
+    private void updatePersistentNotification(int currentStep, int steps, String timeRemaining) {
+        logger.debug("updatePersistentNotification {} of {}", currentStep, steps);
+
+        if (timeRemaining != null) {
+            notificationBuilder.setContentText(timeRemaining);
+        }
+
+        notificationBuilder.setProgress(steps, currentStep, false);
+
+        if (notificationManagerCompat != null) {
+            notificationManagerCompat.notify(BACKUP_NOTIFICATION_ID, notificationBuilder.build());
+        }
+    }
+
+    private String getRemainingTimeText(int currentStep, int steps) {
+        final long millisPassed = System.currentTimeMillis() - startTime;
+        final long millisRemaining = millisPassed * steps / currentStep - millisPassed + FILE_SETTLE_DELAY;
+        String timeRemaining = StringConversionUtil.secondsToString(millisRemaining / DateUtils.SECOND_IN_MILLIS, false);
+        return String.format(getString(R.string.time_remaining), timeRemaining);
+    }
+
+    private void cancelPersistentNotification() {
+        if (notificationManagerCompat != null) {
+            notificationManagerCompat.cancel(BACKUP_NOTIFICATION_ID);
+        }
+    }
+
+    @SuppressLint("MissingPermission")
+    private void showBackupErrorNotification(String message) {
+        String contentText;
+
+        if (!TestUtil.isEmptyOrNull(message)) {
+            contentText = message;
+        } else {
+            contentText = getString(R.string.backup_or_restore_error_body);
+        }
+
+        Intent backupIntent = new Intent(this, HomeActivity.class);
+        PendingIntent pendingIntent = PendingIntent.getActivity(this, (int)System.currentTimeMillis(), backupIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
+
+        NotificationCompat.Builder builder =
+            new NotificationCompat.Builder(this, NotificationChannels.NOTIFICATION_CHANNEL_ALERT)
+                .setSmallIcon(R.drawable.ic_notification_small)
+                .setTicker(getString(R.string.backup_or_restore_error_body))
+                .setContentTitle(getString(R.string.backup_or_restore_error))
+                .setContentText(contentText)
+                .setContentIntent(pendingIntent)
+                .setDefaults(Notification.DEFAULT_LIGHTS|Notification.DEFAULT_SOUND|Notification.DEFAULT_VIBRATE)
+                .setPriority(NotificationCompat.PRIORITY_MAX)
+                .setStyle(new NotificationCompat.BigTextStyle().bigText(contentText))
+                .setAutoCancel(false);
+
+        if (notificationManagerCompat != null) {
+            notificationManagerCompat.notify(BACKUP_COMPLETION_NOTIFICATION_ID, builder.build());
+        } else {
+            RuntimeUtil.runOnUiThread(
+                () -> Toast.makeText(getApplicationContext(), R.string.backup_or_restore_error_body, Toast.LENGTH_LONG).show()
+            );
+        }
+    }
+
+    @SuppressLint({"ServiceCast", "MissingPermission"})
+    private void showBackupSuccessNotification() {
+        logger.debug("showBackupSuccess");
+
+        String text;
+
+        Intent backupIntent = new Intent(this, HomeActivity.class);
+        PendingIntent pendingIntent = PendingIntent.getActivity(this, (int)System.currentTimeMillis(), backupIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
+
+        NotificationCompat.Builder builder =
+            new NotificationCompat.Builder(this, NotificationChannels.NOTIFICATION_CHANNEL_ALERT)
+                .setSmallIcon(R.drawable.ic_notification_small)
+                .setTicker(getString(R.string.backup_or_restore_success_body))
+                .setContentTitle(getString(R.string.app_name))
+                .setContentIntent(pendingIntent)
+                .setDefaults(Notification.DEFAULT_LIGHTS|Notification.DEFAULT_SOUND|Notification.DEFAULT_VIBRATE)
+                .setPriority(NotificationCompat.PRIORITY_MAX)
+                .setAutoCancel(true);
+
+        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
+            // Android Q does not allow restart in the background
+            text = getString(R.string.backup_or_restore_success_body) + "\n" + getString(R.string.tap_to_start, getString(R.string.app_name));
+        } else {
+            text = getString(R.string.backup_or_restore_success_body);
+        }
+
+        builder.setContentText(text);
+        builder.setStyle(new NotificationCompat.BigTextStyle().bigText(text));
 
+        if (notificationManagerCompat == null) {
+            notificationManagerCompat = NotificationManagerCompat.from(this);
         }
 
-		return true;
-	}
-
-	private boolean backupNonces(@NonNull ZipOutputStream zipOutputStream) {
-		logger.info("Backing up nonces");
-
-		if (!next("Backup nonces")) {
-			return false;
-		}
-
-		try {
-			int nonceCountCsp = writeNoncesToBackup(
-				NonceScope.CSP,
-				Tags.NONCE_FILE_NAME_CSP + Tags.CSV_FILE_POSTFIX,
-				zipOutputStream
-			);
-
-			int nonceCountD2d = writeNoncesToBackup(
-				NonceScope.D2D,
-				Tags.NONCE_FILE_NAME_D2D + Tags.CSV_FILE_POSTFIX,
-				zipOutputStream
-			);
-
-			writeNonceCounts(nonceCountCsp, nonceCountD2d, zipOutputStream);
-
-			int remainingCsp = BackupUtils.calcRemainingNoncesProgress(NONCES_CHUNK_SIZE, NONCES_PER_STEP, nonceCountCsp);
-			int remainingD2d = BackupUtils.calcRemainingNoncesProgress(NONCES_CHUNK_SIZE, NONCES_PER_STEP, nonceCountD2d);
-			next("Backup nonce", (int) Math.ceil(((double) remainingCsp + remainingD2d) / NONCES_PER_STEP));
-			logger.info("Nonce backup completed");
-		} catch (IOException | ThreemaException e) {
-			logger.error("Error with byte array output stream", e);
-			return false;
-		}
-
-		return true;
-	}
-
-	private void writeNonceCounts(
-		int nonceCountCsp,
-		int nonceCountD2d,
-		@NonNull ZipOutputStream zipOutputStream
-	) throws IOException, ThreemaException {
-		logger.info("Write nonce counts to backup (CSP: {}, D2D: {})", nonceCountCsp, nonceCountD2d);
-		final String[] nonceCountHeader = new String[]{ Tags.TAG_NONCE_COUNT_CSP, Tags.TAG_NONCE_COUNT_D2D };
-		try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
-			try (
-				OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
-				CSVWriter csvWriter = new CSVWriter(outputStreamWriter, nonceCountHeader)
-			) {
-				csvWriter.createRow()
-					.write(Tags.TAG_NONCE_COUNT_CSP, nonceCountCsp)
-					.write(Tags.TAG_NONCE_COUNT_D2D, nonceCountD2d)
-					.write();
-			}
-			ZipUtil.addZipStream(
-				zipOutputStream,
-				new ByteArrayInputStream(outputStream.toByteArray()),
-				Tags.NONCE_COUNTS_FILE + Tags.CSV_FILE_POSTFIX,
-				false
-			);
-		}
-	}
-
-	private int writeNoncesToBackup(
-		@NonNull NonceScope scope,
-		@NonNull String fileName,
-		@NonNull ZipOutputStream zipOutputStream
-	) throws ThreemaException, IOException {
-		try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
-			int count = writeNonces(scope, outputStream);
-			// Write nonces to zip *after* the CSVWriter has been closed (and therefore flushed)
-			ZipUtil.addZipStream(
-				zipOutputStream,
-				new ByteArrayInputStream(outputStream.toByteArray()),
-				fileName,
-				true
-			);
-			return count;
-		}
-	}
-
-	private int writeNonces(
-		@NonNull NonceScope scope,
-		@NonNull ByteArrayOutputStream outputStream
-	) throws ThreemaException, IOException {
-		logger.info("Backup {} nonces", scope);
-		final String[] nonceHeader = new String[]{Tags.TAG_NONCES};
-		int backedUpNonceCount = 0;
-		try (
-			OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
-			CSVWriter csvWriter = new CSVWriter(outputStreamWriter, nonceHeader)
-		) {
-			long start = System.currentTimeMillis();
-			long nonceCount = nonceFactory.getCount(scope);
-			long numChunks = (long) Math.ceil((double) nonceCount / NONCES_CHUNK_SIZE);
-			List<HashedNonce> nonces = new ArrayList<>(NONCES_CHUNK_SIZE);
-			for (int i = 0; i < numChunks; i++) {
-				nonceFactory.addHashedNoncesChunk(
-					scope,
-					NONCES_CHUNK_SIZE,
-					NONCES_CHUNK_SIZE * i,
-					nonces
-				);
-				for (HashedNonce hashedNonce : nonces) {
-					String nonce = Utils.byteArrayToHexString(hashedNonce.getBytes());
-					csvWriter.createRow().write(Tags.TAG_NONCES, nonce).write();
-				}
-				int increment = nonces.size() / NONCES_PER_STEP;
-				backedUpNonceCount += nonces.size();
-				nonces.clear();
-				if (!next("Backup nonce", increment)) {
-					return backedUpNonceCount;
-				}
-				// Periodically log nonce backup progress for debugging purposes
-				if ((i % 10) == 0 || i == numChunks) {
-					logger.info("Nonce backup progress: {} of {} chunks backed up", i, numChunks);
-				}
-			}
-			long end = System.currentTimeMillis();
-			logger.info("Created backup for all {} nonces in {} ms", scope, end - start);
-		}
-		return backedUpNonceCount;
-	}
-
-	/**
-	 * Create the distribution list zip file.
-	 */
-	private boolean backupDistributionListsAndMessages(
-		@NonNull BackupRestoreDataConfig config,
-		@NonNull ZipOutputStream zipOutputStream
-	) throws ThreemaException, IOException {
-		final String[] distributionListCsvHeader = {
-			Tags.TAG_DISTRIBUTION_LIST_ID,
-			Tags.TAG_DISTRIBUTION_LIST_NAME,
-			Tags.TAG_DISTRIBUTION_CREATED_AT,
-			Tags.TAG_DISTRIBUTION_LAST_UPDATE,
-			Tags.TAG_DISTRIBUTION_MEMBERS,
-			Tags.TAG_DISTRIBUTION_LIST_ARCHIVED,
-		};
-		final String[] distributionListMessageCsvHeader = {
-			Tags.TAG_MESSAGE_API_MESSAGE_ID,
-			Tags.TAG_MESSAGE_UID,
-			Tags.TAG_MESSAGE_IDENTITY,
-			Tags.TAG_MESSAGE_IS_OUTBOX,
-			Tags.TAG_MESSAGE_IS_READ,
-			Tags.TAG_MESSAGE_IS_SAVED,
-			Tags.TAG_MESSAGE_MESSAGE_STATE,
-			Tags.TAG_MESSAGE_POSTED_AT,
-			Tags.TAG_MESSAGE_CREATED_AT,
-			Tags.TAG_MESSAGE_MODIFIED_AT,
-			Tags.TAG_MESSAGE_TYPE,
-			Tags.TAG_MESSAGE_BODY,
-			Tags.TAG_MESSAGE_IS_STATUS_MESSAGE,
-			Tags.TAG_MESSAGE_CAPTION,
-			Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID,
-			Tags.TAG_MESSAGE_DELIVERED_AT,
-			Tags.TAG_MESSAGE_READ_AT,
-		};
-
-		try (final ByteArrayOutputStream distributionListBuffer = new ByteArrayOutputStream()) {
-			try (final CSVWriter distributionListCsv = new CSVWriter(new OutputStreamWriter(distributionListBuffer), distributionListCsvHeader)) {
-
-				for (DistributionListModel distributionListModel : distributionListService.getAll()) {
-					if (!this.next("distribution list " + distributionListModel.getId())) {
-						return false;
-					}
-					distributionListCsv.createRow()
-						.write(Tags.TAG_DISTRIBUTION_LIST_ID, distributionListModel.getId())
-						.write(Tags.TAG_DISTRIBUTION_LIST_NAME, distributionListModel.getName())
-						.write(Tags.TAG_DISTRIBUTION_CREATED_AT, distributionListModel.getCreatedAt())
-						.write(Tags.TAG_DISTRIBUTION_LAST_UPDATE, distributionListModel.getLastUpdate())
-						.write(Tags.TAG_DISTRIBUTION_MEMBERS, distributionListService.getDistributionListIdentities(distributionListModel))
-						.write(Tags.TAG_DISTRIBUTION_LIST_ARCHIVED, distributionListModel.isArchived())
-						.write();
-
-					try (final ByteArrayOutputStream messageBuffer = new ByteArrayOutputStream()) {
-						try (final CSVWriter distributionListMessageCsv = new CSVWriter(new OutputStreamWriter(messageBuffer), distributionListMessageCsvHeader)) {
-
-							final List<DistributionListMessageModel> distributionListMessageModels = this.databaseServiceNew
-								.getDistributionListMessageModelFactory()
-								.getByDistributionListIdUnsorted(distributionListModel.getId());
-							for (DistributionListMessageModel distributionListMessageModel : distributionListMessageModels) {
-								if (!this.next("distribution list message " + distributionListMessageModel.getId())) {
-									return false;
-								}
-								distributionListMessageCsv.createRow()
-									.write(Tags.TAG_MESSAGE_API_MESSAGE_ID, distributionListMessageModel.getApiMessageId())
-									.write(Tags.TAG_MESSAGE_UID, distributionListMessageModel.getUid())
-									.write(Tags.TAG_MESSAGE_IDENTITY, distributionListMessageModel.getIdentity())
-									.write(Tags.TAG_MESSAGE_IS_OUTBOX, distributionListMessageModel.isOutbox())
-									.write(Tags.TAG_MESSAGE_IS_READ, distributionListMessageModel.isRead())
-									.write(Tags.TAG_MESSAGE_IS_SAVED, distributionListMessageModel.isSaved())
-									.write(Tags.TAG_MESSAGE_MESSAGE_STATE, distributionListMessageModel.getState())
-									.write(Tags.TAG_MESSAGE_POSTED_AT, distributionListMessageModel.getPostedAt())
-									.write(Tags.TAG_MESSAGE_CREATED_AT, distributionListMessageModel.getCreatedAt())
-									.write(Tags.TAG_MESSAGE_MODIFIED_AT, distributionListMessageModel.getModifiedAt())
-									.write(Tags.TAG_MESSAGE_TYPE, distributionListMessageModel.getType())
-									.write(Tags.TAG_MESSAGE_BODY, distributionListMessageModel.getBody())
-									.write(Tags.TAG_MESSAGE_IS_STATUS_MESSAGE, distributionListMessageModel.isStatusMessage())
-									.write(Tags.TAG_MESSAGE_CAPTION, distributionListMessageModel.getCaption())
-									.write(Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID, distributionListMessageModel.getQuotedMessageId())
-									.write(Tags.TAG_MESSAGE_DELIVERED_AT, distributionListMessageModel.getDeliveredAt())
-									.write(Tags.TAG_MESSAGE_READ_AT, distributionListMessageModel.getReadAt())
-									.write();
-
-
-								this.backupMediaFile(
-									config,
-									zipOutputStream,
-									Tags.DISTRIBUTION_LIST_MESSAGE_MEDIA_FILE_PREFIX,
-									Tags.DISTRIBUTION_LIST_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX,
-									distributionListMessageModel
-								);
-							}
-						}
-
-						ZipUtil.addZipStream(
-							zipOutputStream,
-							new ByteArrayInputStream(messageBuffer.toByteArray()),
-							Tags.DISTRIBUTION_LIST_MESSAGE_FILE_PREFIX + distributionListModel.getId() + Tags.CSV_FILE_POSTFIX,
-							true
-						);
-					}
-				}
-			}
-
-			ZipUtil.addZipStream(
-				zipOutputStream,
-				new ByteArrayInputStream(distributionListBuffer.toByteArray()),
-				Tags.DISTRIBUTION_LISTS_FILE_NAME + Tags.CSV_FILE_POSTFIX,
-				true
-			);
-		}
-
-		return true;
-	}
-
-
-	/**
-	 * Backup all media files of the given AbstractMessageModel, if {@link MessageUtil#hasDataFile}
-	 * returns true for the specified {@param messageModel}.
-	 */
-	private void backupMediaFile(
-		@NonNull BackupRestoreDataConfig config,
-	    @NonNull ZipOutputStream zipOutputStream,
-	    @NonNull String filePrefix,
-	    @NonNull String thumbnailFilePrefix,
-	    @NonNull AbstractMessageModel messageModel
-	) {
-		if (!MessageUtil.hasDataFile(messageModel)) {
-			//its not a message model or a media message model
-			return;
-		}
-
-		if (!this.next("media " + messageModel.getId(), getStepFactor())) {
-			return;
-		}
-
-		try {
-			boolean saveMedia = false;
-			boolean saveThumbnail = true;
-
-			switch (messageModel.getType()) {
-				case IMAGE:
-					saveMedia = config.backupMedia();
-					// image thumbnails will be generated again on restore - no need to save
-					saveThumbnail = !saveMedia;
-					break;
-				case VIDEO:
-					if (config.backupVideoAndFiles()) {
-						VideoDataModel videoDataModel = messageModel.getVideoData();
-						saveMedia = videoDataModel.isDownloaded();
-					}
-					break;
-				case VOICEMESSAGE:
-					if (config.backupMedia()) {
-						AudioDataModel audioDataModel = messageModel.getAudioData();
-						saveMedia = audioDataModel.isDownloaded();
-					}
-					break;
-				case FILE:
-					if (config.backupVideoAndFiles()) {
-						FileDataModel fileDataModel = messageModel.getFileData();
-						saveMedia = fileDataModel.isDownloaded();
-					}
-					break;
-				default:
-					return;
-			}
-
-			if (saveMedia) {
-				InputStream is = this.fileService.getDecryptedMessageStream(messageModel);
-				if (is != null) {
-					ZipUtil.addZipStream(zipOutputStream, is, filePrefix + messageModel.getUid(), false);
-				} else {
-					logger.debug("Can't add media for message {} ({}): missing file", messageModel.getUid(), messageModel.getPostedAt());
-					// try to save thumbnail if media is missing
-					saveThumbnail = true;
-				}
-			}
-
-			if (config.backupThumbnails() && saveThumbnail) {
-				//save thumbnail every time (if a thumbnail exists)
-				InputStream is = this.fileService.getDecryptedMessageThumbnailStream(messageModel);
-				if (is != null) {
-					ZipUtil.addZipStream(zipOutputStream, is, thumbnailFilePrefix + messageModel.getUid(), false);
-				}
-			}
-		} catch (Exception x) {
-			// Don't abort the whole process, errors for media should not prevent the backup from succeeding
-			logger.debug("Can't add media for message {} ({}): {}", messageModel.getUid(), messageModel.getPostedAt(), x.getMessage());
-		}
-	}
-
-	public void onFinished(@Nullable String message) {
-		if (TextUtils.isEmpty(message)) {
-			logger.debug("onFinished (success={})", backupSuccess);
-		} else {
-			logger.debug("onFinished (success={}): {}", backupSuccess, message);
-		}
-
-		cancelPersistentNotification();
-
-		if (backupSuccess) {
-			// hacky, hacky: delay success notification for a few seconds to allow file system to settle.
-			SystemClock.sleep(FILE_SETTLE_DELAY);
-
-			if (backupFile != null) {
-				// Rename to reflect that the backup has been completed successfully
-				final String filename = backupFile.getName();
-				if (filename != null && backupFile.renameTo(filename.replace(INCOMPLETE_BACKUP_FILENAME_PREFIX, ""))) {
-					// make sure media scanner sees this file
-					logger.debug("Sending media scanner broadcast");
-					sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, backupFile.getUri()));
-
-					// Completed successfully!
-					preferenceService.setLastDataBackupDate(new Date());
-					showBackupSuccessNotification();
-				} else {
-					logger.error("Backup failed: File could not be renamed");
-					showBackupErrorNotification(null);
-				}
-			} else {
-				logger.error("Backup failed: File does not exist");
-				showBackupErrorNotification(null);
-			}
-		} else {
-			logger.error("Backup failed: {}", message);
-			showBackupErrorNotification(message);
-
-			// Send broadcast so that the BackupRestoreProgressActivity can display the message
-			LocalBroadcastManager.getInstance(this).sendBroadcast(
-				new Intent().putExtra(BACKUP_PROGRESS_ERROR_MESSAGE, message)
-			);
-		}
-
-		//try to reopen connection
-		try {
-			if (serviceManager != null) {
-				serviceManager.startConnection();
-			}
-		} catch (Exception e) {
-			logger.error("Exception", e);
-		}
-
-		if (wakeLock != null && wakeLock.isHeld()) {
-			logger.debug("Releasing wakelock");
-			wakeLock.release();
-		}
-
-		stopForeground(true);
-
-		isRunning = false;
-
-		// Send broadcast to indicate that the backup has been completed
-		LocalBroadcastManager.getInstance(ThreemaApplication.getAppContext())
-			.sendBroadcast(new Intent(BACKUP_PROGRESS_INTENT)
-				.putExtra(BACKUP_PROGRESS, 100)
-				.putExtra(BACKUP_PROGRESS_STEPS, 100)
-			);
-
-		stopSelf();
-	}
-
-	@SuppressLint("ForegroundServiceType")
-	private void showPersistentNotification() {
-		logger.debug("showPersistentNotification");
-
-		Intent cancelIntent = new Intent(this, BackupService.class);
-		cancelIntent.putExtra(EXTRA_ID_CANCEL, true);
-		PendingIntent cancelPendingIntent;
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
-			cancelPendingIntent = PendingIntent.getForegroundService(this, (int) System.currentTimeMillis(), cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
-		} else {
-			cancelPendingIntent = PendingIntent.getService(this, (int) System.currentTimeMillis(), cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
-		}
-
-		notificationBuilder = new NotificationCompat.Builder(this, NotificationChannels.NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS)
-					.setContentTitle(getString(R.string.backup_in_progress))
-					.setContentText(getString(R.string.please_wait))
-					.setOngoing(true)
-					.setSmallIcon(R.drawable.ic_notification_small)
-					.setPriority(NotificationCompat.PRIORITY_DEFAULT)
-					.addAction(R.drawable.ic_close_white_24dp, getString(R.string.cancel), cancelPendingIntent);
-
-		Notification notification = notificationBuilder.build();
-
-		startForeground(notification);
-	}
-
-	private void startForeground(Notification notification) {
-		ServiceCompat.startForeground(
-			this,
-			BACKUP_NOTIFICATION_ID,
-			notification,
-			FG_SERVICE_TYPE);
-	}
-
-	@SuppressLint("MissingPermission")
-	private void updatePersistentNotification(int currentStep, int steps, String timeRemaining) {
-		logger.debug("updatePersistentNotification {} of {}", currentStep, steps);
-
-		if (timeRemaining != null) {
-			notificationBuilder.setContentText(timeRemaining);
-		}
-
-		notificationBuilder.setProgress(steps, currentStep, false);
-
-		if (notificationManagerCompat != null) {
-			notificationManagerCompat.notify(BACKUP_NOTIFICATION_ID, notificationBuilder.build());
-		}
-	}
-
-	private String getRemainingTimeText(int currentStep, int steps) {
-		final long millisPassed = System.currentTimeMillis() - startTime;
-		final long millisRemaining = millisPassed * steps / currentStep - millisPassed + FILE_SETTLE_DELAY;
-		String timeRemaining = StringConversionUtil.secondsToString(millisRemaining / DateUtils.SECOND_IN_MILLIS, false);
-		return String.format(getString(R.string.time_remaining), timeRemaining);
-	}
-
-	private void cancelPersistentNotification() {
-		if (notificationManagerCompat != null) {
-			notificationManagerCompat.cancel(BACKUP_NOTIFICATION_ID);
-		}
-	}
-
-	@SuppressLint("MissingPermission")
-	private void showBackupErrorNotification(String message) {
-		String contentText;
-
-		if (!TestUtil.isEmptyOrNull(message)) {
-			contentText = message;
-		} else {
-			contentText = getString(R.string.backup_or_restore_error_body);
-		}
-
-		Intent backupIntent = new Intent(this, HomeActivity.class);
-		PendingIntent pendingIntent = PendingIntent.getActivity(this, (int)System.currentTimeMillis(), backupIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
-
-		NotificationCompat.Builder builder =
-				new NotificationCompat.Builder(this, NotificationChannels.NOTIFICATION_CHANNEL_ALERT)
-						.setSmallIcon(R.drawable.ic_notification_small)
-						.setTicker(getString(R.string.backup_or_restore_error_body))
-						.setContentTitle(getString(R.string.backup_or_restore_error))
-						.setContentText(contentText)
-						.setContentIntent(pendingIntent)
-						.setDefaults(Notification.DEFAULT_LIGHTS|Notification.DEFAULT_SOUND|Notification.DEFAULT_VIBRATE)
-						.setPriority(NotificationCompat.PRIORITY_MAX)
-						.setStyle(new NotificationCompat.BigTextStyle().bigText(contentText))
-						.setAutoCancel(false);
-
-		if (notificationManagerCompat != null) {
-			notificationManagerCompat.notify(BACKUP_COMPLETION_NOTIFICATION_ID, builder.build());
-		} else {
-			RuntimeUtil.runOnUiThread(
-				() -> Toast.makeText(getApplicationContext(), R.string.backup_or_restore_error_body, Toast.LENGTH_LONG).show()
-			);
-		}
-	}
-
-	@SuppressLint({"ServiceCast", "MissingPermission"})
-	private void showBackupSuccessNotification() {
-		logger.debug("showBackupSuccess");
-
-		String text;
-
-		Intent backupIntent = new Intent(this, HomeActivity.class);
-		PendingIntent pendingIntent = PendingIntent.getActivity(this, (int)System.currentTimeMillis(), backupIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
-
-		NotificationCompat.Builder builder =
-				new NotificationCompat.Builder(this, NotificationChannels.NOTIFICATION_CHANNEL_ALERT)
-						.setSmallIcon(R.drawable.ic_notification_small)
-						.setTicker(getString(R.string.backup_or_restore_success_body))
-						.setContentTitle(getString(R.string.app_name))
-						.setContentIntent(pendingIntent)
-						.setDefaults(Notification.DEFAULT_LIGHTS|Notification.DEFAULT_SOUND|Notification.DEFAULT_VIBRATE)
-						.setPriority(NotificationCompat.PRIORITY_MAX)
-						.setAutoCancel(true);
-
-		if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
-			// Android Q does not allow restart in the background
-			text = getString(R.string.backup_or_restore_success_body) + "\n" + getString(R.string.tap_to_start, getString(R.string.app_name));
-		} else {
-			text = getString(R.string.backup_or_restore_success_body);
-		}
-
-		builder.setContentText(text);
-		builder.setStyle(new NotificationCompat.BigTextStyle().bigText(text));
-
-		if (notificationManagerCompat == null) {
-			notificationManagerCompat = NotificationManagerCompat.from(this);
-		}
-
-		notificationManagerCompat.notify(BACKUP_COMPLETION_NOTIFICATION_ID, builder.build());
-	}
-
-	/**
-	 * Show a fake notification before stopping service in order to prevent Context.startForegroundService() did not then call Service.startForeground() crash
-	 */
-	private void safeStopSelf() {
-		Notification notification = new NotificationCompat.Builder(this, NotificationChannels.NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS)
-			.setContentTitle("")
-			.setContentText("")
-			.build();
-
-		startForeground(notification);
-		stopForeground(true);
-		isRunning = false;
-
-		// Send broadcast after isRunning has been set to false to indicate that there is no backup
-		// in progress anymore
-		LocalBroadcastManager.getInstance(ThreemaApplication.getAppContext())
-			.sendBroadcast(new Intent(BACKUP_PROGRESS_INTENT)
-				.putExtra(BACKUP_PROGRESS, 100)
-				.putExtra(BACKUP_PROGRESS_STEPS, 100)
-			);
-
-		stopSelf();
-	}
-
-	/**
-	 * Return a string representation of the next value in randomIterator
-	 * @return a 10 character string
-	 */
-	@NonNull
-	private String getFormattedUniqueId() {
-		return String.format(Locale.US, "%010d", randomIterator.next());
-	}
+        notificationManagerCompat.notify(BACKUP_COMPLETION_NOTIFICATION_ID, builder.build());
+    }
+
+    /**
+     * Show a fake notification before stopping service in order to prevent Context.startForegroundService() did not then call Service.startForeground() crash
+     */
+    private void safeStopSelf() {
+        Notification notification = new NotificationCompat.Builder(this, NotificationChannels.NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS)
+            .setContentTitle("")
+            .setContentText("")
+            .build();
+
+        startForeground(notification);
+        stopForeground(true);
+        isRunning = false;
+
+        // Send broadcast after isRunning has been set to false to indicate that there is no backup
+        // in progress anymore
+        LocalBroadcastManager.getInstance(ThreemaApplication.getAppContext())
+            .sendBroadcast(new Intent(BACKUP_PROGRESS_INTENT)
+                .putExtra(BACKUP_PROGRESS, 100)
+                .putExtra(BACKUP_PROGRESS_STEPS, 100)
+            );
+
+        stopSelf();
+    }
+
+    /**
+     * Return a string representation of the next value in randomIterator
+     * @return a 10 character string
+     */
+    @NonNull
+    private String getFormattedUniqueId() {
+        return String.format(Locale.US, "%010d", randomIterator.next());
+    }
 }
 
 

+ 2195 - 1928
app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java

@@ -41,7 +41,6 @@ import net.lingala.zip4j.io.inputstream.ZipInputStream;
 import net.lingala.zip4j.model.FileHeader;
 
 import org.apache.commons.io.IOUtils;
-import org.json.JSONException;
 import org.slf4j.Logger;
 
 import java.io.File;
@@ -54,6 +53,9 @@ import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.stream.Collectors;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -67,8 +69,9 @@ import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.DummyActivity;
 import ch.threema.app.activities.HomeActivity;
 import ch.threema.app.asynctasks.DeleteIdentityAsyncTask;
-import ch.threema.app.backuprestore.BackupRestoreDataService;
+import ch.threema.app.backuprestore.MessageIdCache;
 import ch.threema.app.collections.Functional;
+import ch.threema.app.emojis.EmojiUtil;
 import ch.threema.app.exceptions.RestoreCanceledException;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.notifications.NotificationChannels;
@@ -82,22 +85,30 @@ import ch.threema.app.utils.BackupUtils;
 import ch.threema.app.utils.CSVReader;
 import ch.threema.app.utils.CSVRow;
 import ch.threema.app.utils.ConfigUtils;
+import ch.threema.app.utils.Counter;
 import ch.threema.app.utils.JsonUtil;
 import ch.threema.app.utils.MessageUtil;
 import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.StringConversionUtil;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.app.utils.ThrowingConsumer;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.crypto.NonceFactory;
 import ch.threema.base.crypto.NonceScope;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.Utils;
+import ch.threema.data.repositories.EmojiReactionsRepository;
+import ch.threema.data.repositories.ModelRepositories;
+import ch.threema.data.storage.DbEmojiReaction;
 import ch.threema.domain.models.GroupId;
 import ch.threema.domain.models.VerificationLevel;
 import ch.threema.domain.protocol.connection.ServerConnection;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.factories.ContactModelFactory;
+import ch.threema.storage.factories.GroupMessageModelFactory;
+import ch.threema.storage.factories.GroupModelFactory;
+import ch.threema.storage.factories.MessageModelFactory;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ContactModel.AcquaintanceLevel;
@@ -127,1932 +138,2188 @@ import static ch.threema.storage.models.GroupModel.UserState.LEFT;
 import static ch.threema.storage.models.GroupModel.UserState.MEMBER;
 
 public class RestoreService extends Service {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("RestoreService");
-
-	public static final String RESTORE_PROGRESS_INTENT = "restore_progress_intent";
-	public static final String RESTORE_PROGRESS = "restore_progress";
-	public static final String RESTORE_PROGRESS_STEPS = "restore_progress_steps";
-	public static final String RESTORE_PROGRESS_MESSAGE = "restore_progress_message";
-	public static final String RESTORE_PROGRESS_ERROR_MESSAGE = "restore_progress_error_message";
-
-	public static final String EXTRA_RESTORE_BACKUP_FILE = "file";
-	public static final String EXTRA_RESTORE_BACKUP_PASSWORD = "pwd";
-	private static final int MAX_THUMBNAIL_SIZE_BYTES = 5 * 1024 * 1024; // do not restore thumbnails that are bigger than 5 MB
-
-	private static final int FG_SERVICE_TYPE = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE ? FOREGROUND_SERVICE_TYPE_DATA_SYNC : 0;
-
-	private ServiceManager serviceManager;
-	private ContactService contactService;
-	private ConversationService conversationService;
-	private FileService fileService;
-	private UserService userService;
-	private DatabaseServiceNew databaseServiceNew;
-	private PreferenceService preferenceService;
+    private static final Logger logger = LoggingUtil.getThreemaLogger("RestoreService");
+
+    public static final String RESTORE_PROGRESS_INTENT = "restore_progress_intent";
+    public static final String RESTORE_PROGRESS = "restore_progress";
+    public static final String RESTORE_PROGRESS_STEPS = "restore_progress_steps";
+    public static final String RESTORE_PROGRESS_MESSAGE = "restore_progress_message";
+    public static final String RESTORE_PROGRESS_ERROR_MESSAGE = "restore_progress_error_message";
+
+    public static final String EXTRA_RESTORE_BACKUP_FILE = "file";
+    public static final String EXTRA_RESTORE_BACKUP_PASSWORD = "pwd";
+    private static final int MAX_THUMBNAIL_SIZE_BYTES = 5 * 1024 * 1024; // do not restore thumbnails that are bigger than 5 MB
+
+    private static final int FG_SERVICE_TYPE = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE ? FOREGROUND_SERVICE_TYPE_DATA_SYNC : 0;
+
+    private ServiceManager serviceManager;
+    private ContactService contactService;
+    private ConversationService conversationService;
+    private FileService fileService;
+    private UserService userService;
+    private DatabaseServiceNew databaseServiceNew;
+    private ModelRepositories modelRepositories;
+    private PreferenceService preferenceService;
     private NotificationPreferenceService notificationPreferenceService;
-	private PowerManager.WakeLock wakeLock;
-	private NotificationManagerCompat notificationManagerCompat;
-	private NonceFactory nonceFactory;
-
-	private NotificationCompat.Builder notificationBuilder;
-
-	private static final int RESTORE_NOTIFICATION_ID = 981772;
-	public static final int RESTORE_COMPLETION_NOTIFICATION_ID = 981773;
-	private static final String EXTRA_ID_CANCEL = "cnc";
-
-	private final RestoreResultImpl restoreResult = new RestoreResultImpl();
-	private final HashMap<String, String> identityIdMap = new HashMap<>();
-	private final HashMap<String, Integer> groupUidMap = new HashMap<>();
-
-	private long currentProgressStep = 0;
-	private long progressSteps = 0;
-	private int latestPercentStep = -1;
-	private long startTime = 0;
-
-	private static boolean restoreSuccess = false;
-
-	private ZipFile zipFile;
-	private String password;
-
-	private static final int STEP_SIZE_PREPARE = 100;
-	private static final int STEP_SIZE_IDENTITY = 100;
-	private static final int STEP_SIZE_MAIN_FILES = 200;
-	private static final int STEP_SIZE_MESSAGES = 1; // per message
-	private static final int STEP_SIZE_GROUP_AVATARS = 50;
-	private static final int STEP_SIZE_MEDIA = 25; // per media file
-	private static final int NONCES_PER_STEP = 50;
-	private static final int NONCES_CHUNK_SIZE = 10_000;
-
-	private long stepSizeTotal = (long) STEP_SIZE_PREPARE + STEP_SIZE_IDENTITY + STEP_SIZE_MAIN_FILES + STEP_SIZE_GROUP_AVATARS;
-
-	private static boolean isCanceled = false;
-	private static boolean isRunning = false;
-
-	public static boolean isRunning() {
-		return isRunning;
-	}
-
-	@Nullable
-	@Override
-	public IBinder onBind(Intent intent) {
-		return null;
-	}
-
-	@SuppressLint("StaticFieldLeak")
-	@Override
-	public int onStartCommand(Intent intent, int flags, int startId) {
-		logger.debug("onStartCommand flags = " + flags + " startId " + startId);
-		ServiceCompat.startForeground(
-			this,
-			RESTORE_NOTIFICATION_ID,
-			getPersistentNotification(),
-			FG_SERVICE_TYPE);
-
-		if (intent != null) {
-			logger.debug("onStartCommand intent != null");
-
-			isCanceled = intent.getBooleanExtra(EXTRA_ID_CANCEL, false);
-
-			if (!isCanceled) {
-				File file = (File) intent.getSerializableExtra(EXTRA_RESTORE_BACKUP_FILE);
-				password = intent.getStringExtra(EXTRA_RESTORE_BACKUP_PASSWORD);
-
-				if (file == null || TextUtils.isEmpty(password)) {
-					showRestoreErrorNotification("Invalid input");
-					stopSelf();
-					isRunning = false;
-
-					return START_NOT_STICKY;
-				}
-
-				PowerManager powerManager = (PowerManager) getApplicationContext().getSystemService(Context.POWER_SERVICE);
-				if (powerManager != null) {
-					String tag = BuildConfig.APPLICATION_ID + ":restore";
-					if (Build.VERSION.SDK_INT == Build.VERSION_CODES.M && Build.MANUFACTURER.equals("Huawei")) {
-						// Huawei will not kill your app if your Wakelock has a well known tag
-						// see https://dontkillmyapp.com/huawei
-						tag = "LocationManagerService";
-					}
-					wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag);
-					if (wakeLock != null) {
-						wakeLock.acquire(DateUtils.DAY_IN_MILLIS);
-					}
-				}
-
-				try {
-					serviceManager.stopConnection();
-				} catch (InterruptedException e) {
-					showRestoreErrorNotification("RestoreService interrupted");
-					stopSelf();
-					return START_NOT_STICKY;
-				}
-
-				new AsyncTask<Void, Void, Boolean>() {
-					@Override
-					protected Boolean doInBackground(Void... params) {
-						zipFile = new ZipFile(file, password.toCharArray());
-						if (!zipFile.isValidZipFile()) {
-							showRestoreErrorNotification(getString(R.string.restore_zip_invalid_file));
-							isRunning = false;
-
-							return false;
-						}
-						return restore();
-					}
-
-					@Override
-					protected void onPostExecute(Boolean success) {
-						stopSelf();
-					}
-				}.execute();
-
-				if (isRunning) {
-					return START_STICKY;
-				}
-			} else {
-				Toast.makeText(this, R.string.restore_data_cancelled, Toast.LENGTH_LONG).show();
-			}
-		} else {
-			logger.debug("onStartCommand intent == null");
-
-			onFinished("Empty intent");
-		}
-		isRunning = false;
-
-		return START_NOT_STICKY;
-	}
-
-	@Override
-	public void onCreate() {
-		logger.info("onCreate");
-
-		super.onCreate();
-
-		isRunning = true;
-
-		serviceManager = ThreemaApplication.getServiceManager();
-		if (serviceManager == null) {
-			stopSelf();
-			return;
-		}
-
-		try {
-			fileService = serviceManager.getFileService();
-			databaseServiceNew = serviceManager.getDatabaseServiceNew();
-			contactService = serviceManager.getContactService();
-			conversationService = serviceManager.getConversationService();
-			userService = serviceManager.getUserService();
-			preferenceService = serviceManager.getPreferenceService();
+    private PowerManager.WakeLock wakeLock;
+    private NotificationManagerCompat notificationManagerCompat;
+    private NonceFactory nonceFactory;
+
+    private NotificationCompat.Builder notificationBuilder;
+
+    private static final int RESTORE_NOTIFICATION_ID = 981772;
+    public static final int RESTORE_COMPLETION_NOTIFICATION_ID = 981773;
+    private static final String EXTRA_ID_CANCEL = "cnc";
+
+    private final HashMap<String, String> identityIdMap = new HashMap<>();
+    private final HashMap<String, Integer> groupUidMap = new HashMap<>();
+
+    private long currentProgressStep = 0;
+    private long progressSteps = 0;
+    private int latestPercentStep = -1;
+    private long startTime = 0;
+
+    private static boolean restoreSuccess = false;
+
+    private ZipFile zipFile;
+    private String password;
+
+    private static final int STEP_SIZE_PREPARE = 100;
+    private static final int STEP_SIZE_IDENTITY = 100;
+    private static final int STEP_SIZE_MAIN_FILES = 200;
+    private static final int STEP_SIZE_MESSAGES = 1; // per message
+    private static final int STEP_SIZE_GROUP_AVATARS = 50;
+    private static final int STEP_SIZE_MEDIA = 25; // per media file
+    private static final int NONCES_PER_STEP = 50;
+    private static final int NONCES_CHUNK_SIZE = 10_000;
+    private static final int REACTIONS_PER_STEP = 25;
+    private static final int REACTIONS_STEP_THRESHOLD = 250;
+
+    private long stepSizeTotal = (long) STEP_SIZE_PREPARE + STEP_SIZE_IDENTITY + STEP_SIZE_MAIN_FILES + STEP_SIZE_GROUP_AVATARS;
+
+    private static boolean isCanceled = false;
+    private static boolean isRunning = false;
+
+    public static boolean isRunning() {
+        return isRunning;
+    }
+
+    @Nullable
+    @Override
+    public IBinder onBind(Intent intent) {
+        return null;
+    }
+
+    @SuppressLint("StaticFieldLeak")
+    @Override
+    public int onStartCommand(Intent intent, int flags, int startId) {
+        logger.debug("onStartCommand flags = {} startId {}", flags, startId);
+        ServiceCompat.startForeground(
+            this,
+            RESTORE_NOTIFICATION_ID,
+            getPersistentNotification(),
+            FG_SERVICE_TYPE);
+
+        if (intent != null) {
+            logger.debug("onStartCommand intent != null");
+
+            isCanceled = intent.getBooleanExtra(EXTRA_ID_CANCEL, false);
+
+            if (!isCanceled) {
+                File file = (File) intent.getSerializableExtra(EXTRA_RESTORE_BACKUP_FILE);
+                password = intent.getStringExtra(EXTRA_RESTORE_BACKUP_PASSWORD);
+
+                if (file == null || TextUtils.isEmpty(password)) {
+                    showRestoreErrorNotification("Invalid input");
+                    stopSelf();
+                    isRunning = false;
+
+                    return START_NOT_STICKY;
+                }
+
+                PowerManager powerManager = (PowerManager) getApplicationContext().getSystemService(Context.POWER_SERVICE);
+                if (powerManager != null) {
+                    String tag = BuildConfig.APPLICATION_ID + ":restore";
+                    if (Build.VERSION.SDK_INT == Build.VERSION_CODES.M && Build.MANUFACTURER.equals("Huawei")) {
+                        // Huawei will not kill your app if your Wakelock has a well known tag
+                        // see https://dontkillmyapp.com/huawei
+                        tag = "LocationManagerService";
+                    }
+                    wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag);
+                    if (wakeLock != null) {
+                        wakeLock.acquire(DateUtils.DAY_IN_MILLIS);
+                    }
+                }
+
+                try {
+                    serviceManager.stopConnection();
+                } catch (InterruptedException e) {
+                    showRestoreErrorNotification("RestoreService interrupted");
+                    stopSelf();
+                    return START_NOT_STICKY;
+                }
+
+                new AsyncTask<Void, Void, Boolean>() {
+                    @Override
+                    protected Boolean doInBackground(Void... params) {
+                        zipFile = new ZipFile(file, password.toCharArray());
+                        if (!zipFile.isValidZipFile()) {
+                            showRestoreErrorNotification(getString(R.string.restore_zip_invalid_file));
+                            isRunning = false;
+
+                            return false;
+                        }
+                        return restore();
+                    }
+
+                    @Override
+                    protected void onPostExecute(Boolean success) {
+                        stopSelf();
+                    }
+                }.execute();
+
+                if (isRunning) {
+                    return START_STICKY;
+                }
+            } else {
+                Toast.makeText(this, R.string.restore_data_cancelled, Toast.LENGTH_LONG).show();
+            }
+        } else {
+            logger.debug("onStartCommand intent == null");
+
+            onFinished("Empty intent");
+        }
+        isRunning = false;
+
+        return START_NOT_STICKY;
+    }
+
+    @Override
+    public void onCreate() {
+        logger.info("onCreate");
+
+        super.onCreate();
+
+        isRunning = true;
+
+        serviceManager = ThreemaApplication.getServiceManager();
+        if (serviceManager == null) {
+            stopSelf();
+            return;
+        }
+
+        try {
+            fileService = serviceManager.getFileService();
+            databaseServiceNew = serviceManager.getDatabaseServiceNew();
+            modelRepositories = serviceManager.getModelRepositories();
+            contactService = serviceManager.getContactService();
+            conversationService = serviceManager.getConversationService();
+            userService = serviceManager.getUserService();
+            preferenceService = serviceManager.getPreferenceService();
             notificationPreferenceService = serviceManager.getNotificationPreferenceService();
-			nonceFactory = serviceManager.getNonceFactory();
-		} catch (Exception e) {
-			logger.error("Could not instantiate all required services", e);
-			stopSelf();
-			return;
-		}
-
-		notificationManagerCompat = NotificationManagerCompat.from(this);
-	}
-
-	@Override
-	public void onDestroy() {
-		logger.info("onDestroy success = {} cancelled = {}", restoreSuccess, isCanceled);
-
-		if (isCanceled) {
-			onFinished(getString(R.string.restore_data_cancelled));
-		}
-
-		super.onDestroy();
-	}
-
-	@Override
-	public void onLowMemory() {
-		logger.info("onLowMemory");
-		super.onLowMemory();
-	}
-
-	@Override
-	public void onTaskRemoved(Intent rootIntent) {
-		logger.info("onTaskRemoved");
-
-		Intent intent = new Intent(this, DummyActivity.class);
-		intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-		startActivity(intent);
-	}
-
-	// ---------------------------------------------------------------------------
-	private static class RestoreResultImpl implements BackupRestoreDataService.RestoreResult {
-		private long contactSuccess = 0;
-		private long contactFailed = 0;
-		private long messageSuccess = 0;
-		private long messageFailed = 0;
-
-		@Override
-		public long getContactSuccess() {
-			return this.contactSuccess;
-		}
-
-		@Override
-		public long getContactFailed() {
-			return this.contactFailed;
-		}
-
-		@Override
-		public long getMessageSuccess() {
-			return this.messageSuccess;
-		}
-
-		@Override
-		public long getMessageFailed() {
-			return this.messageFailed;
-		}
-
-		protected void incContactSuccess() {
-			this.contactSuccess++;
-		}
-		protected void incContactFailed() {
-			this.contactFailed++;
-		}
-		protected void incMessageSuccess() {
-			this.messageSuccess++;
-		}
-		protected void incMessageFailed() {
-			this.messageFailed++;
-		}
-	}
-
-	/**
-	 * CSV file processor
-	 *
-	 * The {@link #row(CSVRow)} method will be called for every row in the CSV file.
-	 */
-	private interface ProcessCsvFile {
-		void row(CSVRow row) throws RestoreCanceledException;
-	}
-
-	private interface GetMessageModel {
-		AbstractMessageModel get(String uid);
-	}
-
-	private RestoreSettings restoreSettings;
-	private final HashMap<String, Integer> ballotIdMap = new HashMap<>();
-	private final HashMap<Integer, Integer> ballotOldIdMap = new HashMap<>();
-	private final HashMap<String, Integer> ballotChoiceIdMap = new HashMap<>();
-	private final HashMap<String, Long> distributionListIdMap = new HashMap<>();
-
-	private boolean writeToDb = false;
-
-	public boolean restore() {
-		logger.info("Restoring data backup");
-
-		int mediaCount;
-		int messageCount;
-		String message;
-
-		if (BuildConfig.DEBUG) {
-			// zipFile.getInputStream() currently causes "Explicit termination method 'end' not called" exception
-			StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
-				.detectAll()
-				.penaltyLog()
-				.build());
-		}
-
-		try {
-			// Ensure that the server connection is stopped before restoring the backup.
-			//
-			// This is important, because during the backup restore process, some outgoing
-			// messages (e.g. group sync messages) might be enqueued. However, we only want to
-			// send those messages if the backup restore succeeded.
-			//
-			// The connection will be resumed in {@link onFinished}.
-			final ServerConnection connection = serviceManager.getConnection();
-			if (connection.isRunning()) {
-				connection.stop();
-			}
-
-			// We use two passes for a restore. The first pass only scans the files in the backup,
-			// but does not write to the database. In the second pass, the files are actually written.
-			for (int nTry = 0; nTry < 2; nTry++) {
-				logger.info("Attempt {}", nTry + 1);
-				if (nTry > 0) {
-					this.writeToDb = true;
-					this.initProgress(stepSizeTotal);
-				}
-
-				this.identityIdMap.clear();
-				this.groupUidMap.clear();
-				this.ballotIdMap.clear();
-				this.ballotOldIdMap.clear();
-				this.ballotChoiceIdMap.clear();
-				this.distributionListIdMap.clear();
-
-				if (this.writeToDb) {
-					updateProgress(STEP_SIZE_PREPARE);
-
-					//clear tables!!
-					logger.info("Clearing current tables");
-					databaseServiceNew.getMessageModelFactory().deleteAll();
-					databaseServiceNew.getContactModelFactory().deleteAll();
-					databaseServiceNew.getGroupMessageModelFactory().deleteAll();
-					databaseServiceNew.getGroupMemberModelFactory().deleteAll();
-					databaseServiceNew.getGroupModelFactory().deleteAll();
-					databaseServiceNew.getDistributionListMessageModelFactory().deleteAll();
-					databaseServiceNew.getDistributionListMemberModelFactory().deleteAll();
-					databaseServiceNew.getDistributionListModelFactory().deleteAll();
-					databaseServiceNew.getBallotModelFactory().deleteAll();
-					databaseServiceNew.getBallotVoteModelFactory().deleteAll();
-					databaseServiceNew.getBallotChoiceModelFactory().deleteAll();
-					databaseServiceNew.getOutgoingGroupSyncRequestLogModelFactory().deleteAll();
-					databaseServiceNew.getIncomingGroupSyncRequestLogModelFactory().deleteAll();
-
-					// Remove all media files (don't remove recursively, tmp folder contain the restoring files
-					logger.info("Deleting current media files");
-					fileService.clearDirectory(fileService.getAppDataPath(), false);
-				}
-
-				List<FileHeader> fileHeaders = zipFile.getFileHeaders();
-
-				// The restore settings file contains the data backup format version
-				this.restoreSettings = getRestoreSettings(fileHeaders);
-
-				if (restoreSettings.isUnsupportedVersion()) {
-					throw new ThreemaException(getString(R.string.backup_version_mismatch));
-				}
-
-				// Restore the identity
-				logger.info("Restoring identity");
-				FileHeader identityHeader = Functional.select(
-					fileHeaders,
-					type -> TestUtil.compare(type.getFileName(), Tags.IDENTITY_FILE_NAME)
-				);
-				if (identityHeader != null && this.writeToDb) {
-					String identityContent;
-					try (InputStream inputStream = zipFile.getInputStream(identityHeader)) {
-						identityContent = IOUtils.toString(inputStream);
-					}
-
-					try {
-						if (!userService.restoreIdentity(identityContent, this.password)) {
-							throw new ThreemaException(getString(R.string.unable_to_restore_identity_because, "n/a"));
-						}
-						// If the backup is older than version 19, the contact avatar file has the
-						// id as suffix and is not "me". Therefore we need to include the identity
-						// in the id map, so that restoring this id's avatar file works.
-						if (restoreSettings.getVersion() < 19) {
-							identityIdMap.put(userService.getIdentity(), userService.getIdentity());
-						}
-					} catch (UnknownHostException e) {
-						throw e;
-					} catch (Exception e) {
-						throw new ThreemaException(getString(R.string.unable_to_restore_identity_because, e.getMessage()));
-					}
-
-					updateProgress(STEP_SIZE_IDENTITY);
-				}
-
-				// Restore nonces
-				logger.info("Restoring nonces");
-				int nonceCount = restoreNonces(fileHeaders);
-
-				//contacts, groups and distribution lists
-				logger.info("Restoring main files (contacts, groups, distribution lists)");
-				if(!this.restoreMainFiles(fileHeaders)) {
-					logger.error("restore main files failed");
-					//continue anyway!
-				}
-
-				updateProgress(STEP_SIZE_MAIN_FILES);
-
-				logger.info("Restoring message files");
-				messageCount = this.restoreMessageFiles(fileHeaders);
-				if(messageCount == 0) {
-					logger.error("restore message files failed");
-					//continue anyway!
-				}
-
-				logger.info("Restoring group avatar files");
-				if(!this.restoreGroupAvatarFiles(fileHeaders)) {
-					logger.error("restore group avatar files failed");
-					//continue anyway!
-				}
-
-				updateProgress(STEP_SIZE_GROUP_AVATARS);
-
-				logger.info("Restoring message media files");
-				mediaCount = this.restoreMessageMediaFiles(fileHeaders);
-				if (mediaCount == 0) {
-					logger.warn("No media files restored. Might be a backup without media?");
-					//continue anyway!
-				} else {
-					logger.info("{} media files found", mediaCount);
-				}
-
-				//restore all avatars
-				logger.info("Restoring avatars");
-				if(!this.restoreContactAvatars(fileHeaders)) {
-					logger.error("restore contact avatar files failed");
-					//continue anyway!
-				}
-
-				// Reset the profile pic upload so that the own profile picture is redistributed
-				preferenceService.setProfilePicUploadDate(new Date(0));
-				preferenceService.setProfilePicUploadData(null);
-
-				// If we're restoring a backup that does not yet contain lastUpdate (version <22),
-				// calculate lastUpdate ourselves based on restored data.
-				if (restoreSettings.getVersion() < 22) {
-					this.conversationService.calculateLastUpdateForAllConversations();
-				}
-
-				if (!writeToDb) {
-					stepSizeTotal += (messageCount * STEP_SIZE_MESSAGES);
-					stepSizeTotal += ((long) mediaCount * STEP_SIZE_MEDIA);
-					stepSizeTotal += (long) Math.ceil((double) nonceCount / NONCES_PER_STEP);
-				}
-			}
-
-			logger.info("Restore successful!");
-			restoreSuccess = true;
-			onFinished(null);
-
-			return true;
-		} catch (InterruptedException e) {
-			logger.error("Interrupted while restoring identity", e);
-			Thread.currentThread().interrupt();
-			message = "Interrupted while restoring identity";
-		} catch (RestoreCanceledException e) {
-			logger.error("Restore cancelled", e);
-			message = getString(R.string.restore_data_cancelled);
-		} catch(IOException e) {
-			logger.error("Exception while restoring backup", e);
-			message = getString(R.string.invalid_zip_restore_failed, e.getMessage());
-		} catch (Exception e) {
-			// wrong password? no connection? throw
-			logger.error("Exception while restoring backup", e);
-			message = e.getMessage();
-		}
-
-		onFinished(message);
-
-		return false;
-	}
-
-	@NonNull
-	private RestoreSettings getRestoreSettings(List<FileHeader> fileHeaders) throws ThreemaException, IOException {
-		FileHeader settingsHeader = Functional.select(fileHeaders, type -> TestUtil.compare(type.getFileName(), Tags.SETTINGS_FILE_NAME));
-		if (settingsHeader == null) {
-			logger.error("Settings file header is missing");
-			throw new ThreemaException(getString(R.string.invalid_backup));
-		}
-		try (
-			InputStream is = zipFile.getInputStream(settingsHeader);
-			InputStreamReader inputStreamReader = new InputStreamReader(is);
-			CSVReader csvReader = new CSVReader(inputStreamReader, false)
-		) {
-			RestoreSettings settings = new RestoreSettings();
-			settings.parse(csvReader.readAll());
-			return settings;
-		}
-	}
-
-	/**
-	 * restore the main files (contacts, groups, distribution lists)
-	 */
-	private boolean restoreMainFiles(List<FileHeader> fileHeaders) throws IOException, RestoreCanceledException {
-		FileHeader ballotMain = null;
-		FileHeader ballotChoice = null;
-		FileHeader ballotVote = null;
-		for (FileHeader fileHeader : fileHeaders) {
-			String fileName = fileHeader.getFileName();
-
-			if (fileName.endsWith(Tags.CSV_FILE_POSTFIX)) {
-				final String fileNameWithoutExtension = fileName.substring(0, fileName.length() - Tags.CSV_FILE_POSTFIX.length());
-				switch (fileNameWithoutExtension) {
-					case Tags.CONTACTS_FILE_NAME:
-						if (!this.restoreContactFile(fileHeader)) {
-							logger.error("restore contact file failed");
-							return false;
-						}
-						break;
-					case Tags.GROUPS_FILE_NAME:
-						if (!this.restoreGroupFile(fileHeader)) {
-							logger.error("restore group file failed");
-						}
-						break;
-					case Tags.DISTRIBUTION_LISTS_FILE_NAME:
-						if(!this.restoreDistributionListFile(fileHeader)) {
-							logger.error("restore distribution list file failed");
-						}
-						break;
-					case Tags.BALLOT_FILE_NAME:
-						ballotMain = fileHeader;
-						break;
-					case Tags.BALLOT_CHOICE_FILE_NAME:
-						ballotChoice = fileHeader;
-						break;
-					case Tags.BALLOT_VOTE_FILE_NAME:
-						ballotVote = fileHeader;
-						break;
-				}
-			}
-		}
-
-		if (TestUtil.required(ballotMain, ballotChoice, ballotVote)) {
-			this.restoreBallotFile(ballotMain, ballotChoice, ballotVote);
-		}
-
-		return true;
-	}
-
-	/**
-	 * Attempt to restore the nonces. If restoring of nonces fails for some reason 0 is returned.
-	 * Since we continue anyway, there is no need to distinguish between zero restored nonces and
-	 * a failure.
-	 */
-	private int restoreNonces(List<FileHeader> fileHeaders) throws IOException, RestoreCanceledException {
-		if (!writeToDb) {
-			// If not writing to the database only the count of nonces is required.
-			// Try to read optional nonces count file if present in backup.
-			logger.info("Get nonce counts");
-			int nonceCount = readNonceCounts(fileHeaders);
-			if (nonceCount >= 0) {
-				// If the nonce count is available return it and skip reading the whole nonces file.
-				logger.info("{} nonces in backup", nonceCount);
-				return nonceCount;
-			} else {
-				logger.info("Count nonces in backup.");
-			}
-		}
-
-		int nonceCountCsp = restoreNonces(
-			NonceScope.CSP,
-			Tags.NONCE_FILE_NAME_CSP + Tags.CSV_FILE_POSTFIX,
-			fileHeaders
-		);
-
-		int nonceCountD2d = restoreNonces(
-			NonceScope.D2D,
-			Tags.NONCE_FILE_NAME_D2D + Tags.CSV_FILE_POSTFIX,
-			fileHeaders
-		);
-
-		int remainingCsp = BackupUtils.calcRemainingNoncesProgress(NONCES_CHUNK_SIZE, NONCES_PER_STEP, nonceCountCsp);
-		int remainingD2d = BackupUtils.calcRemainingNoncesProgress(NONCES_CHUNK_SIZE, NONCES_PER_STEP, nonceCountD2d);
-		int remainingNonceProgress = remainingCsp + remainingD2d;
-		logger.debug("Remaining nonce progress: {}", remainingNonceProgress);
-		updateProgress((long) Math.ceil((double) remainingNonceProgress / NONCES_PER_STEP));
-
-		return nonceCountCsp + nonceCountD2d;
-	}
-
-	/**
-	 * Read the counts from the nonce counts file if available.
-	 *
-	 * @return the count, or -1 if the count could not be read from some reason.
-	 */
-	private int readNonceCounts(List<FileHeader> fileHeaders) throws IOException {
-		FileHeader nonceCountFileHeader = getFileHeader(Tags.NONCE_COUNTS_FILE + Tags.CSV_FILE_POSTFIX, fileHeaders);
-		if (nonceCountFileHeader == null) {
-			logger.info("No nonce count file available in backup");
-			return -1;
-		}
-		try (ZipInputStream inputStream = this.zipFile.getInputStream(nonceCountFileHeader);
-		     InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
-		     CSVReader csvReader = new CSVReader(inputStreamReader, true)
-		) {
-			CSVRow row = csvReader.readNextRow();
-			if (row == null) {
-				logger.warn("Could not read nonce count. File is empty.");
-				return -1;
-			}
-			return row.getInteger(Tags.TAG_NONCE_COUNT_CSP) + row.getInteger(Tags.TAG_NONCE_COUNT_D2D);
-		} catch (ThreemaException | NumberFormatException e) {
-			logger.warn("Could not read nonce count", e);
-			return -1;
-		}
-	}
-
-	/**
-	 * Get the file header where the file name matches the provided exactFileName.
-	 *
-	 * @param exactFileName The file name that is matched against
-	 * @param fileHeaders The file headers that are scanned
-	 * @return The first matching file header or null if none matches
-	 */
-	@Nullable
-	private FileHeader getFileHeader(@NonNull String exactFileName, List<FileHeader> fileHeaders) {
-		for (FileHeader fileHeader : fileHeaders) {
-			if (exactFileName.equals(fileHeader.getFileName())) {
-				return fileHeader;
-			}
-		}
-		logger.info("No file header for '{}' found", exactFileName);
-		return null;
-	}
-
-	private int restoreNonces(
-		@NonNull NonceScope scope,
-		@NonNull String nonceBackupFile,
-		@NonNull List<FileHeader> fileHeaders
-	) throws IOException, RestoreCanceledException {
-		logger.info("Restore {} nonces", scope);
-		final FileHeader nonceFileHeader = getFileHeader(nonceBackupFile, fileHeaders);
-		if (nonceFileHeader == null) {
-			logger.info("Nonce file header is null");
-			return 0;
-		}
-
-		try (ZipInputStream inputStream = this.zipFile.getInputStream(nonceFileHeader);
-		     InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
-		     CSVReader csvReader = new CSVReader(inputStreamReader, true)
-		) {
-			int nonceCount = 0;
-			boolean success = true;
-			CSVRow row;
-			List<byte[]> nonceBytes = new ArrayList<>(NONCES_CHUNK_SIZE);
-			while ((row = csvReader.readNextRow()) != null) {
-				try {
-					// Note that currently there is only one nonce per row, and therefore we do
-					// not need to read them as array. However, this gives us the flexibility to
-					// backup several nonces in one row (as we have done in 5.1-alpha3)
-					String[] nonces = row.getStrings(Tags.TAG_NONCES);
-					nonceCount += nonces.length;
-					if (writeToDb) {
-						for (String nonce : nonces) {
-							nonceBytes.add(Utils.hexStringToByteArray(nonce));
-							if (nonceBytes.size() >= NONCES_CHUNK_SIZE) {
-								success &= insertNonces(scope, nonceBytes);
-								nonceBytes.clear();
-							}
-						}
-					}
-				} catch (ThreemaException e) {
-					logger.error("Could not insert nonces", e);
-					return 0;
-				}
-			}
-			if (!nonceBytes.isEmpty()) {
-				success &= insertNonces(scope, nonceBytes);
-			}
-			if (success) {
-				logger.info("Restored {} {} nonces", nonceCount, scope);
-				return nonceCount;
-			} else {
-				logger.warn("Restoring {} nonces was not successfull", scope);
-				return 0;
-			}
-		}
-	}
-
-	private boolean insertNonces(
-		@NonNull NonceScope scope,
-		@NonNull List<byte[]> nonces
-	) throws RestoreCanceledException {
-		logger.debug("Write {} nonces to database", nonces.size());
-		boolean success = nonceFactory.insertHashedNoncesJava(scope, nonces);
-		updateProgress(nonces.size() / NONCES_PER_STEP);
-		return success;
-	}
-
-	/**
-	 * restore all avatars and profile pics
-	 */
-	private boolean restoreContactAvatars(List<FileHeader> fileHeaders) {
-		for (FileHeader fileHeader : fileHeaders) {
-			String fileName = fileHeader.getFileName();
-			if (fileName.startsWith(Tags.CONTACT_AVATAR_FILE_PREFIX)) {
-				if(!this.restoreContactAvatarFile(fileHeader)) {
-					logger.error("restore contact avatar {} file failed or skipped", fileName);
-					//continue anyway
-				}
-			}
-			else if (fileName.startsWith(Tags.CONTACT_PROFILE_PIC_FILE_PREFIX)) {
-				if(!this.restoreContactPhotoFile(fileHeader)) {
-					logger.error("restore contact profile pic {} file failed or skipped", fileName);
-					//continue anyway
-				}
-			}
-		}
-		return true;
-	}
-	/**
-	 * restore all message files
-	 */
-	private int restoreMessageFiles(List<FileHeader> fileHeaders) throws IOException, RestoreCanceledException {
-		int count = 0;
-		for (FileHeader fileHeader : fileHeaders) {
-
-			String fileName = fileHeader.getFileName();
-
-			if (!fileName.endsWith(Tags.CSV_FILE_POSTFIX)) {
-				continue;
-			}
-
-			if (fileName.startsWith(Tags.MESSAGE_FILE_PREFIX)) {
-				try {
-					count += this.restoreContactMessageFile(fileHeader);
-				} catch (ThreemaException e) {
-					logger.error("restore contact message file failed");
-					return 0;
-				}
-			}
-			else if (fileName.startsWith(Tags.GROUP_MESSAGE_FILE_PREFIX)) {
-				try {
-					count += this.restoreGroupMessageFile(fileHeader);
-				} catch (ThreemaException e) {
-					logger.error("restore group message file failed");
-					return 0;
-				}
-			}
-			else if (fileName.startsWith(Tags.DISTRIBUTION_LIST_MESSAGE_FILE_PREFIX)) {
-				try {
-					count += this.restoreDistributionListMessageFile(fileHeader);
-				} catch (ThreemaException e) {
-					logger.error("restore distributionList message file failed");
-					return 0;
-				}
-			}
-		}
-		return count;
-	}
-
-	/**
-	 * restore all group avatars!
-	 */
-	private boolean restoreGroupAvatarFiles(List<FileHeader> fileHeaders) {
-		boolean success = true;
-		for(FileHeader fileHeader: fileHeaders) {
-			String fileName = fileHeader.getFileName();
-
-			if (!fileName.startsWith(Tags.GROUP_AVATAR_PREFIX)) {
-				continue;
-			}
-
-			final String groupUid = fileName.substring(Tags.GROUP_AVATAR_PREFIX.length());
-			if (!TestUtil.isEmptyOrNull(groupUid)) {
-				Integer groupId = groupUidMap.get(groupUid);
-				if (groupId != null) {
-					GroupModel m = databaseServiceNew.getGroupModelFactory().getById(groupId);
-					if (m != null) {
-						try (InputStream inputStream = zipFile.getInputStream(fileHeader)) {
-							this.fileService.writeGroupAvatar(m, IOUtils.toByteArray(inputStream));
-						} catch (Exception e) {
-							//ignore, just the avatar :)
-							success = false;
-						}
-						//
-					}
-				}
-			}
-		}
-
-		return success;
-	}
-
-	/**
-	 * restore all message media
-	 */
-	private int restoreMessageMediaFiles(List<FileHeader> fileHeaders) throws RestoreCanceledException {
-		int count = 0;
-
-		count += this.restoreMessageMediaFiles(
-			fileHeaders,
-			Tags.MESSAGE_MEDIA_FILE_PREFIX,
-			Tags.MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX,
-			uid -> databaseServiceNew.getMessageModelFactory().getByUid(uid)
-		);
-
-		count += this.restoreMessageMediaFiles(
-			fileHeaders,
-			Tags.GROUP_MESSAGE_MEDIA_FILE_PREFIX,
-			Tags.GROUP_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX,
-			uid -> databaseServiceNew.getGroupMessageModelFactory().getByUid(uid)
-		);
-
-		count += this.restoreMessageMediaFiles(
-			fileHeaders,
-			Tags.DISTRIBUTION_LIST_MESSAGE_MEDIA_FILE_PREFIX,
-			Tags.DISTRIBUTION_LIST_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX,
-			uid -> databaseServiceNew.getDistributionListMessageModelFactory().getByUid(uid)
-		);
-
-		return count;
-	}
-
-
-	/**
-	 * restore all message media
-	 */
-	private int restoreMessageMediaFiles(
-		@NonNull List<FileHeader> fileHeaders,
-		@NonNull String filePrefix,
-		@NonNull String thumbnailPrefix,
-		@NonNull GetMessageModel getMessageModel
-	) throws RestoreCanceledException {
-		int count = 0;
-
-		//process all thumbnails
-		Map<String, FileHeader> thumbnailFileHeaders = new HashMap<>();
-
-		for (FileHeader fileHeader : fileHeaders) {
-			String fileName = fileHeader.getFileName();
-			if(!TestUtil.isEmptyOrNull(fileName)
-					&& fileName.startsWith(thumbnailPrefix)) {
-				thumbnailFileHeaders.put(fileName, fileHeader);
-			}
-		}
-
-		for (FileHeader fileHeader : fileHeaders) {
-			String fileName = fileHeader.getFileName();
-
-			String messageUid;
-			if (fileName.startsWith(filePrefix)) {
-				messageUid = fileName.substring(filePrefix.length());
-			} else if (fileName.startsWith(thumbnailPrefix)) {
-				messageUid = fileName.substring(thumbnailPrefix.length());
-			} else {
-				continue;
-			}
-
-			AbstractMessageModel model = getMessageModel.get(messageUid);
-
-			if (model != null) {
-				try {
-					if (fileName.startsWith(thumbnailPrefix)) {
-						// restore thumbnail
-						if (this.writeToDb) {
-							FileHeader thumbnailFileHeader = thumbnailFileHeaders.get(thumbnailPrefix + messageUid);
-							if (thumbnailFileHeader != null) {
-								try (ZipInputStream inputStream = zipFile.getInputStream(thumbnailFileHeader)) {
-									byte[] thumbnailBytes = IOUtils.toByteArray(inputStream);
-									if (thumbnailBytes != null && thumbnailBytes.length < MAX_THUMBNAIL_SIZE_BYTES) {
-										this.fileService.saveThumbnail(model, thumbnailBytes);
-									}
-								} catch (OutOfMemoryError e) {
-									logger.error("Not enough memory for thumbnail", e);
-								}
-							}
-						}
-					} else {
-						if (this.writeToDb) {
-							byte[] imageData;
-							try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
-								imageData = IOUtils.toByteArray(inputStream);
-								this.fileService.writeConversationMedia(model, imageData);
-							} catch (OutOfMemoryError e) {
-								logger.error("Not enough memory for media", e);
-								imageData = null;
-							}
-
-							if (MessageUtil.canHaveThumbnailFile(model)) {
-								//check if a thumbnail file is in backup
-								FileHeader thumbnailFileHeader = thumbnailFileHeaders.get(thumbnailPrefix + messageUid);
-
-								//if no thumbnail file exist in backup, generate one
-								if (thumbnailFileHeader == null && imageData != null) {
-									this.fileService.writeConversationMediaThumbnail(model, imageData);
-								}
-							}
-						}
-					}
-					count++;
-					updateProgress(STEP_SIZE_MEDIA);
-				} catch (RestoreCanceledException e) {
-					throw new RestoreCanceledException();
-				} catch (Exception x) {
-					logger.error("Exception", x);
-					//ignore and continue
-				}
-			} else {
-				count++;
-			}
-		}
-		return count;
-	}
-
-	private boolean restoreContactFile(@NonNull FileHeader fileHeader) throws IOException, RestoreCanceledException {
-		return this.processCsvFile(fileHeader, row -> {
-			try {
-				ContactModel contactModel = createContactModel(row, restoreSettings);
-				if (writeToDb) {
-					//set the default color
-					ContactModelFactory contactModelFactory = databaseServiceNew.getContactModelFactory();
-					contactModelFactory.createOrUpdate(contactModel);
-					restoreResult.incContactSuccess();
-				}
-			} catch (Exception x) {
-				logger.error("Could not restore contact", x);
-				if (writeToDb) {
-					//process next
-					restoreResult.incContactFailed();
-				}
-			}
-		});
-	}
-
-	private boolean restoreContactAvatarFile(@NonNull FileHeader fileHeader){
-		// Look up avatar filename
-		String filename = fileHeader.getFileName();
-		if (TestUtil.isEmptyOrNull(filename)) {
-			return false;
-		}
-
-		// Look up contact model for this avatar
-		String identityId = filename.substring(Tags.CONTACT_AVATAR_FILE_PREFIX.length());
-		if (TestUtil.isEmptyOrNull(identityId)) {
-			return false;
-		}
-
-		ContactModel contactModel;
-		if (Tags.CONTACT_AVATAR_FILE_SUFFIX_ME.equals(identityId)) {
-			contactModel = contactService.getMe();
-		} else {
-			contactModel = contactService.getByIdentity(identityIdMap.get(identityId));
-		}
-
-		if (contactModel == null) {
-			return false;
-		}
-
-		// Set contact avatar
-		try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
-			return fileService.writeUserDefinedProfilePicture(
-				contactModel.getIdentity(),
-				IOUtils.toByteArray(inputStream)
-			);
-		} catch (Exception e) {
-			logger.error("Exception while writing contact avatar", e);
-			return false;
-		}
-	}
-
-	private boolean restoreContactPhotoFile(@NonNull FileHeader fileHeader){
-		// Look up profile picture filename
-		String filename = fileHeader.getFileName();
-		if(TestUtil.isEmptyOrNull(filename)) {
-			return false;
-		}
-
-		// Look up contact model for this avatar
-		String identityId = filename.substring(Tags.CONTACT_PROFILE_PIC_FILE_PREFIX.length());
-		if (TestUtil.isEmptyOrNull(identityId)) {
-			return false;
-		}
-		ContactModel contactModel = contactService.getByIdentity(identityIdMap.get(identityId));
-		if (contactModel == null) {
-			return false;
-		}
-
-		// Set contact profile picture
-		try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
-			return fileService.writeContactDefinedProfilePicture(
-				contactModel.getIdentity(),
-				IOUtils.toByteArray(inputStream));
-		} catch (Exception e) {
-			logger.error("Exception while writing contact profile picture", e);
-			return false;
-		}
-	}
-
-	private boolean restoreGroupFile(@NonNull FileHeader fileHeader) throws IOException, RestoreCanceledException {
-		return this.processCsvFile(fileHeader, row -> {
-			try {
-				GroupModel groupModel = createGroupModel(row, restoreSettings);
-
-				if (writeToDb) {
-					databaseServiceNew.getGroupModelFactory().create(
-							groupModel
-					);
-
-					if (restoreSettings.getVersion() >= 19) {
-						groupUidMap.put(row.getString(Tags.TAG_GROUP_UID), groupModel.getId());
-					} else {
-						groupUidMap.put(BackupUtils.buildGroupUid(groupModel), groupModel.getId());
-					}
-
-					restoreResult.incContactSuccess();
-				}
-
-				if (writeToDb) {
-					String myIdentity = userService.getIdentity();
-					boolean isInMemberList = false;
-
-					List<GroupMemberModel> groupMemberModels = createGroupMembers(row, groupModel.getId());
-
-					for (GroupMemberModel groupMemberModel : groupMemberModels) {
-						if (!myIdentity.equals(groupMemberModel.getIdentity())) {
-							databaseServiceNew.getGroupMemberModelFactory().create(groupMemberModel);
-						} else {
-							isInMemberList = true;
-						}
-					}
-					if (restoreSettings.getVersion() < 25) {
-						// In this case the group user state is not included in the backup and we
-						// need to determine the state based on the group member list.
-						groupModel.setUserState(isInMemberList ? MEMBER : LEFT);
-						databaseServiceNew.getGroupModelFactory().update(groupModel);
-					}
-				}
-			} catch (Exception x) {
-				logger.error("Could not restore group", x);
-				if (writeToDb) {
-					//process next
-					restoreResult.incContactFailed();
-				}
-			}
-		});
-	}
-
-	private boolean restoreDistributionListFile(@NonNull FileHeader fileHeader) throws IOException, RestoreCanceledException {
-		return this.processCsvFile(fileHeader, row -> {
-			try {
-				DistributionListModel distributionListModel = createDistributionListModel(row);
-
-				if (writeToDb) {
-					databaseServiceNew.getDistributionListModelFactory().create(
-							distributionListModel);
-					distributionListIdMap.put(BackupUtils.buildDistributionListUid(distributionListModel), distributionListModel.getId());
-					restoreResult.incContactSuccess();
-				}
-
-				List<DistributionListMemberModel> distributionListMemberModels = createDistributionListMembers(row, distributionListModel.getId());
-				if (writeToDb) {
-					for (DistributionListMemberModel distributionListMemberModel : distributionListMemberModels) {
-						databaseServiceNew.getDistributionListMemberModelFactory().create(
-								distributionListMemberModel
-						);
-					}
-				}
-			} catch (Exception x) {
-				logger.error("Could not restore distribution list", x);
-				if (writeToDb) {
-					//process next
-					restoreResult.incContactFailed();
-				}
-			}
-		});
-	}
-
-	private void restoreBallotFile(
-		@NonNull FileHeader ballotMain,
-		@NonNull final FileHeader ballotChoice,
-		@NonNull FileHeader ballotVote
-	) throws IOException, RestoreCanceledException {
-		this.processCsvFile(ballotMain, row -> {
-			try {
-				BallotModel ballotModel = createBallotModel(row);
-
-				if (writeToDb) {
-					databaseServiceNew.getBallotModelFactory().create(
-							ballotModel
-					);
-
-					ballotIdMap.put(BackupUtils.buildBallotUid(ballotModel), ballotModel.getId());
-					ballotOldIdMap.put(row.getInteger(Tags.TAG_BALLOT_ID), ballotModel.getId());
-				}
-
-				LinkBallotModel ballotLinkModel = createLinkBallotModel(row, ballotModel.getId());
-
-				if (writeToDb) {
-					if(ballotLinkModel == null) {
-						//link failed
-						logger.error("link failed");
-					}
-					if(ballotLinkModel instanceof GroupBallotModel) {
-						databaseServiceNew.getGroupBallotModelFactory().create(
-								(GroupBallotModel)ballotLinkModel
-								);
-					}
-					else if(ballotLinkModel instanceof IdentityBallotModel) {
-						databaseServiceNew.getIdentityBallotModelFactory().create(
-								(IdentityBallotModel)ballotLinkModel
-						);
-					}
-					else {
-						logger.error("not handled link");
-					}
-				}
-
-			} catch (Exception x) {
-				logger.error("Could not restore ballot", x);
-				if (writeToDb) {
-					//process next
-					restoreResult.incContactFailed();
-				}
-			}
-		});
-
-		this.processCsvFile(ballotChoice, row -> {
-			try {
-				BallotChoiceModel ballotChoiceModel = createBallotChoiceModel(row);
-				if (ballotChoiceModel != null && writeToDb) {
-					databaseServiceNew.getBallotChoiceModelFactory().create(
-							ballotChoiceModel
-					);
-					ballotChoiceIdMap.put(BackupUtils.buildBallotChoiceUid(ballotChoiceModel), ballotChoiceModel.getId());
-				}
-			} catch (Exception x) {
-				logger.error("Exception", x);
-				//continue!
-			}
-		});
-
-		this.processCsvFile(ballotVote, row -> {
-			try {
-				BallotVoteModel ballotVoteModel = createBallotVoteModel(row);
-				if (ballotVoteModel != null && writeToDb) {
-					databaseServiceNew.getBallotVoteModelFactory().create(
-							ballotVoteModel
-					);
-				}
-			} catch (Exception x) {
-				logger.error("Exception", x);
-				//continue!
-			}
-		});
-	}
-
-	private GroupModel createGroupModel(CSVRow row, RestoreSettings restoreSettings) throws ThreemaException {
-		GroupModel groupModel = new GroupModel();
-		groupModel.setApiGroupId(new GroupId(row.getString(Tags.TAG_GROUP_ID)));
-		groupModel.setCreatorIdentity(row.getString(Tags.TAG_GROUP_CREATOR));
-		groupModel.setName(row.getString(Tags.TAG_GROUP_NAME));
-		groupModel.setCreatedAt(row.getDate(Tags.TAG_GROUP_CREATED_AT));
-
-		if(restoreSettings.getVersion() >= 4) {
-			groupModel.setDeleted(row.getBoolean(Tags.TAG_GROUP_DELETED));
-		} else {
-			groupModel.setDeleted(false);
-		}
-		if(restoreSettings.getVersion() >= 14) {
-			groupModel.setArchived(row.getBoolean(Tags.TAG_GROUP_ARCHIVED));
-		}
-
-		if (restoreSettings.getVersion() >= 17) {
-			groupModel.setGroupDesc(row.getString(Tags.TAG_GROUP_DESC));
-			groupModel.setGroupDescTimestamp(row.getDate(Tags.TAG_GROUP_DESC_TIMESTAMP));
-		}
-
-		if (restoreSettings.getVersion() >= 22) {
-			groupModel.setLastUpdate(row.getDate(Tags.TAG_GROUP_LAST_UPDATE));
-		}
-
-		if (restoreSettings.getVersion() >= 25) {
-			groupModel.setUserState(UserState.valueOf(row.getInteger(Tags.TAG_GROUP_USER_STATE)));
-		}
-
-		return groupModel;
-	}
-
-	private BallotModel createBallotModel(CSVRow row) throws ThreemaException {
-		BallotModel ballotModel = new BallotModel();
-
-		ballotModel.setApiBallotId(row.getString(Tags.TAG_BALLOT_API_ID));
-		ballotModel.setCreatorIdentity(row.getString(Tags.TAG_BALLOT_API_CREATOR));
-		ballotModel.setName(row.getString(Tags.TAG_BALLOT_NAME));
-
-		String state = row.getString(Tags.TAG_BALLOT_STATE);
-		if(TestUtil.compare(state, BallotModel.State.CLOSED.toString())) {
-			ballotModel.setState(BallotModel.State.CLOSED);
-		}
-		else if(TestUtil.compare(state, BallotModel.State.OPEN.toString())) {
-			ballotModel.setState(BallotModel.State.OPEN);
-		}
-		else if(TestUtil.compare(state, BallotModel.State.TEMPORARY.toString())) {
-			ballotModel.setState(BallotModel.State.TEMPORARY);
-		}
-
-		String assessment = row.getString(Tags.TAG_BALLOT_ASSESSMENT);
-		if(TestUtil.compare(assessment, BallotModel.Assessment.MULTIPLE_CHOICE.toString())) {
-			ballotModel.setAssessment(BallotModel.Assessment.MULTIPLE_CHOICE);
-		}
-		else if(TestUtil.compare(assessment, BallotModel.Assessment.SINGLE_CHOICE.toString())) {
-			ballotModel.setAssessment(BallotModel.Assessment.SINGLE_CHOICE);
-		}
-
-		String type = row.getString(Tags.TAG_BALLOT_TYPE);
-		if(TestUtil.compare(type, BallotModel.Type.INTERMEDIATE.toString())) {
-			ballotModel.setType(BallotModel.Type.INTERMEDIATE);
-		}
-		else if(TestUtil.compare(type, BallotModel.Type.RESULT_ON_CLOSE.toString())) {
-			ballotModel.setType(BallotModel.Type.RESULT_ON_CLOSE);
-		}
-
-		String choiceType = row.getString(Tags.TAG_BALLOT_C_TYPE);
-		if(TestUtil.compare(choiceType, BallotModel.ChoiceType.TEXT.toString())) {
-			ballotModel.setChoiceType(BallotModel.ChoiceType.TEXT);
-		}
-
-		ballotModel.setLastViewedAt(row.getDate(Tags.TAG_BALLOT_LAST_VIEWED_AT));
-		ballotModel.setCreatedAt(row.getDate(Tags.TAG_BALLOT_CREATED_AT));
-		ballotModel.setModifiedAt(row.getDate(Tags.TAG_BALLOT_MODIFIED_AT));
-
-		return ballotModel;
-	}
-
-	private LinkBallotModel createLinkBallotModel(CSVRow row, int ballotId) throws ThreemaException {
-		String reference = row.getString(Tags.TAG_BALLOT_REF);
-		String referenceId = row.getString(Tags.TAG_BALLOT_REF_ID);
-		Integer groupId = null;
-		String identity = null;
-
-		if(reference.endsWith("GroupBallotModel")) {
-			groupId = this.groupUidMap.get(referenceId);
-		}
-		else if(reference.endsWith("IdentityBallotModel")) {
-			identity = referenceId;
-		}
-		else {
-			//first try to get the reference as group
-			groupId = this.groupUidMap.get(referenceId);
-			if(groupId == null) {
-				if(referenceId != null && referenceId.length() == ProtocolDefines.IDENTITY_LEN) {
-					identity = referenceId;
-				}
-			}
-		}
-
-		if(groupId != null) {
-			GroupBallotModel linkBallotModel = new GroupBallotModel();
-			linkBallotModel.setBallotId(ballotId);
-			linkBallotModel.setGroupId(groupId);
-
-			return linkBallotModel;
-		}
-		else if(identity != null) {
-			IdentityBallotModel linkBallotModel = new IdentityBallotModel();
-			linkBallotModel.setBallotId(ballotId);
-			linkBallotModel.setIdentity(referenceId);
-			return linkBallotModel;
-		}
-
-		if(writeToDb) {
-			logger.error("invalid ballot reference " + reference + " with id " + referenceId);
-			return null;
-		}
-		//not a valid reference!
-		return null;
-	}
-
-	private BallotChoiceModel createBallotChoiceModel(CSVRow row) throws ThreemaException {
-		Integer ballotId = ballotIdMap.get(row.getString(Tags.TAG_BALLOT_CHOICE_BALLOT_UID));
-		if(ballotId == null) {
-			logger.error("invalid ballotId");
-			return null;
-		}
-
-		BallotChoiceModel ballotChoiceModel = new BallotChoiceModel();
-		ballotChoiceModel.setBallotId(ballotId);
-		ballotChoiceModel.setApiBallotChoiceId(row.getInteger(Tags.TAG_BALLOT_CHOICE_API_ID));
-		ballotChoiceModel.setApiBallotChoiceId(row.getInteger(Tags.TAG_BALLOT_CHOICE_API_ID));
-
-		String type = row.getString(Tags.TAG_BALLOT_CHOICE_TYPE);
-		if(TestUtil.compare(type, BallotChoiceModel.Type.Text.toString())) {
-			ballotChoiceModel.setType(BallotChoiceModel.Type.Text);
-		}
-
-		ballotChoiceModel.setName(row.getString(Tags.TAG_BALLOT_CHOICE_NAME));
-		ballotChoiceModel.setVoteCount(row.getInteger(Tags.TAG_BALLOT_CHOICE_VOTE_COUNT));
-		ballotChoiceModel.setOrder(row.getInteger(Tags.TAG_BALLOT_CHOICE_ORDER));
-		ballotChoiceModel.setCreatedAt(row.getDate(Tags.TAG_BALLOT_CHOICE_CREATED_AT));
-		ballotChoiceModel.setModifiedAt(row.getDate(Tags.TAG_BALLOT_CHOICE_MODIFIED_AT));
-
-		return ballotChoiceModel;
-	}
-
-	private BallotVoteModel createBallotVoteModel(CSVRow row) throws ThreemaException {
-		Integer ballotId = ballotIdMap.get(row.getString(Tags.TAG_BALLOT_VOTE_BALLOT_UID));
-		Integer ballotChoiceId = ballotChoiceIdMap.get(row.getString(Tags.TAG_BALLOT_VOTE_CHOICE_UID));
-
-		if(!TestUtil.required(ballotId, ballotChoiceId)) {
-			return null;
-		}
-
-		BallotVoteModel ballotVoteModel = new BallotVoteModel();
-		ballotVoteModel.setBallotId(ballotId);
-		ballotVoteModel.setBallotChoiceId(ballotChoiceId);
-		ballotVoteModel.setVotingIdentity(row.getString(Tags.TAG_BALLOT_VOTE_IDENTITY));
-		ballotVoteModel.setChoice(row.getInteger(Tags.TAG_BALLOT_VOTE_CHOICE));
-		ballotVoteModel.setCreatedAt(row.getDate(Tags.TAG_BALLOT_VOTE_CREATED_AT));
-		ballotVoteModel.setModifiedAt(row.getDate(Tags.TAG_BALLOT_VOTE_MODIFIED_AT));
-		return ballotVoteModel;
-	}
-
-	private int restoreContactMessageFile(FileHeader fileHeader) throws IOException, ThreemaException, RestoreCanceledException {
-		final int[] count = {0};
-
-		String fileName = fileHeader.getFileName();
-		if(fileName == null) {
-			throw new ThreemaException(null);
-		}
-
-		final String identityId = fileName.substring(Tags.MESSAGE_FILE_PREFIX.length(), fileName.indexOf(Tags.CSV_FILE_POSTFIX));
-		if (TestUtil.isEmptyOrNull(identityId)) {
-			throw new ThreemaException(null);
-		}
-
-		String identity = identityIdMap.get(identityId);
-
-		if (!this.processCsvFile(fileHeader, row -> {
-			try {
-				MessageModel messageModel = createMessageModel(row, restoreSettings);
-				messageModel.setIdentity(identity);
-				count[0]++;
-
-				if (writeToDb) {
-					updateProgress(STEP_SIZE_MESSAGES);
-
-					//faster, do not make a createORupdate to safe queries
-					databaseServiceNew.getMessageModelFactory().create(
-							messageModel
-					);
-					restoreResult.incMessageSuccess();
-				}
-			} catch (RestoreCanceledException e) {
-				throw new RestoreCanceledException();
-			} catch (Exception x) {
-				if (writeToDb) {
-					restoreResult.incMessageFailed();
-				}
-			}
-		})) {
-			throw new ThreemaException(null);
-		}
-		return count[0];
-	}
-
-	private int restoreGroupMessageFile(FileHeader fileHeader)  throws IOException, ThreemaException, RestoreCanceledException {
-		final int[] count = {0};
-
-		String fileName = fileHeader.getFileName();
-		if(fileName == null) {
-			throw new ThreemaException(null);
-		}
-
-		final String groupUid = fileName.substring(Tags.GROUP_MESSAGE_FILE_PREFIX.length(), fileName.indexOf(Tags.CSV_FILE_POSTFIX));
-		if (TestUtil.isEmptyOrNull(groupUid)) {
-			throw new ThreemaException(null);
-		}
-
-		if (!this.processCsvFile(fileHeader, row -> {
-			try {
-				GroupMessageModel groupMessageModel = createGroupMessageModel(row, restoreSettings);
-				count[0]++;
-
-				if (writeToDb) {
-					updateProgress(STEP_SIZE_MESSAGES);
-					Integer groupId = groupUidMap.get(groupUid);
-					if (groupId != null) {
-						groupMessageModel.setGroupId(groupId);
-						databaseServiceNew.getGroupMessageModelFactory().create(
-								groupMessageModel
-						);
-					}
-					restoreResult.incMessageSuccess();
-				}
-			} catch (RestoreCanceledException e) {
-				throw new RestoreCanceledException();
-			} catch (Exception x) {
-				if (writeToDb) {
-					restoreResult.incMessageFailed();
-				}
-			}
-		})) {
-			throw new ThreemaException(null);
-		}
-		return count[0];
-	}
-
-	private int restoreDistributionListMessageFile(FileHeader fileHeader) throws IOException, ThreemaException, RestoreCanceledException {
-		final int[] count = {0};
-
-		String fileName = fileHeader.getFileName();
-		if(fileName == null) {
-			throw new ThreemaException(null);
-		}
-
-		String[] pieces = fileName.substring(Tags.DISTRIBUTION_LIST_MESSAGE_FILE_PREFIX.length(), fileName.indexOf(Tags.CSV_FILE_POSTFIX)).split("-");
-
-		if(pieces.length != 1) {
-			throw new ThreemaException(null);
-		}
-
-		final String distributionListBackupUid = pieces[0];
-
-		if (TestUtil.isEmptyOrNull(distributionListBackupUid)) {
-			throw new ThreemaException(null);
-		}
-
-		if (!this.processCsvFile(fileHeader, row -> {
-			try {
-				DistributionListMessageModel distributionListMessageModel = createDistributionListMessageModel(row, restoreSettings);
-				count[0]++;
-
-				if (writeToDb) {
-					updateProgress(STEP_SIZE_MESSAGES);
-
-					final Long distributionListId = distributionListIdMap.get(distributionListBackupUid);
-					if (distributionListId != null) {
-						distributionListMessageModel.setDistributionListId(distributionListId);
-						databaseServiceNew.getDistributionListMessageModelFactory().createOrUpdate(
-								distributionListMessageModel
-						);
-					}
-					restoreResult.incContactSuccess();
-				}
-			} catch (RestoreCanceledException e) {
-				throw new RestoreCanceledException();
-			} catch (Exception x) {
-				if (writeToDb) {
-					restoreResult.incMessageFailed();
-				}
-			}
-		})) {
-			throw new ThreemaException(null);
-		}
-		return count[0];
-	}
-
-	private DistributionListModel createDistributionListModel(CSVRow row) throws ThreemaException {
-		DistributionListModel distributionListModel = new DistributionListModel();
-		distributionListModel.setId(row.getLong(Tags.TAG_DISTRIBUTION_LIST_ID));
-		distributionListModel.setName(row.getString(Tags.TAG_DISTRIBUTION_LIST_NAME));
-		distributionListModel.setCreatedAt(row.getDate(Tags.TAG_DISTRIBUTION_CREATED_AT));
-		if(restoreSettings.getVersion() >= 14) {
-			distributionListModel.setArchived(row.getBoolean(Tags.TAG_DISTRIBUTION_LIST_ARCHIVED));
-		}
-		if (restoreSettings.getVersion() >= 22) {
-			distributionListModel.setLastUpdate(row.getDate(Tags.TAG_DISTRIBUTION_LAST_UPDATE));
-		}
-		return distributionListModel;
-	}
-
-	private List<GroupMemberModel> createGroupMembers(CSVRow row, int groupId) throws ThreemaException {
-		List<GroupMemberModel> res = new ArrayList<>();
-		for(String identity: row.getStrings(Tags.TAG_GROUP_MEMBERS)) {
-			if(!TestUtil.isEmptyOrNull(identity)) {
-				GroupMemberModel m = new GroupMemberModel();
-				m.setGroupId(groupId);
-				m.setIdentity(identity);
-				res.add(m);
-			}
-		}
-		return res;
-	}
-
-	private List<DistributionListMemberModel> createDistributionListMembers(CSVRow row, long distributionListId) throws ThreemaException {
-		List<DistributionListMemberModel> res = new ArrayList<>();
-		for(String identity: row.getStrings(Tags.TAG_DISTRIBUTION_MEMBERS)) {
-			if(!TestUtil.isEmptyOrNull(identity)) {
-				DistributionListMemberModel m = new DistributionListMemberModel();
-				m.setDistributionListId(distributionListId);
-				m.setIdentity(identity);
-				m.setActive(true);
-				res.add(m);
-			}
-		}
-		return res;
-	}
-
-	private ContactModel createContactModel(CSVRow row, RestoreSettings restoreSettings) throws ThreemaException {
-
-		ContactModel contactModel = new ContactModel(
-				row.getString(Tags.TAG_CONTACT_IDENTITY),
-				Utils.hexStringToByteArray(row.getString(Tags.TAG_CONTACT_PUBLIC_KEY)));
-
-		String verificationString = row.getString(Tags.TAG_CONTACT_VERIFICATION_LEVEL);
-		VerificationLevel verification = VerificationLevel.UNVERIFIED;
-
-		if (verificationString.equals(VerificationLevel.SERVER_VERIFIED.name())) {
-			verification = VerificationLevel.SERVER_VERIFIED;
-		} else if (verificationString.equals(VerificationLevel.FULLY_VERIFIED.name())) {
-			verification = VerificationLevel.FULLY_VERIFIED;
-		}
-		contactModel.verificationLevel = verification;
-		contactModel.setFirstName(row.getString(Tags.TAG_CONTACT_FIRST_NAME));
-		contactModel.setLastName(row.getString(Tags.TAG_CONTACT_LAST_NAME));
-
-		if(restoreSettings.getVersion() >= 3) {
-			contactModel.setPublicNickName(row.getString(Tags.TAG_CONTACT_NICK_NAME));
-		}
-		if(restoreSettings.getVersion() >= 13) {
-			final boolean isHidden = row.getBoolean(Tags.TAG_CONTACT_HIDDEN);
-			// Contacts are marked as hidden if their acquaintance level is GROUP
-			contactModel.setAcquaintanceLevel(isHidden ? AcquaintanceLevel.GROUP : AcquaintanceLevel.DIRECT);
-		}
-		if(restoreSettings.getVersion() >= 14) {
-			contactModel.setArchived(row.getBoolean(Tags.TAG_CONTACT_ARCHIVED));
-		}
-		if (restoreSettings.getVersion() >= 19) {
-			identityIdMap.put(row.getString(Tags.TAG_CONTACT_IDENTITY_ID), contactModel.getIdentity());
-		} else {
-			identityIdMap.put(contactModel.getIdentity(), contactModel.getIdentity());
-		}
-		if (restoreSettings.getVersion() >= 22) {
-			contactModel.setLastUpdate(row.getDate(Tags.TAG_CONTACT_LAST_UPDATE));
-		}
-		contactModel.setIsRestored(true);
-
-		return contactModel;
-	}
-
-	private void fillMessageModel(AbstractMessageModel messageModel, CSVRow row, RestoreSettings restoreSettings) throws ThreemaException {
-		messageModel.setApiMessageId(row.getString(Tags.TAG_MESSAGE_API_MESSAGE_ID));
-		messageModel.setOutbox(row.getBoolean(Tags.TAG_MESSAGE_IS_OUTBOX));
-		messageModel.setRead(row.getBoolean(Tags.TAG_MESSAGE_IS_READ));
-		messageModel.setSaved(row.getBoolean(Tags.TAG_MESSAGE_IS_SAVED));
-
-		String messageState = row.getString(Tags.TAG_MESSAGE_MESSAGE_STATE);
-		MessageState state = null;
-		if (messageState.equals(MessageState.PENDING.name())) {
-			state = MessageState.PENDING;
-		} else if (messageState.equals(MessageState.SENDFAILED.name())) {
-			state = MessageState.SENDFAILED;
-		} else if (messageState.equals(MessageState.USERACK.name())) {
-			state = MessageState.USERACK;
-		} else if (messageState.equals(MessageState.USERDEC.name())) {
-			state = MessageState.USERDEC;
-		} else if (messageState.equals(MessageState.DELIVERED.name())) {
-			state = MessageState.DELIVERED;
-		} else if (messageState.equals(MessageState.READ.name())) {
-			state = MessageState.READ;
-		} else if (messageState.equals(MessageState.SENDING.name())) {
-			state = MessageState.SENDING;
-		} else if (messageState.equals(MessageState.SENT.name())) {
-			state = MessageState.SENT;
-		} else if (messageState.equals(MessageState.CONSUMED.name())) {
-			state = MessageState.CONSUMED;
-		} else if (messageState.equals(MessageState.FS_KEY_MISMATCH.name())) {
-			state = MessageState.FS_KEY_MISMATCH;
-		}
-
-		messageModel.setState(state);
-		MessageType messageType = MessageType.TEXT;
-		@MessageContentsType int messageContentsType = MessageContentsType.UNDEFINED;
-		String typeAsString = row.getString(Tags.TAG_MESSAGE_TYPE);
-
-		if (typeAsString.equals(MessageType.VIDEO.name())) {
-			messageType = MessageType.VIDEO;
-			messageContentsType = MessageContentsType.VIDEO;
-		} else if (typeAsString.equals(MessageType.VOICEMESSAGE.name())) {
-			messageType = MessageType.VOICEMESSAGE;
-			messageContentsType = MessageContentsType.VOICE_MESSAGE;
-		} else if (typeAsString.equals(MessageType.LOCATION.name())) {
-			messageType = MessageType.LOCATION;
-			messageContentsType = MessageContentsType.LOCATION;
-		} else if (typeAsString.equals(MessageType.IMAGE.name())) {
-			messageType = MessageType.IMAGE;
-			messageContentsType = MessageContentsType.IMAGE;
-		} else if (typeAsString.equals(MessageType.CONTACT.name())) {
-			messageType = MessageType.CONTACT;
-			messageContentsType = MessageContentsType.CONTACT;
-		} else if (typeAsString.equals(MessageType.BALLOT.name())) {
-			messageType = MessageType.BALLOT;
-			messageContentsType = MessageContentsType.BALLOT;
-		} else if (typeAsString.equals(MessageType.FILE.name())) {
-			messageType = MessageType.FILE;
-			// get mime type from body
-			String body = row.getString(Tags.TAG_MESSAGE_BODY);
-			if (!TestUtil.isEmptyOrNull(body)) {
-				FileDataModel fileDataModel = FileDataModel.create(body);
-				messageContentsType = MimeUtil.getContentTypeFromFileData(fileDataModel);
-			} else {
-				messageContentsType = MessageContentsType.FILE;
-			}
-		} else if (typeAsString.equals(MessageType.VOIP_STATUS.name())) {
-			messageType = MessageType.VOIP_STATUS;
-			messageContentsType = MessageContentsType.VOIP_STATUS;
-		} else if (typeAsString.equals(MessageType.GROUP_CALL_STATUS.name())) {
-			messageType = MessageType.GROUP_CALL_STATUS;
-			messageContentsType = MessageContentsType.GROUP_CALL_STATUS;
-		} else if (typeAsString.equals(MessageType.GROUP_STATUS.name())) {
-			messageType = MessageType.GROUP_STATUS;
-			messageContentsType = MessageContentsType.GROUP_STATUS;
-		}
-		messageModel.setType(messageType);
-		messageModel.setMessageContentsType(messageContentsType);
-		messageModel.setBody(row.getString(Tags.TAG_MESSAGE_BODY));
-
-		if(messageModel.getType() == MessageType.BALLOT) {
-			//try to update to new ballot id
-			BallotDataModel ballotData = messageModel.getBallotData();
-			if(this.ballotOldIdMap.containsKey(ballotData.getBallotId())) {
-				BallotDataModel newBallotData = new BallotDataModel(ballotData.getType(), this.ballotOldIdMap.get(ballotData.getBallotId()));
-				messageModel.setBallotData(newBallotData);
-			}
-		}
-		if(restoreSettings.getVersion() >= 2) {
-			messageModel.setIsStatusMessage(row.getBoolean(Tags.TAG_MESSAGE_IS_STATUS_MESSAGE));
-		}
-
-		if(restoreSettings.getVersion() >= 10) {
-			messageModel.setCaption(row.getString(Tags.TAG_MESSAGE_CAPTION));
-		}
-
-		if(restoreSettings.getVersion() >= 15) {
-			String quotedMessageId = row.getString(Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID);
-			if (!TestUtil.isEmptyOrNull(quotedMessageId)) {
-				messageModel.setQuotedMessageId(quotedMessageId);
-			}
-		}
-
-		if(restoreSettings.getVersion() >= 20) {
-			if (!(messageModel instanceof DistributionListMessageModel)) {
-				Integer displayTags = row.getInteger(Tags.TAG_MESSAGE_DISPLAY_TAGS);
-				if (displayTags != null) {
-					messageModel.setDisplayTags(displayTags);
-				}
-			}
-		}
-	}
-	private MessageModel createMessageModel(CSVRow row, RestoreSettings restoreSettings) throws ThreemaException {
-		MessageModel messageModel = new MessageModel();
-		this.fillMessageModel(messageModel, row, restoreSettings);
-
-		messageModel.setPostedAt(row.getDate(Tags.TAG_MESSAGE_POSTED_AT));
-		messageModel.setCreatedAt(row.getDate(Tags.TAG_MESSAGE_CREATED_AT));
-		if(restoreSettings.getVersion() >= 5) {
-			messageModel.setModifiedAt(row.getDate(Tags.TAG_MESSAGE_MODIFIED_AT));
-		}
-		messageModel.setUid(row.getString(Tags.TAG_MESSAGE_UID));
-
-		if (restoreSettings.getVersion() >= 16) {
-			messageModel.setDeliveredAt(row.getDate(Tags.TAG_MESSAGE_DELIVERED_AT));
-			messageModel.setReadAt(row.getDate(Tags.TAG_MESSAGE_READ_AT));
-		}
-
-		if (restoreSettings.getVersion() >= 23) {
-			messageModel.setEditedAt(row.getDate(Tags.TAG_MESSAGE_EDITED_AT));
-		}
-
-		if (restoreSettings.getVersion() >= 24) {
-			messageModel.setDeletedAt(row.getDate(Tags.TAG_MESSAGE_DELETED_AT));
-		}
-
-		return messageModel;
-	}
-
-	private GroupMessageModel createGroupMessageModel(CSVRow row, RestoreSettings restoreSettings) throws ThreemaException {
-		GroupMessageModel messageModel = new GroupMessageModel();
-		this.fillMessageModel(messageModel, row, restoreSettings);
-
-		messageModel.setIdentity(row.getString(Tags.TAG_MESSAGE_IDENTITY));
-		messageModel.setPostedAt(row.getDate(Tags.TAG_MESSAGE_POSTED_AT));
-		messageModel.setCreatedAt(row.getDate(Tags.TAG_MESSAGE_CREATED_AT));
-		if(restoreSettings.getVersion() >= 5) {
-			messageModel.setModifiedAt(row.getDate(Tags.TAG_MESSAGE_MODIFIED_AT));
-		}
-		messageModel.setUid(row.getString(Tags.TAG_MESSAGE_UID));
-		if (restoreSettings.getVersion() >= 16) {
-			messageModel.setDeliveredAt(row.getDate(Tags.TAG_MESSAGE_DELIVERED_AT));
-			messageModel.setReadAt(row.getDate(Tags.TAG_MESSAGE_READ_AT));
-		}
-		if (restoreSettings.getVersion() >= 17) {
-			String messageStatesJson = row.getString(Tags.TAG_GROUP_MESSAGE_STATES);
-			if (!TestUtil.isEmptyOrNull(messageStatesJson)) {
-				try {
-					Map<String, Object> messageStatesMap = JsonUtil.convertObject(messageStatesJson);
-					messageModel.setGroupMessageStates(messageStatesMap);
-				} catch (JSONException ignored) {
-					// map may not be available, empty or invalid
-				}
-			}
-		}
-		if (restoreSettings.getVersion() >= 23) {
-			messageModel.setEditedAt(row.getDate(Tags.TAG_MESSAGE_EDITED_AT));
-		}
-		if (restoreSettings.getVersion() >= 24) {
-			messageModel.setDeletedAt(row.getDate(Tags.TAG_MESSAGE_DELETED_AT));
-		}
-		return messageModel;
-	}
-
-	private DistributionListMessageModel createDistributionListMessageModel(CSVRow row, RestoreSettings restoreSettings) throws ThreemaException {
-		DistributionListMessageModel messageModel = new DistributionListMessageModel();
-		this.fillMessageModel(messageModel, row, restoreSettings);
-
-		messageModel.setIdentity(row.getString(Tags.TAG_MESSAGE_IDENTITY));
-		messageModel.setPostedAt(row.getDate(Tags.TAG_MESSAGE_POSTED_AT));
-		messageModel.setCreatedAt(row.getDate(Tags.TAG_MESSAGE_CREATED_AT));
-		if(restoreSettings.getVersion() >= 5) {
-			messageModel.setModifiedAt(row.getDate(Tags.TAG_MESSAGE_MODIFIED_AT));
-		}
-		messageModel.setUid(row.getString(Tags.TAG_MESSAGE_UID));
-		if (restoreSettings.getVersion() >= 16) {
-			messageModel.setDeliveredAt(row.getDate(Tags.TAG_MESSAGE_DELIVERED_AT));
-			messageModel.setReadAt(row.getDate(Tags.TAG_MESSAGE_READ_AT));
-		}
-		return messageModel;
-	}
-
-	private boolean processCsvFile(
-		@NonNull FileHeader fileHeader,
-		@NonNull ProcessCsvFile processCsvFile
-	) throws IOException, RestoreCanceledException {
-		try (ZipInputStream inputStream = this.zipFile.getInputStream(fileHeader);
-		     InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
-		     CSVReader csvReader = new CSVReader(inputStreamReader, true)) {
-			CSVRow row;
-			while ((row = csvReader.readNextRow()) != null) {
-				processCsvFile.row(row);
-			}
-		}
-		return true;
-	}
-
-	private void initProgress(long steps) {
-		this.currentProgressStep = 0;
-		this.progressSteps = steps;
-		this.latestPercentStep = 0;
-		this.startTime = System.currentTimeMillis();
-
-		this.handleProgress();
-	}
-
-	private void updateProgress(long increment) throws RestoreCanceledException {
-		if (isCanceled) {
-			throw new RestoreCanceledException();
-		}
-
-		if (writeToDb) {
-			this.currentProgressStep += increment;
-			handleProgress();
-		}
-	}
-
-	/**
-	 * only call progress on 100 steps
-	 */
-	private void handleProgress() {
-		int p = (int) (100d / (double) this.progressSteps * (double) this.currentProgressStep);
-		if (p > this.latestPercentStep) {
-			this.latestPercentStep = p;
-			String remainingTimeText = getRemainingTimeText(latestPercentStep, 100);
-			updatePersistentNotification(latestPercentStep, 100, false, remainingTimeText);
-			LocalBroadcastManager.getInstance(this)
-				.sendBroadcast(new Intent(RESTORE_PROGRESS_INTENT)
-					.putExtra(RESTORE_PROGRESS, latestPercentStep)
-					.putExtra(RESTORE_PROGRESS_STEPS, 100)
-					.putExtra(RESTORE_PROGRESS_MESSAGE, remainingTimeText)
-				);
-		}
-	}
-
-	public void onFinished(String message) {
-		logger.info("onFinished success = {}", restoreSuccess);
-
-		cancelPersistentNotification();
-
-		if (restoreSuccess && userService.hasIdentity()) {
-			notificationPreferenceService.setWizardRunning(true);
-
-			showRestoreSuccessNotification();
-
-			//try to reopen connection
-			try {
-				if (!serviceManager.getConnection().isRunning()) {
-					serviceManager.startConnection();
-				}
-			} catch (Exception e) {
-				logger.error("Exception", e);
-			}
-
-			if (wakeLock != null && wakeLock.isHeld()) {
-				logger.debug("releasing wakelock");
-				wakeLock.release();
-			}
-
-			stopForeground(true);
-
-			isRunning = false;
-
-			// Send broadcast after isRunning has been set to false to indicate that there is no
-			// backup being restored anymore
-			LocalBroadcastManager.getInstance(this)
-				.sendBroadcast(new Intent(RESTORE_PROGRESS_INTENT)
-					.putExtra(RESTORE_PROGRESS, 100)
-					.putExtra(RESTORE_PROGRESS_STEPS, 100)
-				);
-
-			if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
-				ConfigUtils.scheduleAppRestart(getApplicationContext(), 2 * (int) DateUtils.SECOND_IN_MILLIS, getApplicationContext().getResources().getString(R.string.ipv6_restart_now));
-			}
-			stopSelf();
-		} else {
-			showRestoreErrorNotification(message);
-
-			// Send broadcast so that the BackupRestoreProgressActivity can display the message
-			LocalBroadcastManager.getInstance(this).sendBroadcast(
-				new Intent(RESTORE_PROGRESS_INTENT).putExtra(RESTORE_PROGRESS_ERROR_MESSAGE, message)
-			);
-
-			new DeleteIdentityAsyncTask(null, () -> {
-				isRunning = false;
-
-				System.exit(0);
-			}).execute();
-		}
-	}
-
-	private Notification getPersistentNotification() {
-		logger.debug("getPersistentNotification");
-
-		Intent cancelIntent = new Intent(this, RestoreService.class);
-		cancelIntent.putExtra(EXTRA_ID_CANCEL, true);
-		PendingIntent cancelPendingIntent;
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
-			cancelPendingIntent = PendingIntent.getForegroundService(this, (int) System.currentTimeMillis(), cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
-		} else {
-			cancelPendingIntent = PendingIntent.getService(this, (int) System.currentTimeMillis(), cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
-		}
-
-		notificationBuilder = new NotificationCompat.Builder(this, NotificationChannels.NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS)
-			.setContentTitle(getString(R.string.restoring_backup))
-			.setContentText(getString(R.string.please_wait))
-			.setOngoing(true)
-			.setSmallIcon(R.drawable.ic_notification_small)
-			.setPriority(NotificationCompat.PRIORITY_DEFAULT)
-			.addAction(R.drawable.ic_close_white_24dp, getString(R.string.cancel), cancelPendingIntent);
-
-		return notificationBuilder.build();
-	}
-
-	@SuppressLint("MissingPermission")
-	private void updatePersistentNotification(int currentStep, int steps, boolean indeterminate, @Nullable final String remainingTimeText) {
-		logger.debug("updatePersistentNotification {} of {}", currentStep, steps);
-
-		if (remainingTimeText != null) {
-			notificationBuilder.setContentText(remainingTimeText);
-		}
-
-		notificationBuilder.setProgress(steps, currentStep, indeterminate);
-		notificationManagerCompat.notify(RESTORE_NOTIFICATION_ID, notificationBuilder.build());
-	}
-
-	private String getRemainingTimeText(int currentStep, int steps) {
-		final long millisPassed = System.currentTimeMillis() - startTime;
-		final long millisRemaining = millisPassed * steps / currentStep - millisPassed;
-		String timeRemaining = StringConversionUtil.secondsToString(millisRemaining / DateUtils.SECOND_IN_MILLIS, false);
-		return String.format(getString(R.string.time_remaining), timeRemaining);
-	}
-
-
-	private void cancelPersistentNotification() {
-		notificationManagerCompat.cancel(RESTORE_NOTIFICATION_ID);
-	}
-
-	@SuppressLint("MissingPermission")
-	private void showRestoreErrorNotification(String message) {
-		String contentText;
-
-		if (!TestUtil.isEmptyOrNull(message)) {
-			contentText = message;
-		} else {
-			contentText = getString(R.string.restore_error_body);
-		}
-
-		NotificationCompat.Builder builder =
-			new NotificationCompat.Builder(this, NotificationChannels.NOTIFICATION_CHANNEL_ALERT)
-				.setSmallIcon(R.drawable.ic_notification_small)
-				.setTicker(getString(R.string.restore_error_body))
-				.setContentTitle(getString(R.string.restoring_backup))
-				.setContentText(contentText)
-				.setDefaults(Notification.DEFAULT_LIGHTS|Notification.DEFAULT_SOUND|Notification.DEFAULT_VIBRATE)
-				.setPriority(NotificationCompat.PRIORITY_MAX)
-				.setStyle(new NotificationCompat.BigTextStyle().bigText(contentText))
-				.setAutoCancel(false);
-
-		notificationManagerCompat.notify(RESTORE_COMPLETION_NOTIFICATION_ID, builder.build());
-	}
-
-
-	@SuppressLint("MissingPermission")
-	private void showRestoreSuccessNotification() {
-		String text;
-
-		NotificationCompat.Builder builder =
-			new NotificationCompat.Builder(this, NotificationChannels.NOTIFICATION_CHANNEL_ALERT)
-				.setSmallIcon(R.drawable.ic_notification_small)
-				.setTicker(getString(R.string.restore_success_body))
-				.setContentTitle(getString(R.string.restoring_backup))
-				.setDefaults(Notification.DEFAULT_LIGHTS|Notification.DEFAULT_SOUND|Notification.DEFAULT_VIBRATE)
-				.setPriority(NotificationCompat.PRIORITY_MAX)
-				.setAutoCancel(true);
-
-		if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
-			// Android Q does not allow restart in the background
-			Intent backupIntent = new Intent(this, HomeActivity.class);
-			PendingIntent pendingIntent = PendingIntent.getActivity(this, (int)System.currentTimeMillis(), backupIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
-
-			builder.setContentIntent(pendingIntent);
-
-			text = getString(R.string.restore_success_body) + "\n" + getString(R.string.tap_to_start, getString(R.string.app_name));
-		} else {
-			text = getString(R.string.restore_success_body);
-		}
-
-		builder.setContentText(text);
-		builder.setStyle(new NotificationCompat.BigTextStyle().bigText(text));
-
-		notificationManagerCompat.notify(RESTORE_COMPLETION_NOTIFICATION_ID, builder.build());
-	}
+            nonceFactory = serviceManager.getNonceFactory();
+        } catch (Exception e) {
+            logger.error("Could not instantiate all required services", e);
+            stopSelf();
+            return;
+        }
+
+        notificationManagerCompat = NotificationManagerCompat.from(this);
+    }
+
+    @Override
+    public void onDestroy() {
+        logger.info("onDestroy success = {} cancelled = {}", restoreSuccess, isCanceled);
+
+        if (isCanceled) {
+            onFinished(getString(R.string.restore_data_cancelled));
+        }
+
+        super.onDestroy();
+    }
+
+    @Override
+    public void onLowMemory() {
+        logger.info("onLowMemory");
+        super.onLowMemory();
+    }
+
+    @Override
+    public void onTaskRemoved(Intent rootIntent) {
+        logger.info("onTaskRemoved");
+
+        Intent intent = new Intent(this, DummyActivity.class);
+        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        startActivity(intent);
+    }
+
+    /**
+     * CSV file processor
+     * <p>
+     * The {@link #row(CSVRow)} method will be called for every row in the CSV file.
+     */
+    private interface ProcessCsvFile {
+        void row(@NonNull CSVRow row) throws RestoreCanceledException;
+    }
+
+    private interface GetMessageModel {
+        AbstractMessageModel get(String uid);
+    }
+
+    private RestoreSettings restoreSettings;
+    private final HashMap<String, Integer> ballotIdMap = new HashMap<>();
+    private final HashMap<Integer, Integer> ballotOldIdMap = new HashMap<>();
+    private final HashMap<String, Integer> ballotChoiceIdMap = new HashMap<>();
+    private final HashMap<String, Long> distributionListIdMap = new HashMap<>();
+
+    private boolean writeToDb = false;
+
+    public boolean restore() {
+        logger.info("Restoring data backup");
+
+        String message;
+
+        if (BuildConfig.DEBUG) {
+            // zipFile.getInputStream() currently causes "Explicit termination method 'end' not called" exception
+            StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
+                .detectAll()
+                .penaltyLog()
+                .build());
+        }
+
+        try {
+            // Ensure that the server connection is stopped before restoring the backup.
+            //
+            // This is important, because during the backup restore process, some outgoing
+            // messages (e.g. group sync messages) might be enqueued. However, we only want to
+            // send those messages if the backup restore succeeded.
+            //
+            // The connection will be resumed in {@link onFinished}.
+            final ServerConnection connection = serviceManager.getConnection();
+            if (connection.isRunning()) {
+                connection.stop();
+            }
+
+            // We use two passes for a restore. The first pass only scans the files in the backup,
+            // but does not write to the database. In the second pass, the files are actually written.
+            for (int nTry = 0; nTry < 2; nTry++) {
+                logger.info("Attempt {}", nTry + 1);
+                if (nTry > 0) {
+                    this.writeToDb = true;
+                    this.initProgress(stepSizeTotal);
+                }
+
+                this.identityIdMap.clear();
+                this.groupUidMap.clear();
+                this.ballotIdMap.clear();
+                this.ballotOldIdMap.clear();
+                this.ballotChoiceIdMap.clear();
+                this.distributionListIdMap.clear();
+
+                if (this.writeToDb) {
+                    updateProgress(STEP_SIZE_PREPARE);
+
+                    // clear tables!!
+                    logger.info("Clearing current tables");
+                    databaseServiceNew.getMessageModelFactory().deleteAll();
+                    databaseServiceNew.getContactModelFactory().deleteAll();
+                    databaseServiceNew.getGroupMessageModelFactory().deleteAll();
+                    databaseServiceNew.getGroupMemberModelFactory().deleteAll();
+                    databaseServiceNew.getGroupModelFactory().deleteAll();
+                    databaseServiceNew.getDistributionListMessageModelFactory().deleteAll();
+                    databaseServiceNew.getDistributionListMemberModelFactory().deleteAll();
+                    databaseServiceNew.getDistributionListModelFactory().deleteAll();
+                    databaseServiceNew.getBallotModelFactory().deleteAll();
+                    databaseServiceNew.getBallotVoteModelFactory().deleteAll();
+                    databaseServiceNew.getBallotChoiceModelFactory().deleteAll();
+                    databaseServiceNew.getOutgoingGroupSyncRequestLogModelFactory().deleteAll();
+                    databaseServiceNew.getIncomingGroupSyncRequestLogModelFactory().deleteAll();
+
+                    modelRepositories.getEmojiReaction().deleteAllReactions();
+                    // TODO(ANDR-3207): delete all edit history entries
+
+                    // Remove all media files (don't remove recursively, tmp folder contain the restoring files
+                    logger.info("Deleting current media files");
+                    fileService.clearDirectory(fileService.getAppDataPath(), false);
+                }
+
+                List<FileHeader> fileHeaders = zipFile.getFileHeaders();
+
+                // The restore settings file contains the data backup format version
+                this.restoreSettings = getRestoreSettings(fileHeaders);
+
+                if (restoreSettings.isUnsupportedVersion()) {
+                    logger.error(
+                        "Backup version {} is higher than supported version {}",
+                        restoreSettings.getVersion(),
+                        RestoreSettings.CURRENT_VERSION
+                    );
+                    throw new ThreemaException(getString(R.string.backup_version_mismatch));
+                }
+
+                // Restore the identity
+                logger.info("Restoring identity");
+                FileHeader identityHeader = Functional.select(
+                    fileHeaders,
+                    type -> TestUtil.compare(type.getFileName(), Tags.IDENTITY_FILE_NAME)
+                );
+                if (identityHeader != null && this.writeToDb) {
+                    String identityContent;
+                    try (InputStream inputStream = zipFile.getInputStream(identityHeader)) {
+                        identityContent = IOUtils.toString(inputStream);
+                    }
+
+                    try {
+                        if (!userService.restoreIdentity(identityContent, this.password)) {
+                            throw new ThreemaException(getString(R.string.unable_to_restore_identity_because, "n/a"));
+                        }
+                        // If the backup is older than version 19, the contact avatar file has the
+                        // id as suffix and is not "me". Therefore we need to include the identity
+                        // in the id map, so that restoring this id's avatar file works.
+                        if (restoreSettings.getVersion() < 19) {
+                            identityIdMap.put(userService.getIdentity(), userService.getIdentity());
+                        }
+                    } catch (UnknownHostException e) {
+                        throw e;
+                    } catch (Exception e) {
+                        throw new ThreemaException(getString(R.string.unable_to_restore_identity_because, e.getMessage()));
+                    }
+
+                    updateProgress(STEP_SIZE_IDENTITY);
+                }
+
+                // Restore nonces
+                logger.info("Restoring nonces");
+                int nonceCount = restoreNonces(fileHeaders);
+
+                // contacts, groups and distribution lists
+                logger.info("Restoring main files (contacts, groups, distribution lists)");
+                if(!this.restoreMainFiles(fileHeaders)) {
+                    logger.error("restore main files failed");
+                    // continue anyway!
+                }
+
+                updateProgress(STEP_SIZE_MAIN_FILES);
+
+                logger.info("Restoring message files");
+                long messageCount = this.restoreMessageFiles(fileHeaders);
+                if(messageCount == 0) {
+                    logger.error("restore message files failed");
+                    // continue anyway!
+                }
+
+                logger.info("Restoring group avatar files");
+                if(!this.restoreGroupAvatarFiles(fileHeaders)) {
+                    logger.error("restore group avatar files failed");
+                    // continue anyway!
+                }
+
+                updateProgress(STEP_SIZE_GROUP_AVATARS);
+
+                logger.info("Restoring message media files");
+                long mediaCount = this.restoreMessageMediaFiles(fileHeaders);
+                if (mediaCount == 0) {
+                    logger.warn("No media files restored. Might be a backup without media?");
+                    // continue anyway!
+                } else {
+                    logger.info("{} media files found", mediaCount);
+                }
+
+                // restore all avatars
+                logger.info("Restoring avatars");
+                if(!this.restoreContactAvatars(fileHeaders)) {
+                    logger.error("restore contact avatar files failed");
+                    // continue anyway!
+                }
+
+                // Reset the profile pic upload so that the own profile picture is redistributed
+                preferenceService.setProfilePicUploadDate(new Date(0));
+                preferenceService.setProfilePicUploadData(null);
+
+                // If we're restoring a backup that does not yet contain lastUpdate (version <22),
+                // calculate lastUpdate ourselves based on restored data.
+                if (restoreSettings.getVersion() < 22) {
+                    this.conversationService.calculateLastUpdateForAllConversations();
+                }
+
+                long stepsRestoreReactions = this.restoreReactions(fileHeaders);
+
+                if (!writeToDb) {
+                    stepSizeTotal += (messageCount * STEP_SIZE_MESSAGES);
+                    stepSizeTotal += (mediaCount * STEP_SIZE_MEDIA);
+                    stepSizeTotal += (long) Math.ceil((double) nonceCount / NONCES_PER_STEP);
+                    stepSizeTotal += stepsRestoreReactions;
+                }
+            }
+
+            logger.info("Restore successful!");
+            restoreSuccess = true;
+            onFinished(null);
+
+            return true;
+        } catch (InterruptedException e) {
+            logger.error("Interrupted while restoring identity", e);
+            Thread.currentThread().interrupt();
+            message = "Interrupted while restoring identity";
+        } catch (RestoreCanceledException e) {
+            logger.error("Restore cancelled", e);
+            message = getString(R.string.restore_data_cancelled);
+        } catch(IOException e) {
+            logger.error("Exception while restoring backup", e);
+            message = getString(R.string.invalid_zip_restore_failed, e.getMessage());
+        } catch (Exception e) {
+            // wrong password? no connection? throw
+            logger.error("Exception while restoring backup", e);
+            message = e.getMessage();
+        }
+
+        onFinished(message);
+
+        return false;
+    }
+
+    @NonNull
+    private RestoreSettings getRestoreSettings(List<FileHeader> fileHeaders) throws ThreemaException, IOException {
+        FileHeader settingsHeader = Functional.select(fileHeaders, type -> TestUtil.compare(type.getFileName(), Tags.SETTINGS_FILE_NAME));
+        if (settingsHeader == null) {
+            logger.error("Settings file header is missing");
+            throw new ThreemaException(getString(R.string.invalid_backup));
+        }
+        try (
+            InputStream is = zipFile.getInputStream(settingsHeader);
+            InputStreamReader inputStreamReader = new InputStreamReader(is);
+            CSVReader csvReader = new CSVReader(inputStreamReader, false)
+        ) {
+            RestoreSettings settings = new RestoreSettings();
+            settings.parse(csvReader.readAll());
+            return settings;
+        }
+    }
+
+    /**
+     * restore the main files (contacts, groups, distribution lists)
+     */
+    private boolean restoreMainFiles(List<FileHeader> fileHeaders) throws IOException, RestoreCanceledException {
+        FileHeader ballotMain = null;
+        FileHeader ballotChoice = null;
+        FileHeader ballotVote = null;
+        for (FileHeader fileHeader : fileHeaders) {
+            String fileName = fileHeader.getFileName();
+
+            if (fileName.endsWith(Tags.CSV_FILE_POSTFIX)) {
+                final String fileNameWithoutExtension = fileName.substring(0, fileName.length() - Tags.CSV_FILE_POSTFIX.length());
+                switch (fileNameWithoutExtension) {
+                    case Tags.CONTACTS_FILE_NAME:
+                        if (!this.restoreContactFile(fileHeader)) {
+                            logger.error("restore contact file failed");
+                            return false;
+                        }
+                        break;
+                    case Tags.GROUPS_FILE_NAME:
+                        if (!this.restoreGroupFile(fileHeader)) {
+                            logger.error("restore group file failed");
+                        }
+                        break;
+                    case Tags.DISTRIBUTION_LISTS_FILE_NAME:
+                        if(!this.restoreDistributionListFile(fileHeader)) {
+                            logger.error("restore distribution list file failed");
+                        }
+                        break;
+                    case Tags.BALLOT_FILE_NAME:
+                        ballotMain = fileHeader;
+                        break;
+                    case Tags.BALLOT_CHOICE_FILE_NAME:
+                        ballotChoice = fileHeader;
+                        break;
+                    case Tags.BALLOT_VOTE_FILE_NAME:
+                        ballotVote = fileHeader;
+                        break;
+                }
+            }
+        }
+
+        if (TestUtil.required(ballotMain, ballotChoice, ballotVote)) {
+            this.restoreBallotFile(ballotMain, ballotChoice, ballotVote);
+        }
+
+        return true;
+    }
+
+    /**
+     * Attempt to restore the nonces. If restoring of nonces fails for some reason 0 is returned.
+     * Since we continue anyway, there is no need to distinguish between zero restored nonces and
+     * a failure.
+     */
+    private int restoreNonces(List<FileHeader> fileHeaders) throws IOException, RestoreCanceledException {
+        if (!writeToDb) {
+            // If not writing to the database only the count of nonces is required.
+            // Try to read optional nonces count file if present in backup.
+            logger.info("Get nonce counts");
+            int nonceCount = readNonceCounts(fileHeaders);
+            if (nonceCount >= 0) {
+                // If the nonce count is available return it and skip reading the whole nonces file.
+                logger.info("{} nonces in backup", nonceCount);
+                return nonceCount;
+            } else {
+                logger.info("Count nonces in backup.");
+            }
+        }
+
+        int nonceCountCsp = restoreNonces(
+            NonceScope.CSP,
+            Tags.NONCE_FILE_NAME_CSP + Tags.CSV_FILE_POSTFIX,
+            fileHeaders
+        );
+
+        int nonceCountD2d = restoreNonces(
+            NonceScope.D2D,
+            Tags.NONCE_FILE_NAME_D2D + Tags.CSV_FILE_POSTFIX,
+            fileHeaders
+        );
+
+        int remainingCsp = BackupUtils.calcRemainingNoncesProgress(NONCES_CHUNK_SIZE, NONCES_PER_STEP, nonceCountCsp);
+        int remainingD2d = BackupUtils.calcRemainingNoncesProgress(NONCES_CHUNK_SIZE, NONCES_PER_STEP, nonceCountD2d);
+        int remainingNonceProgress = remainingCsp + remainingD2d;
+        logger.debug("Remaining nonce progress: {}", remainingNonceProgress);
+        updateProgress((long) Math.ceil((double) remainingNonceProgress / NONCES_PER_STEP));
+
+        return nonceCountCsp + nonceCountD2d;
+    }
+
+    /**
+     * Read the counts from the nonce counts file if available.
+     *
+     * @return the count, or -1 if the count could not be read from some reason.
+     */
+    private int readNonceCounts(List<FileHeader> fileHeaders) throws IOException {
+        FileHeader nonceCountFileHeader = getFileHeader(Tags.NONCE_COUNTS_FILE + Tags.CSV_FILE_POSTFIX, fileHeaders);
+        if (nonceCountFileHeader == null) {
+            logger.info("No nonce count file available in backup");
+            return -1;
+        }
+        try (ZipInputStream inputStream = this.zipFile.getInputStream(nonceCountFileHeader);
+             InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
+             CSVReader csvReader = new CSVReader(inputStreamReader, true)
+        ) {
+            CSVRow row = csvReader.readNextRow();
+            if (row == null) {
+                logger.warn("Could not read nonce count. File is empty.");
+                return -1;
+            }
+            return row.getInteger(Tags.TAG_NONCE_COUNT_CSP) + row.getInteger(Tags.TAG_NONCE_COUNT_D2D);
+        } catch (ThreemaException | NumberFormatException e) {
+            logger.warn("Could not read nonce count", e);
+            return -1;
+        }
+    }
+
+    /**
+     * Get the file header where the file name matches the provided exactFileName.
+     *
+     * @param exactFileName The file name that is matched against
+     * @param fileHeaders The file headers that are scanned
+     * @return The first matching file header or null if none matches
+     */
+    @Nullable
+    private FileHeader getFileHeader(@NonNull String exactFileName, List<FileHeader> fileHeaders) {
+        for (FileHeader fileHeader : fileHeaders) {
+            if (exactFileName.equals(fileHeader.getFileName())) {
+                return fileHeader;
+            }
+        }
+        logger.info("No file header for '{}' found", exactFileName);
+        return null;
+    }
+
+    private int restoreNonces(
+        @NonNull NonceScope scope,
+        @NonNull String nonceBackupFile,
+        @NonNull List<FileHeader> fileHeaders
+    ) throws IOException, RestoreCanceledException {
+        logger.info("Restore {} nonces", scope);
+        final FileHeader nonceFileHeader = getFileHeader(nonceBackupFile, fileHeaders);
+        if (nonceFileHeader == null) {
+            logger.info("Nonce file header is null");
+            return 0;
+        }
+
+        try (ZipInputStream inputStream = this.zipFile.getInputStream(nonceFileHeader);
+             InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
+             CSVReader csvReader = new CSVReader(inputStreamReader, true)
+        ) {
+            int nonceCount = 0;
+            boolean success = true;
+            CSVRow row;
+            List<byte[]> nonceBytes = new ArrayList<>(NONCES_CHUNK_SIZE);
+            while ((row = csvReader.readNextRow()) != null) {
+                try {
+                    // Note that currently there is only one nonce per row, and therefore we do
+                    // not need to read them as array. However, this gives us the flexibility to
+                    // backup several nonces in one row (as we have done in 5.1-alpha3)
+                    String[] nonces = row.getStrings(Tags.TAG_NONCES);
+                    nonceCount += nonces.length;
+                    if (writeToDb) {
+                        for (String nonce : nonces) {
+                            nonceBytes.add(Utils.hexStringToByteArray(nonce));
+                            if (nonceBytes.size() >= NONCES_CHUNK_SIZE) {
+                                success &= insertNonces(scope, nonceBytes);
+                                nonceBytes.clear();
+                            }
+                        }
+                    }
+                } catch (ThreemaException e) {
+                    logger.error("Could not insert nonces", e);
+                    return 0;
+                }
+            }
+            if (!nonceBytes.isEmpty()) {
+                success &= insertNonces(scope, nonceBytes);
+            }
+            if (success) {
+                logger.info("Restored {} {} nonces", nonceCount, scope);
+                return nonceCount;
+            } else {
+                logger.warn("Restoring {} nonces was not successfull", scope);
+                return 0;
+            }
+        }
+    }
+
+    private boolean insertNonces(
+        @NonNull NonceScope scope,
+        @NonNull List<byte[]> nonces
+    ) throws RestoreCanceledException {
+        logger.debug("Write {} nonces to database", nonces.size());
+        boolean success = nonceFactory.insertHashedNoncesJava(scope, nonces);
+        updateProgress(nonces.size() / NONCES_PER_STEP);
+        return success;
+    }
+
+    private long restoreReactions(@NonNull List<FileHeader> fileHeaders) throws Exception {
+        logger.info("Restore reactions");
+        FileHeader reactionCountFileHeader = getFileHeader(Tags.REACTION_COUNTS_FILE + Tags.CSV_FILE_POSTFIX, fileHeaders);
+        long restoreReactionsSteps = reactionCountFileHeader != null
+            ? getRestoreReactionsSteps(reactionCountFileHeader)
+            : 0;
+
+        if (writeToDb) {
+            FileHeader contactReactionsFileHeader = getFileHeader(Tags.CONTACT_REACTIONS_FILE_NAME + Tags.CSV_FILE_POSTFIX, fileHeaders);
+            if (contactReactionsFileHeader != null) {
+                restoreContactReactions(contactReactionsFileHeader);
+            }
+
+            FileHeader groupReactionsFileHeader = getFileHeader(Tags.GROUP_REACTIONS_FILE_NAME + Tags.CSV_FILE_POSTFIX, fileHeaders);
+            if (groupReactionsFileHeader != null) {
+                restoreGroupReactions(groupReactionsFileHeader);
+            }
+        }
+
+        return restoreReactionsSteps;
+    }
+
+    /**
+     * The reaction count is only read an logged.
+     * At the moment this is not used, but can later be used to improve the
+     * progress calculation of the restore process.
+     */
+    private long getRestoreReactionsSteps(@NonNull FileHeader reactionCountFileHeader) throws IOException {
+        try (ZipInputStream inputStream = this.zipFile.getInputStream(reactionCountFileHeader);
+             InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
+             CSVReader csvReader = new CSVReader(inputStreamReader, true)
+        ) {
+            CSVRow row = csvReader.readNextRow();
+            if (row == null) {
+                logger.warn("Could not read reaction counts. File is empty");
+                return 0;
+            }
+            long contactReactionCount = row.getLong(Tags.TAG_REACTION_COUNT_CONTACTS);
+            long groupReactionCount = row.getLong(Tags.TAG_REACTION_COUNT_GROUPS);
+            logger.info(
+                "Reactions: (contactReactionCount={}, groupReactionCount={})",
+                contactReactionCount,
+                groupReactionCount
+            );
+            return (contactReactionCount / REACTIONS_PER_STEP) + (groupReactionCount / REACTIONS_PER_STEP);
+        } catch (ThreemaException | NumberFormatException e) {
+            logger.warn("Could not read reaction count", e);
+            return 0;
+        }
+    }
+
+    private MessageIdCache<MessageIdCache.ContactMessageKey> createContactMessageIdCache() {
+        MessageModelFactory messageModelFactory = databaseServiceNew.getMessageModelFactory();
+        return new MessageIdCache<>(key ->
+            messageModelFactory
+                .getByApiMessageIdAndIdentity(key.getMessageId(), key.getContactIdentity())
+                .getId()
+        );
+    }
+
+    private MessageIdCache<MessageIdCache.GroupMessageKey> createGroupMessageIdCache() {
+        GroupModelFactory groupModelFactory = databaseServiceNew.getGroupModelFactory();
+        GroupMessageModelFactory groupMessageModelFactory = databaseServiceNew.getGroupMessageModelFactory();
+
+        return new MessageIdCache<>(key -> {
+            @Nullable GroupModel groupModel = groupModelFactory
+                .getByApiGroupIdAndCreator(key.getApiGroupId(), key.getGroupCreatorIdentity());
+            if (groupModel == null) {
+                throw new NoSuchElementException();
+            }
+            return groupMessageModelFactory.getByApiMessageIdAndGroupId(
+                key.getMessageId(),
+                groupModel.getId()
+            ).getId();
+        });
+    }
+
+    private void iterateRows(@NonNull FileHeader fileHeader, ThrowingConsumer<CSVRow> rowConsumer) throws Exception {
+        try (ZipInputStream inputStream = this.zipFile.getInputStream(fileHeader);
+             InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
+             CSVReader csvReader = new CSVReader(inputStreamReader, true)
+        ) {
+            CSVRow row;
+            while ((row = csvReader.readNextRow()) != null) {
+                rowConsumer.accept(row);
+            }
+        }
+    }
+
+    private void restoreContactReactions(@NonNull FileHeader contactReactionsFileHeader) throws Exception {
+        logger.info("Restore contact reactions");
+        final MessageIdCache<MessageIdCache.ContactMessageKey> messageIdCache = createContactMessageIdCache();
+        EmojiReactionsRepository reactionsRepository = modelRepositories.getEmojiReaction();
+        reactionsRepository.restoreContactReactions(insertHandle -> {
+            final Counter restoredReactionsCounter = new Counter(REACTIONS_PER_STEP);
+            iterateRows(contactReactionsFileHeader, row -> {
+                try {
+                    String contactIdentity = row.getString(Tags.TAG_REACTION_CONTACT_IDENTITY);
+                    String apiMessageId = row.getString(Tags.TAG_REACTION_API_MESSAGE_ID);
+                    String senderIdentity = row.getString(Tags.TAG_REACTION_SENDER_IDENTITY);
+                    String sequence = row.getString(Tags.TAG_REACTION_EMOJI_SEQUENCE);
+                    long reactedAt = row.getLong(Tags.TAG_REACTION_REACTED_AT);
+
+                    MessageIdCache.ContactMessageKey key = new MessageIdCache.ContactMessageKey(
+                        contactIdentity,
+                        apiMessageId
+                    );
+
+                    int id = messageIdCache.get(key);
+                    insertHandle.insert(new DbEmojiReaction(
+                        id,
+                        senderIdentity,
+                        sequence,
+                        new Date(reactedAt)
+                    ));
+                    restoredReactionsCounter.count();
+                    long steps = restoredReactionsCounter.getAndResetSteps(REACTIONS_STEP_THRESHOLD);
+                    if (steps > 0) {
+                        updateProgress(steps);
+                    }
+                } catch (NoSuchElementException exception) {
+                    logger.info("Could not get message id for reaction. Skip contact reaction.");
+                } catch (NumberFormatException exception) {
+                    logger.info("Could not read reacted at date from backup. Skip contact reaction.", exception);
+                }
+            });
+            updateProgress(restoredReactionsCounter.getSteps());
+            logger.info("Restored {} contact reactions", restoredReactionsCounter);
+        });
+    }
+
+    private void restoreGroupReactions(@NonNull FileHeader groupReactionsFileHeader) throws Exception {
+        logger.info("Restore group reactions");
+        final MessageIdCache<MessageIdCache.GroupMessageKey> messageIdCache = createGroupMessageIdCache();
+        EmojiReactionsRepository reactionsRepository = modelRepositories.getEmojiReaction();
+        reactionsRepository.restoreGroupReactions(insertHandle -> {
+            final Counter restoredReactionsCounter = new Counter(REACTIONS_PER_STEP);
+            iterateRows(groupReactionsFileHeader, row -> {
+                try {
+                    String apiGroupId = row.getString(Tags.TAG_REACTION_API_GROUP_ID);
+                    String groupCreatorIdentity = row.getString(Tags.TAG_REACTION_GROUP_CREATOR_IDENTITY);
+                    String apiMessageId = row.getString(Tags.TAG_REACTION_API_MESSAGE_ID);
+                    String senderIdentity = row.getString(Tags.TAG_REACTION_SENDER_IDENTITY);
+                    String sequence = row.getString(Tags.TAG_REACTION_EMOJI_SEQUENCE);
+                    long reactedAt = row.getLong(Tags.TAG_REACTION_REACTED_AT);
+
+                    MessageIdCache.GroupMessageKey key = new MessageIdCache.GroupMessageKey(
+                        apiGroupId,
+                        groupCreatorIdentity,
+                        apiMessageId
+                    );
+
+                    int id = messageIdCache.get(key);
+                    insertHandle.insert(new DbEmojiReaction(
+                        id,
+                        senderIdentity,
+                        sequence,
+                        new Date(reactedAt)
+                    ));
+                    restoredReactionsCounter.count();
+                    long steps = restoredReactionsCounter.getAndResetSteps(REACTIONS_STEP_THRESHOLD);
+                    if (steps > 0) {
+                        updateProgress(steps);
+                    }
+                } catch (NoSuchElementException exception) {
+                    logger.info("Could not get message id for reaction. Skip group reaction");
+                } catch (NumberFormatException exception) {
+                    logger.info("Could not read reacted at date from backup. Skip group reaction", exception);
+                }
+            });
+            updateProgress(restoredReactionsCounter.getSteps());
+            logger.info("Restored {} group reactions", restoredReactionsCounter);
+        });
+    }
+
+    /**
+     * restore all avatars and profile pics
+     */
+    private boolean restoreContactAvatars(List<FileHeader> fileHeaders) {
+        for (FileHeader fileHeader : fileHeaders) {
+            String fileName = fileHeader.getFileName();
+            if (fileName.startsWith(Tags.CONTACT_AVATAR_FILE_PREFIX)) {
+                if(!this.restoreContactAvatarFile(fileHeader)) {
+                    logger.error("restore contact avatar {} file failed or skipped", fileName);
+                    // continue anyway
+                }
+            }
+            else if (fileName.startsWith(Tags.CONTACT_PROFILE_PIC_FILE_PREFIX)) {
+                if(!this.restoreContactPhotoFile(fileHeader)) {
+                    logger.error("restore contact profile pic {} file failed or skipped", fileName);
+                    // continue anyway
+                }
+            }
+        }
+        return true;
+    }
+    /**
+     * restore all message files
+     */
+    private long restoreMessageFiles(List<FileHeader> fileHeaders) throws IOException, RestoreCanceledException {
+        long count = 0;
+        for (FileHeader fileHeader : fileHeaders) {
+
+            String fileName = fileHeader.getFileName();
+
+            if (!fileName.endsWith(Tags.CSV_FILE_POSTFIX)) {
+                continue;
+            }
+
+            if (fileName.startsWith(Tags.MESSAGE_FILE_PREFIX)) {
+                try {
+                    count += this.restoreContactMessageFile(fileHeader);
+                } catch (ThreemaException e) {
+                    logger.error("restore contact message file failed");
+                    return 0;
+                }
+            }
+            else if (fileName.startsWith(Tags.GROUP_MESSAGE_FILE_PREFIX)) {
+                try {
+                    count += this.restoreGroupMessageFile(fileHeader);
+                } catch (ThreemaException e) {
+                    logger.error("restore group message file failed");
+                    return 0;
+                }
+            }
+            else if (fileName.startsWith(Tags.DISTRIBUTION_LIST_MESSAGE_FILE_PREFIX)) {
+                try {
+                    count += this.restoreDistributionListMessageFile(fileHeader);
+                } catch (ThreemaException e) {
+                    logger.error("restore distributionList message file failed");
+                    return 0;
+                }
+            }
+        }
+        return count;
+    }
+
+    /**
+     * restore all group avatars!
+     */
+    private boolean restoreGroupAvatarFiles(List<FileHeader> fileHeaders) {
+        boolean success = true;
+        for(FileHeader fileHeader: fileHeaders) {
+            String fileName = fileHeader.getFileName();
+
+            if (!fileName.startsWith(Tags.GROUP_AVATAR_PREFIX)) {
+                continue;
+            }
+
+            final String groupUid = fileName.substring(Tags.GROUP_AVATAR_PREFIX.length());
+            if (!TestUtil.isEmptyOrNull(groupUid)) {
+                Integer groupId = groupUidMap.get(groupUid);
+                if (groupId != null) {
+                    GroupModel m = databaseServiceNew.getGroupModelFactory().getById(groupId);
+                    if (m != null) {
+                        try (InputStream inputStream = zipFile.getInputStream(fileHeader)) {
+                            this.fileService.writeGroupAvatar(m, IOUtils.toByteArray(inputStream));
+                        } catch (Exception e) {
+                            // ignore, just the avatar :)
+                            success = false;
+                        }
+                        //
+                    }
+                }
+            }
+        }
+
+        return success;
+    }
+
+    /**
+     * restore all message media
+     */
+    private long restoreMessageMediaFiles(List<FileHeader> fileHeaders) throws RestoreCanceledException {
+        long count = 0;
+
+        count += this.restoreMessageMediaFiles(
+            fileHeaders,
+            Tags.MESSAGE_MEDIA_FILE_PREFIX,
+            Tags.MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX,
+            uid -> databaseServiceNew.getMessageModelFactory().getByUid(uid)
+        );
+
+        count += this.restoreMessageMediaFiles(
+            fileHeaders,
+            Tags.GROUP_MESSAGE_MEDIA_FILE_PREFIX,
+            Tags.GROUP_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX,
+            uid -> databaseServiceNew.getGroupMessageModelFactory().getByUid(uid)
+        );
+
+        count += this.restoreMessageMediaFiles(
+            fileHeaders,
+            Tags.DISTRIBUTION_LIST_MESSAGE_MEDIA_FILE_PREFIX,
+            Tags.DISTRIBUTION_LIST_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX,
+            uid -> databaseServiceNew.getDistributionListMessageModelFactory().getByUid(uid)
+        );
+
+        return count;
+    }
+
+
+    /**
+     * restore all message media
+     */
+    private long restoreMessageMediaFiles(
+        @NonNull List<FileHeader> fileHeaders,
+        @NonNull String filePrefix,
+        @NonNull String thumbnailPrefix,
+        @NonNull GetMessageModel getMessageModel
+    ) throws RestoreCanceledException {
+        long count = 0;
+
+        // process all thumbnails
+        Map<String, FileHeader> thumbnailFileHeaders = new HashMap<>();
+
+        for (FileHeader fileHeader : fileHeaders) {
+            String fileName = fileHeader.getFileName();
+            if(!TestUtil.isEmptyOrNull(fileName)
+                && fileName.startsWith(thumbnailPrefix)) {
+                thumbnailFileHeaders.put(fileName, fileHeader);
+            }
+        }
+
+        for (FileHeader fileHeader : fileHeaders) {
+            String fileName = fileHeader.getFileName();
+
+            String messageUid;
+            if (fileName.startsWith(filePrefix)) {
+                messageUid = fileName.substring(filePrefix.length());
+            } else if (fileName.startsWith(thumbnailPrefix)) {
+                messageUid = fileName.substring(thumbnailPrefix.length());
+            } else {
+                continue;
+            }
+
+            AbstractMessageModel model = getMessageModel.get(messageUid);
+
+            if (model != null) {
+                try {
+                    if (fileName.startsWith(thumbnailPrefix)) {
+                        // restore thumbnail
+                        if (this.writeToDb) {
+                            FileHeader thumbnailFileHeader = thumbnailFileHeaders.get(thumbnailPrefix + messageUid);
+                            if (thumbnailFileHeader != null) {
+                                try (ZipInputStream inputStream = zipFile.getInputStream(thumbnailFileHeader)) {
+                                    byte[] thumbnailBytes = IOUtils.toByteArray(inputStream);
+                                    if (thumbnailBytes != null && thumbnailBytes.length < MAX_THUMBNAIL_SIZE_BYTES) {
+                                        this.fileService.saveThumbnail(model, thumbnailBytes);
+                                    }
+                                } catch (OutOfMemoryError e) {
+                                    logger.error("Not enough memory for thumbnail", e);
+                                }
+                            }
+                        }
+                    } else {
+                        if (this.writeToDb) {
+                            byte[] imageData;
+                            try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
+                                imageData = IOUtils.toByteArray(inputStream);
+                                this.fileService.writeConversationMedia(model, imageData);
+                            } catch (OutOfMemoryError e) {
+                                logger.error("Not enough memory for media", e);
+                                imageData = null;
+                            }
+
+                            if (MessageUtil.canHaveThumbnailFile(model)) {
+                                // check if a thumbnail file is in backup
+                                FileHeader thumbnailFileHeader = thumbnailFileHeaders.get(thumbnailPrefix + messageUid);
+
+                                // if no thumbnail file exist in backup, generate one
+                                if (thumbnailFileHeader == null && imageData != null) {
+                                    this.fileService.writeConversationMediaThumbnail(model, imageData);
+                                }
+                            }
+                        }
+                    }
+                    count++;
+                    updateProgress(STEP_SIZE_MEDIA);
+                } catch (RestoreCanceledException e) {
+                    throw new RestoreCanceledException();
+                } catch (Exception x) {
+                    logger.error("Exception", x);
+                    // ignore and continue
+                }
+            } else {
+                count++;
+            }
+        }
+        return count;
+    }
+
+    private boolean restoreContactFile(@NonNull FileHeader fileHeader) throws IOException, RestoreCanceledException {
+        return this.processCsvFile(fileHeader, row -> {
+            try {
+                ContactModel contactModel = createContactModel(row, restoreSettings);
+                if (writeToDb) {
+                    // set the default color
+                    ContactModelFactory contactModelFactory = databaseServiceNew.getContactModelFactory();
+                    contactModelFactory.createOrUpdate(contactModel);
+                }
+            } catch (Exception x) {
+                logger.error("Could not restore contact", x);
+            }
+        });
+    }
+
+    private boolean restoreContactAvatarFile(@NonNull FileHeader fileHeader){
+        // Look up avatar filename
+        String filename = fileHeader.getFileName();
+        if (TestUtil.isEmptyOrNull(filename)) {
+            return false;
+        }
+
+        // Look up contact model for this avatar
+        String identityId = filename.substring(Tags.CONTACT_AVATAR_FILE_PREFIX.length());
+        if (TestUtil.isEmptyOrNull(identityId)) {
+            return false;
+        }
+
+        ContactModel contactModel;
+        if (Tags.CONTACT_AVATAR_FILE_SUFFIX_ME.equals(identityId)) {
+            contactModel = contactService.getMe();
+        } else {
+            contactModel = contactService.getByIdentity(identityIdMap.get(identityId));
+        }
+
+        if (contactModel == null) {
+            return false;
+        }
+
+        // Set contact avatar
+        try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
+            return fileService.writeUserDefinedProfilePicture(
+                contactModel.getIdentity(),
+                IOUtils.toByteArray(inputStream)
+            );
+        } catch (Exception e) {
+            logger.error("Exception while writing contact avatar", e);
+            return false;
+        }
+    }
+
+    private boolean restoreContactPhotoFile(@NonNull FileHeader fileHeader){
+        // Look up profile picture filename
+        String filename = fileHeader.getFileName();
+        if(TestUtil.isEmptyOrNull(filename)) {
+            return false;
+        }
+
+        // Look up contact model for this avatar
+        String identityId = filename.substring(Tags.CONTACT_PROFILE_PIC_FILE_PREFIX.length());
+        if (TestUtil.isEmptyOrNull(identityId)) {
+            return false;
+        }
+        ContactModel contactModel = contactService.getByIdentity(identityIdMap.get(identityId));
+        if (contactModel == null) {
+            return false;
+        }
+
+        // Set contact profile picture
+        try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
+            return fileService.writeContactDefinedProfilePicture(
+                contactModel.getIdentity(),
+                IOUtils.toByteArray(inputStream));
+        } catch (Exception e) {
+            logger.error("Exception while writing contact profile picture", e);
+            return false;
+        }
+    }
+
+    private boolean restoreGroupFile(@NonNull FileHeader fileHeader) throws IOException, RestoreCanceledException {
+        return this.processCsvFile(fileHeader, row -> {
+            try {
+                GroupModel groupModel = createGroupModel(row, restoreSettings);
+
+                if (writeToDb) {
+                    databaseServiceNew.getGroupModelFactory().create(
+                        groupModel
+                    );
+
+                    if (restoreSettings.getVersion() >= 19) {
+                        groupUidMap.put(row.getString(Tags.TAG_GROUP_UID), groupModel.getId());
+                    } else {
+                        groupUidMap.put(BackupUtils.buildGroupUid(groupModel), groupModel.getId());
+                    }
+                }
+
+                if (writeToDb) {
+                    String myIdentity = userService.getIdentity();
+                    boolean isInMemberList = false;
+
+                    List<GroupMemberModel> groupMemberModels = createGroupMembers(row, groupModel.getId());
+
+                    for (GroupMemberModel groupMemberModel : groupMemberModels) {
+                        if (!myIdentity.equals(groupMemberModel.getIdentity())) {
+                            databaseServiceNew.getGroupMemberModelFactory().create(groupMemberModel);
+                        } else {
+                            isInMemberList = true;
+                        }
+                    }
+                    if (restoreSettings.getVersion() < 25) {
+                        // In this case the group user state is not included in the backup and we
+                        // need to determine the state based on the group member list.
+                        groupModel.setUserState(isInMemberList ? MEMBER : LEFT);
+                        databaseServiceNew.getGroupModelFactory().update(groupModel);
+                    }
+                }
+            } catch (Exception x) {
+                logger.error("Could not restore group", x);
+            }
+        });
+    }
+
+    private boolean restoreDistributionListFile(@NonNull FileHeader fileHeader) throws IOException, RestoreCanceledException {
+        return this.processCsvFile(fileHeader, row -> {
+            try {
+                DistributionListModel distributionListModel = createDistributionListModel(row);
+
+                if (writeToDb) {
+                    databaseServiceNew.getDistributionListModelFactory().create(
+                        distributionListModel);
+                    distributionListIdMap.put(BackupUtils.buildDistributionListUid(distributionListModel), distributionListModel.getId());
+                }
+
+                List<DistributionListMemberModel> distributionListMemberModels = createDistributionListMembers(row, distributionListModel.getId());
+                if (writeToDb) {
+                    for (DistributionListMemberModel distributionListMemberModel : distributionListMemberModels) {
+                        databaseServiceNew.getDistributionListMemberModelFactory().create(
+                            distributionListMemberModel
+                        );
+                    }
+                }
+            } catch (Exception x) {
+                logger.error("Could not restore distribution list", x);
+            }
+        });
+    }
+
+    private void restoreBallotFile(
+        @NonNull FileHeader ballotMain,
+        @NonNull final FileHeader ballotChoice,
+        @NonNull FileHeader ballotVote
+    ) throws IOException, RestoreCanceledException {
+        this.processCsvFile(ballotMain, row -> {
+            try {
+                BallotModel ballotModel = createBallotModel(row);
+
+                if (writeToDb) {
+                    databaseServiceNew.getBallotModelFactory().create(
+                        ballotModel
+                    );
+
+                    ballotIdMap.put(BackupUtils.buildBallotUid(ballotModel), ballotModel.getId());
+                    ballotOldIdMap.put(row.getInteger(Tags.TAG_BALLOT_ID), ballotModel.getId());
+                }
+
+                LinkBallotModel ballotLinkModel = createLinkBallotModel(row, ballotModel.getId());
+
+                if (writeToDb) {
+                    if(ballotLinkModel == null) {
+                        // link failed
+                        logger.error("link failed");
+                    }
+                    if(ballotLinkModel instanceof GroupBallotModel) {
+                        databaseServiceNew.getGroupBallotModelFactory().create(
+                            (GroupBallotModel)ballotLinkModel
+                        );
+                    }
+                    else if(ballotLinkModel instanceof IdentityBallotModel) {
+                        databaseServiceNew.getIdentityBallotModelFactory().create(
+                            (IdentityBallotModel)ballotLinkModel
+                        );
+                    }
+                    else {
+                        logger.error("not handled link");
+                    }
+                }
+
+            } catch (Exception x) {
+                logger.error("Could not restore ballot", x);
+            }
+        });
+
+        this.processCsvFile(ballotChoice, row -> {
+            try {
+                BallotChoiceModel ballotChoiceModel = createBallotChoiceModel(row);
+                if (ballotChoiceModel != null && writeToDb) {
+                    databaseServiceNew.getBallotChoiceModelFactory().create(
+                        ballotChoiceModel
+                    );
+                    ballotChoiceIdMap.put(BackupUtils.buildBallotChoiceUid(ballotChoiceModel), ballotChoiceModel.getId());
+                }
+            } catch (Exception x) {
+                logger.error("Exception", x);
+                // continue!
+            }
+        });
+
+        this.processCsvFile(ballotVote, row -> {
+            try {
+                BallotVoteModel ballotVoteModel = createBallotVoteModel(row);
+                if (ballotVoteModel != null && writeToDb) {
+                    databaseServiceNew.getBallotVoteModelFactory().create(
+                        ballotVoteModel
+                    );
+                }
+            } catch (Exception x) {
+                logger.error("Exception", x);
+                // continue!
+            }
+        });
+    }
+
+    private GroupModel createGroupModel(CSVRow row, RestoreSettings restoreSettings) throws ThreemaException {
+        GroupModel groupModel = new GroupModel();
+        groupModel.setApiGroupId(new GroupId(row.getString(Tags.TAG_GROUP_ID)));
+        groupModel.setCreatorIdentity(row.getString(Tags.TAG_GROUP_CREATOR));
+        groupModel.setName(row.getString(Tags.TAG_GROUP_NAME));
+        groupModel.setCreatedAt(row.getDate(Tags.TAG_GROUP_CREATED_AT));
+
+        if(restoreSettings.getVersion() >= 4) {
+            groupModel.setDeleted(row.getBoolean(Tags.TAG_GROUP_DELETED));
+        } else {
+            groupModel.setDeleted(false);
+        }
+        if(restoreSettings.getVersion() >= 14) {
+            groupModel.setArchived(row.getBoolean(Tags.TAG_GROUP_ARCHIVED));
+        }
+
+        if (restoreSettings.getVersion() >= 17) {
+            groupModel.setGroupDesc(row.getString(Tags.TAG_GROUP_DESC));
+            groupModel.setGroupDescTimestamp(row.getDate(Tags.TAG_GROUP_DESC_TIMESTAMP));
+        }
+
+        if (restoreSettings.getVersion() >= 22) {
+            groupModel.setLastUpdate(row.getDate(Tags.TAG_GROUP_LAST_UPDATE));
+        }
+
+        if (restoreSettings.getVersion() >= 25) {
+            groupModel.setUserState(UserState.valueOf(row.getInteger(Tags.TAG_GROUP_USER_STATE)));
+        }
+
+        return groupModel;
+    }
+
+    private BallotModel createBallotModel(CSVRow row) throws ThreemaException {
+        BallotModel ballotModel = new BallotModel();
+
+        ballotModel.setApiBallotId(row.getString(Tags.TAG_BALLOT_API_ID));
+        ballotModel.setCreatorIdentity(row.getString(Tags.TAG_BALLOT_API_CREATOR));
+        ballotModel.setName(row.getString(Tags.TAG_BALLOT_NAME));
+
+        String state = row.getString(Tags.TAG_BALLOT_STATE);
+        if(TestUtil.compare(state, BallotModel.State.CLOSED.toString())) {
+            ballotModel.setState(BallotModel.State.CLOSED);
+        }
+        else if(TestUtil.compare(state, BallotModel.State.OPEN.toString())) {
+            ballotModel.setState(BallotModel.State.OPEN);
+        }
+        else if(TestUtil.compare(state, BallotModel.State.TEMPORARY.toString())) {
+            ballotModel.setState(BallotModel.State.TEMPORARY);
+        }
+
+        String assessment = row.getString(Tags.TAG_BALLOT_ASSESSMENT);
+        if(TestUtil.compare(assessment, BallotModel.Assessment.MULTIPLE_CHOICE.toString())) {
+            ballotModel.setAssessment(BallotModel.Assessment.MULTIPLE_CHOICE);
+        }
+        else if(TestUtil.compare(assessment, BallotModel.Assessment.SINGLE_CHOICE.toString())) {
+            ballotModel.setAssessment(BallotModel.Assessment.SINGLE_CHOICE);
+        }
+
+        String type = row.getString(Tags.TAG_BALLOT_TYPE);
+        if(TestUtil.compare(type, BallotModel.Type.INTERMEDIATE.toString())) {
+            ballotModel.setType(BallotModel.Type.INTERMEDIATE);
+        }
+        else if(TestUtil.compare(type, BallotModel.Type.RESULT_ON_CLOSE.toString())) {
+            ballotModel.setType(BallotModel.Type.RESULT_ON_CLOSE);
+        }
+
+        String choiceType = row.getString(Tags.TAG_BALLOT_C_TYPE);
+        if(TestUtil.compare(choiceType, BallotModel.ChoiceType.TEXT.toString())) {
+            ballotModel.setChoiceType(BallotModel.ChoiceType.TEXT);
+        }
+
+        ballotModel.setLastViewedAt(row.getDate(Tags.TAG_BALLOT_LAST_VIEWED_AT));
+        ballotModel.setCreatedAt(row.getDate(Tags.TAG_BALLOT_CREATED_AT));
+        ballotModel.setModifiedAt(row.getDate(Tags.TAG_BALLOT_MODIFIED_AT));
+
+        return ballotModel;
+    }
+
+    private LinkBallotModel createLinkBallotModel(CSVRow row, int ballotId) throws ThreemaException {
+        String reference = row.getString(Tags.TAG_BALLOT_REF);
+        String referenceId = row.getString(Tags.TAG_BALLOT_REF_ID);
+        Integer groupId = null;
+        String identity = null;
+
+        if(reference.endsWith("GroupBallotModel")) {
+            groupId = this.groupUidMap.get(referenceId);
+        }
+        else if(reference.endsWith("IdentityBallotModel")) {
+            identity = referenceId;
+        }
+        else {
+            // first try to get the reference as group
+            groupId = this.groupUidMap.get(referenceId);
+            if(groupId == null) {
+                if(referenceId != null && referenceId.length() == ProtocolDefines.IDENTITY_LEN) {
+                    identity = referenceId;
+                }
+            }
+        }
+
+        if(groupId != null) {
+            GroupBallotModel linkBallotModel = new GroupBallotModel();
+            linkBallotModel.setBallotId(ballotId);
+            linkBallotModel.setGroupId(groupId);
+
+            return linkBallotModel;
+        }
+        else if(identity != null) {
+            IdentityBallotModel linkBallotModel = new IdentityBallotModel();
+            linkBallotModel.setBallotId(ballotId);
+            linkBallotModel.setIdentity(referenceId);
+            return linkBallotModel;
+        }
+
+        if(writeToDb) {
+            logger.error("invalid ballot reference {} with id {}", reference, referenceId);
+            return null;
+        }
+        // not a valid reference!
+        return null;
+    }
+
+    private BallotChoiceModel createBallotChoiceModel(CSVRow row) throws ThreemaException {
+        Integer ballotId = ballotIdMap.get(row.getString(Tags.TAG_BALLOT_CHOICE_BALLOT_UID));
+        if(ballotId == null) {
+            logger.error("invalid ballotId");
+            return null;
+        }
+
+        BallotChoiceModel ballotChoiceModel = new BallotChoiceModel();
+        ballotChoiceModel.setBallotId(ballotId);
+        ballotChoiceModel.setApiBallotChoiceId(row.getInteger(Tags.TAG_BALLOT_CHOICE_API_ID));
+        ballotChoiceModel.setApiBallotChoiceId(row.getInteger(Tags.TAG_BALLOT_CHOICE_API_ID));
+
+        String type = row.getString(Tags.TAG_BALLOT_CHOICE_TYPE);
+        if(TestUtil.compare(type, BallotChoiceModel.Type.Text.toString())) {
+            ballotChoiceModel.setType(BallotChoiceModel.Type.Text);
+        }
+
+        ballotChoiceModel.setName(row.getString(Tags.TAG_BALLOT_CHOICE_NAME));
+        ballotChoiceModel.setVoteCount(row.getInteger(Tags.TAG_BALLOT_CHOICE_VOTE_COUNT));
+        ballotChoiceModel.setOrder(row.getInteger(Tags.TAG_BALLOT_CHOICE_ORDER));
+        ballotChoiceModel.setCreatedAt(row.getDate(Tags.TAG_BALLOT_CHOICE_CREATED_AT));
+        ballotChoiceModel.setModifiedAt(row.getDate(Tags.TAG_BALLOT_CHOICE_MODIFIED_AT));
+
+        return ballotChoiceModel;
+    }
+
+    private BallotVoteModel createBallotVoteModel(CSVRow row) throws ThreemaException {
+        Integer ballotId = ballotIdMap.get(row.getString(Tags.TAG_BALLOT_VOTE_BALLOT_UID));
+        Integer ballotChoiceId = ballotChoiceIdMap.get(row.getString(Tags.TAG_BALLOT_VOTE_CHOICE_UID));
+
+        if(ballotId == null || ballotChoiceId == null) {
+            return null;
+        }
+
+        BallotVoteModel ballotVoteModel = new BallotVoteModel();
+        ballotVoteModel.setBallotId(ballotId);
+        ballotVoteModel.setBallotChoiceId(ballotChoiceId);
+        ballotVoteModel.setVotingIdentity(row.getString(Tags.TAG_BALLOT_VOTE_IDENTITY));
+        ballotVoteModel.setChoice(row.getInteger(Tags.TAG_BALLOT_VOTE_CHOICE));
+        ballotVoteModel.setCreatedAt(row.getDate(Tags.TAG_BALLOT_VOTE_CREATED_AT));
+        ballotVoteModel.setModifiedAt(row.getDate(Tags.TAG_BALLOT_VOTE_MODIFIED_AT));
+        return ballotVoteModel;
+    }
+
+    private long restoreContactMessageFile(FileHeader fileHeader) throws IOException, ThreemaException, RestoreCanceledException {
+        final Counter counter = new Counter();
+
+        String fileName = fileHeader.getFileName();
+        if(fileName == null) {
+            throw new ThreemaException(null);
+        }
+
+        final String identityId = fileName.substring(Tags.MESSAGE_FILE_PREFIX.length(), fileName.indexOf(Tags.CSV_FILE_POSTFIX));
+        if (TestUtil.isEmptyOrNull(identityId)) {
+            throw new ThreemaException(null);
+        }
+
+        String identity = identityIdMap.get(identityId);
+
+        if (!this.processCsvFile(fileHeader, row -> {
+            try {
+                counter.count();
+
+                if (writeToDb) {
+                    MessageModel messageModel = createMessageModel(row, restoreSettings);
+                    messageModel.setIdentity(identity);
+
+                    // faster, do not make a createOrUpdate to safe queries
+                    boolean success = databaseServiceNew.getMessageModelFactory().create(
+                        messageModel
+                    );
+                    if (success) {
+                        tryMapContactAckDecToReaction(row, messageModel);
+                    }
+
+                    updateProgress(STEP_SIZE_MESSAGES);
+                }
+            } catch (RestoreCanceledException e) {
+                throw new RestoreCanceledException();
+            } catch (Exception e) {
+                logger.error("Could not restore contact message file", e);
+            }
+        })) {
+            throw new ThreemaException(null);
+        }
+        return counter.getCount();
+    }
+
+    /**
+     * If the backup entry has State USERACK or USERDEC a corresponding
+     * reaction is created for this message.
+     * If the reaction cannot be created this will be logged but ignored.
+     * If the backup entry has a state other than USERACK or USERDEC, this method
+     * has no effect.
+     * <p>
+     * Not that this will not alter the state of {@code messageModel}
+     * (also see {@link #setMessageState})
+     */
+    private void tryMapContactAckDecToReaction(@NonNull CSVRow row, @NonNull MessageModel messageModel) {
+        try {
+            String backupMessageStateName = row.getString(Tags.TAG_MESSAGE_MESSAGE_STATE);
+
+            if (backupMessageStateName != null) {
+                createContactReactionForMessage(backupMessageStateName, messageModel);
+            }
+        } catch (Exception e) {
+            logger.error("Exception while trying to map ACK/DEC message state to a reaction", e);
+        }
+    }
+
+    private void createContactReactionForMessage(
+        @NonNull String backupMessageStateName,
+        @NonNull MessageModel messageModel
+    ) throws Exception {
+        String senderIdentity = messageModel.isOutbox()
+            ? messageModel.getIdentity()
+            : userService.getIdentity();
+        DbEmojiReaction reaction = createReactionForStateName(backupMessageStateName, senderIdentity, messageModel);
+        if (reaction != null) {
+            logger.debug(
+                "Create contact reaction for message {} (id={}) with state {}",
+                messageModel.getApiMessageId(),
+                messageModel.getId(),
+                backupMessageStateName
+            );
+            modelRepositories.getEmojiReaction().restoreContactReactions(insertScope ->
+                insertScope.insert(reaction)
+            );
+        }
+
+    }
+
+    private long restoreGroupMessageFile(FileHeader fileHeader)  throws IOException, ThreemaException, RestoreCanceledException {
+        final Counter counter = new Counter();
+
+        String fileName = fileHeader.getFileName();
+        if(fileName == null) {
+            throw new ThreemaException(null);
+        }
+
+        final String groupUid = fileName.substring(Tags.GROUP_MESSAGE_FILE_PREFIX.length(), fileName.indexOf(Tags.CSV_FILE_POSTFIX));
+        if (TestUtil.isEmptyOrNull(groupUid)) {
+            throw new ThreemaException("Group uid could not be extracted");
+        }
+
+        if (!this.processCsvFile(fileHeader, row -> {
+            try {
+                counter.count();
+
+                if (writeToDb) {
+                    GroupMessageModel groupMessageModel = createGroupMessageModel(row, restoreSettings);
+                    Integer groupId = groupUidMap.get(groupUid);
+                    if (groupId != null) {
+                        groupMessageModel.setGroupId(groupId);
+                        boolean success = databaseServiceNew.getGroupMessageModelFactory().create(
+                            groupMessageModel
+                        );
+                        if (success) {
+                            tryMapGroupAckDecToReactions(row, groupMessageModel);
+                        }
+                    }
+                    updateProgress(STEP_SIZE_MESSAGES);
+                }
+            } catch (RestoreCanceledException e) {
+                throw new RestoreCanceledException();
+            } catch (Exception e) {
+                logger.error("Could not restore group message file", e);
+            }
+        })) {
+            throw new ThreemaException(null);
+        }
+        return counter.getCount();
+    }
+
+    private void tryMapGroupAckDecToReactions(@NonNull CSVRow row, @NonNull GroupMessageModel messageModel) {
+        if (restoreSettings.getVersion() >= 17) {
+            try {
+                String messageStatesJson = row.getString(Tags.TAG_GROUP_MESSAGE_STATES);
+                if (!TestUtil.isEmptyOrNull(messageStatesJson)) {
+                    createGroupReactionsForMessage(messageStatesJson, messageModel);
+                }
+            } catch (Exception e) {
+                logger.error("Exception while trying to map group ACK/DEC to reactions", e);
+            }
+        }
+    }
+
+    private void createGroupReactionsForMessage(
+        @NonNull String messageStatesJson,
+        @NonNull GroupMessageModel messageModel
+    ) throws Exception {
+        logger.debug(
+            "Create group reactions for message {} (id={}) with states {}",
+            messageModel.getApiMessageId(),
+            messageModel.getId(),
+            messageStatesJson
+        );
+        Map<String, Object> messageStatesMap = JsonUtil.convertObject(messageStatesJson);
+        List<DbEmojiReaction> reactions = messageStatesMap.entrySet().stream()
+            .filter(entry -> entry != null && entry.getKey() != null && entry.getValue() instanceof String)
+            .map(entry -> createReactionForStateName(
+                    (String) entry.getValue(),
+                    entry.getKey(),
+                    messageModel
+                ))
+            .filter(Objects::nonNull)
+            .collect(Collectors.toList());
+        if (!reactions.isEmpty()) {
+            modelRepositories.getEmojiReaction()
+                .restoreGroupReactions(insertScope -> reactions.forEach(insertScope::insert));
+        }
+    }
+
+    @Nullable
+    private DbEmojiReaction createReactionForStateName(
+        @NonNull String stateName,
+        @NonNull String senderIdentity,
+        @NonNull AbstractMessageModel messageModel
+    ) {
+        String reaction = mapMessageStateNameToReactionSequence(stateName);
+        if (reaction == null) {
+            return null;
+        }
+
+        // We do not exactly now, when this reaction was actually created
+        // therefore we make a best guess.
+        Date reactedAt;
+        if (messageModel.getModifiedAt() != null) {
+            // This is the closest we get
+            reactedAt = messageModel.getModifiedAt();
+        } else if (messageModel.getCreatedAt() != null) {
+            // Use creation date of message if modified at is not available
+            reactedAt = messageModel.getCreatedAt();
+        } else {
+            // Fallback to current date if no other dates are present
+            reactedAt = new Date();
+        }
+
+        return new DbEmojiReaction(
+            messageModel.getId(),
+            senderIdentity,
+            reaction,
+            reactedAt
+        );
+    }
+
+    @Nullable
+    private String mapMessageStateNameToReactionSequence(@NonNull String stateName) {
+        if (MessageState.USERACK.name().equals(stateName)) {
+            return EmojiUtil.THUMBS_UP_SEQUENCE;
+        } else if (MessageState.USERDEC.name().equals(stateName)) {
+            return EmojiUtil.THUMBS_DOWN_SEQUENCE;
+        } else {
+            return null;
+        }
+    }
+
+    private long restoreDistributionListMessageFile(FileHeader fileHeader) throws IOException, ThreemaException, RestoreCanceledException {
+        final Counter counter = new Counter();
+
+        String fileName = fileHeader.getFileName();
+        if(fileName == null) {
+            throw new ThreemaException(null);
+        }
+
+        String[] pieces = fileName.substring(Tags.DISTRIBUTION_LIST_MESSAGE_FILE_PREFIX.length(), fileName.indexOf(Tags.CSV_FILE_POSTFIX)).split("-");
+
+        if(pieces.length != 1) {
+            throw new ThreemaException(null);
+        }
+
+        final String distributionListBackupUid = pieces[0];
+
+        if (TestUtil.isEmptyOrNull(distributionListBackupUid)) {
+            throw new ThreemaException(null);
+        }
+
+        if (!this.processCsvFile(fileHeader, row -> {
+            try {
+                DistributionListMessageModel distributionListMessageModel = createDistributionListMessageModel(row, restoreSettings);
+                counter.count();
+
+                if (writeToDb) {
+                    updateProgress(STEP_SIZE_MESSAGES);
+
+                    final Long distributionListId = distributionListIdMap.get(distributionListBackupUid);
+                    if (distributionListId != null) {
+                        distributionListMessageModel.setDistributionListId(distributionListId);
+                        databaseServiceNew.getDistributionListMessageModelFactory().createOrUpdate(
+                            distributionListMessageModel
+                        );
+                    }
+                }
+            } catch (RestoreCanceledException e) {
+                throw new RestoreCanceledException();
+            } catch (Exception e) {
+                logger.error("Could not restore distribution list message file", e);
+            }
+        })) {
+            throw new ThreemaException(null);
+        }
+        return counter.getCount();
+    }
+
+    private DistributionListModel createDistributionListModel(CSVRow row) throws ThreemaException {
+        DistributionListModel distributionListModel = new DistributionListModel();
+        distributionListModel.setId(row.getLong(Tags.TAG_DISTRIBUTION_LIST_ID));
+        distributionListModel.setName(row.getString(Tags.TAG_DISTRIBUTION_LIST_NAME));
+        distributionListModel.setCreatedAt(row.getDate(Tags.TAG_DISTRIBUTION_CREATED_AT));
+        if(restoreSettings.getVersion() >= 14) {
+            distributionListModel.setArchived(row.getBoolean(Tags.TAG_DISTRIBUTION_LIST_ARCHIVED));
+        }
+        if (restoreSettings.getVersion() >= 22) {
+            distributionListModel.setLastUpdate(row.getDate(Tags.TAG_DISTRIBUTION_LAST_UPDATE));
+        }
+        return distributionListModel;
+    }
+
+    private List<GroupMemberModel> createGroupMembers(CSVRow row, int groupId) throws ThreemaException {
+        List<GroupMemberModel> res = new ArrayList<>();
+        for(String identity: row.getStrings(Tags.TAG_GROUP_MEMBERS)) {
+            if(!TestUtil.isEmptyOrNull(identity)) {
+                GroupMemberModel m = new GroupMemberModel();
+                m.setGroupId(groupId);
+                m.setIdentity(identity);
+                res.add(m);
+            }
+        }
+        return res;
+    }
+
+    private List<DistributionListMemberModel> createDistributionListMembers(CSVRow row, long distributionListId) throws ThreemaException {
+        List<DistributionListMemberModel> res = new ArrayList<>();
+        for(String identity: row.getStrings(Tags.TAG_DISTRIBUTION_MEMBERS)) {
+            if(!TestUtil.isEmptyOrNull(identity)) {
+                DistributionListMemberModel m = new DistributionListMemberModel();
+                m.setDistributionListId(distributionListId);
+                m.setIdentity(identity);
+                m.setActive(true);
+                res.add(m);
+            }
+        }
+        return res;
+    }
+
+    private ContactModel createContactModel(CSVRow row, RestoreSettings restoreSettings) throws ThreemaException {
+
+        ContactModel contactModel = new ContactModel(
+            row.getString(Tags.TAG_CONTACT_IDENTITY),
+            Utils.hexStringToByteArray(row.getString(Tags.TAG_CONTACT_PUBLIC_KEY)));
+
+        String verificationString = row.getString(Tags.TAG_CONTACT_VERIFICATION_LEVEL);
+        VerificationLevel verification = VerificationLevel.UNVERIFIED;
+
+        if (verificationString.equals(VerificationLevel.SERVER_VERIFIED.name())) {
+            verification = VerificationLevel.SERVER_VERIFIED;
+        } else if (verificationString.equals(VerificationLevel.FULLY_VERIFIED.name())) {
+            verification = VerificationLevel.FULLY_VERIFIED;
+        }
+        contactModel.verificationLevel = verification;
+        contactModel.setFirstName(row.getString(Tags.TAG_CONTACT_FIRST_NAME));
+        contactModel.setLastName(row.getString(Tags.TAG_CONTACT_LAST_NAME));
+
+        if(restoreSettings.getVersion() >= 3) {
+            contactModel.setPublicNickName(row.getString(Tags.TAG_CONTACT_NICK_NAME));
+        }
+        if(restoreSettings.getVersion() >= 13) {
+            final boolean isHidden = row.getBoolean(Tags.TAG_CONTACT_HIDDEN);
+            // Contacts are marked as hidden if their acquaintance level is GROUP
+            contactModel.setAcquaintanceLevel(isHidden ? AcquaintanceLevel.GROUP : AcquaintanceLevel.DIRECT);
+        }
+        if(restoreSettings.getVersion() >= 14) {
+            contactModel.setArchived(row.getBoolean(Tags.TAG_CONTACT_ARCHIVED));
+        }
+        if (restoreSettings.getVersion() >= 19) {
+            identityIdMap.put(row.getString(Tags.TAG_CONTACT_IDENTITY_ID), contactModel.getIdentity());
+        } else {
+            identityIdMap.put(contactModel.getIdentity(), contactModel.getIdentity());
+        }
+        if (restoreSettings.getVersion() >= 22) {
+            contactModel.setLastUpdate(row.getDate(Tags.TAG_CONTACT_LAST_UPDATE));
+        }
+        contactModel.setIsRestored(true);
+
+        return contactModel;
+    }
+
+    private void fillMessageModel(
+        @NonNull AbstractMessageModel messageModel,
+        @NonNull CSVRow row,
+        @NonNull RestoreSettings restoreSettings
+    ) throws ThreemaException {
+        messageModel.setApiMessageId(row.getString(Tags.TAG_MESSAGE_API_MESSAGE_ID));
+        messageModel.setOutbox(row.getBoolean(Tags.TAG_MESSAGE_IS_OUTBOX));
+        messageModel.setRead(row.getBoolean(Tags.TAG_MESSAGE_IS_READ));
+        messageModel.setSaved(row.getBoolean(Tags.TAG_MESSAGE_IS_SAVED));
+
+        setCommonTimestamps(messageModel, row);
+
+        setMessageState(messageModel, row);
+
+        setMessageContent(messageModel, row);
+
+        tryUpdatingToNewBallotId(messageModel);
+
+        messageModel.setUid(row.getString(Tags.TAG_MESSAGE_UID));
+
+        if(restoreSettings.getVersion() >= 2) {
+            messageModel.setIsStatusMessage(row.getBoolean(Tags.TAG_MESSAGE_IS_STATUS_MESSAGE));
+        }
+
+        if(restoreSettings.getVersion() >= 10) {
+            messageModel.setCaption(row.getString(Tags.TAG_MESSAGE_CAPTION));
+        }
+
+        if(restoreSettings.getVersion() >= 15) {
+            String quotedMessageId = row.getString(Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID);
+            if (!TestUtil.isEmptyOrNull(quotedMessageId)) {
+                messageModel.setQuotedMessageId(quotedMessageId);
+            }
+        }
+
+        if(restoreSettings.getVersion() >= 20) {
+            if (!(messageModel instanceof DistributionListMessageModel)) {
+                Integer displayTags = row.getInteger(Tags.TAG_MESSAGE_DISPLAY_TAGS);
+                messageModel.setDisplayTags(displayTags);
+            }
+        }
+    }
+
+    private void setCommonTimestamps(
+        @NonNull AbstractMessageModel messageModel,
+        @NonNull CSVRow row
+    ) throws ThreemaException {
+        messageModel.setPostedAt(row.getDate(Tags.TAG_MESSAGE_POSTED_AT));
+        messageModel.setCreatedAt(row.getDate(Tags.TAG_MESSAGE_CREATED_AT));
+
+        if(restoreSettings.getVersion() >= 5) {
+            messageModel.setModifiedAt(row.getDate(Tags.TAG_MESSAGE_MODIFIED_AT));
+        }
+
+        if (restoreSettings.getVersion() >= 16) {
+            messageModel.setDeliveredAt(row.getDate(Tags.TAG_MESSAGE_DELIVERED_AT));
+            messageModel.setReadAt(row.getDate(Tags.TAG_MESSAGE_READ_AT));
+        }
+
+        // Edit/delete is only available for contact and group messages
+        if (messageModel instanceof MessageModel || messageModel instanceof GroupMessageModel) {
+            if (restoreSettings.getVersion() >= 23) {
+                messageModel.setEditedAt(row.getDate(Tags.TAG_MESSAGE_EDITED_AT));
+            }
+            if (restoreSettings.getVersion() >= 24) {
+                messageModel.setDeletedAt(row.getDate(Tags.TAG_MESSAGE_DELETED_AT));
+            }
+        }
+    }
+
+    /**
+     * Set the state for this message. If the message state is ACK/DEC
+     * the correct state will be derived from the available timestamps.
+     * Therefore this only leads to correct results if the timestamps on this message
+     * are already set.
+     *
+     * Note that no reaction is created in case of ACK/DEC. This has to be taken
+     * care of separately see {@link #tryMapContactAckDecToReaction}.
+     */
+    private void setMessageState(
+        @NonNull AbstractMessageModel messageModel,
+        @NonNull CSVRow row
+    ) throws ThreemaException {
+        String messageState = row.getString(Tags.TAG_MESSAGE_MESSAGE_STATE);
+        MessageState state = null;
+        if (messageState.equals(MessageState.PENDING.name())) {
+            state = MessageState.PENDING;
+        } else if (messageState.equals(MessageState.SENDFAILED.name())) {
+            state = MessageState.SENDFAILED;
+        } else if (messageState.equals(MessageState.USERACK.name()) || messageState.equals(MessageState.USERDEC.name())) {
+            state = messageModel.getReadAt() != null
+                ? MessageState.READ
+                : MessageState.DELIVERED;
+        } else if (messageState.equals(MessageState.DELIVERED.name())) {
+            state = MessageState.DELIVERED;
+        } else if (messageState.equals(MessageState.READ.name())) {
+            state = MessageState.READ;
+        } else if (messageState.equals(MessageState.SENDING.name())) {
+            state = MessageState.SENDING;
+        } else if (messageState.equals(MessageState.SENT.name())) {
+            state = MessageState.SENT;
+        } else if (messageState.equals(MessageState.CONSUMED.name())) {
+            state = MessageState.CONSUMED;
+        } else if (messageState.equals(MessageState.FS_KEY_MISMATCH.name())) {
+            state = MessageState.FS_KEY_MISMATCH;
+        }
+        messageModel.setState(state);
+    }
+
+    private void setMessageContent(@NonNull AbstractMessageModel messageModel, @NonNull CSVRow row) throws ThreemaException {
+        MessageType messageType = MessageType.TEXT;
+        @MessageContentsType int messageContentsType = MessageContentsType.UNDEFINED;
+        String typeAsString = row.getString(Tags.TAG_MESSAGE_TYPE);
+
+        if (typeAsString.equals(MessageType.VIDEO.name())) {
+            messageType = MessageType.VIDEO;
+            messageContentsType = MessageContentsType.VIDEO;
+        } else if (typeAsString.equals(MessageType.VOICEMESSAGE.name())) {
+            messageType = MessageType.VOICEMESSAGE;
+            messageContentsType = MessageContentsType.VOICE_MESSAGE;
+        } else if (typeAsString.equals(MessageType.LOCATION.name())) {
+            messageType = MessageType.LOCATION;
+            messageContentsType = MessageContentsType.LOCATION;
+        } else if (typeAsString.equals(MessageType.IMAGE.name())) {
+            messageType = MessageType.IMAGE;
+            messageContentsType = MessageContentsType.IMAGE;
+        } else if (typeAsString.equals(MessageType.CONTACT.name())) {
+            messageType = MessageType.CONTACT;
+            messageContentsType = MessageContentsType.CONTACT;
+        } else if (typeAsString.equals(MessageType.BALLOT.name())) {
+            messageType = MessageType.BALLOT;
+            messageContentsType = MessageContentsType.BALLOT;
+        } else if (typeAsString.equals(MessageType.FILE.name())) {
+            messageType = MessageType.FILE;
+            // get mime type from body
+            String body = row.getString(Tags.TAG_MESSAGE_BODY);
+            if (!TestUtil.isEmptyOrNull(body)) {
+                FileDataModel fileDataModel = FileDataModel.create(body);
+                messageContentsType = MimeUtil.getContentTypeFromFileData(fileDataModel);
+            } else {
+                messageContentsType = MessageContentsType.FILE;
+            }
+        } else if (typeAsString.equals(MessageType.VOIP_STATUS.name())) {
+            messageType = MessageType.VOIP_STATUS;
+            messageContentsType = MessageContentsType.VOIP_STATUS;
+        } else if (typeAsString.equals(MessageType.GROUP_CALL_STATUS.name())) {
+            messageType = MessageType.GROUP_CALL_STATUS;
+            messageContentsType = MessageContentsType.GROUP_CALL_STATUS;
+        } else if (typeAsString.equals(MessageType.GROUP_STATUS.name())) {
+            messageType = MessageType.GROUP_STATUS;
+            messageContentsType = MessageContentsType.GROUP_STATUS;
+        }
+        messageModel.setType(messageType);
+        messageModel.setMessageContentsType(messageContentsType);
+        messageModel.setBody(row.getString(Tags.TAG_MESSAGE_BODY));
+    }
+
+    private void tryUpdatingToNewBallotId(@NonNull AbstractMessageModel messageModel) {
+        if(messageModel.getType() == MessageType.BALLOT) {
+            // try to update to new ballot id
+            BallotDataModel ballotData = messageModel.getBallotData();
+            Integer ballotId = this.ballotOldIdMap.get(ballotData.getBallotId());
+            if(ballotId != null) {
+                BallotDataModel newBallotData = new BallotDataModel(ballotData.getType(), ballotId);
+                messageModel.setBallotData(newBallotData);
+            }
+        }
+    }
+
+    private MessageModel createMessageModel(
+        @NonNull CSVRow row,
+        @NonNull RestoreSettings restoreSettings
+    ) throws ThreemaException {
+        MessageModel messageModel = new MessageModel();
+        this.fillMessageModel(messageModel, row, restoreSettings);
+        return messageModel;
+    }
+
+    private GroupMessageModel createGroupMessageModel(CSVRow row, RestoreSettings restoreSettings) throws ThreemaException {
+        GroupMessageModel messageModel = new GroupMessageModel();
+        this.fillMessageModel(messageModel, row, restoreSettings);
+        messageModel.setIdentity(row.getString(Tags.TAG_MESSAGE_IDENTITY));
+        return messageModel;
+    }
+
+    private DistributionListMessageModel createDistributionListMessageModel(CSVRow row, RestoreSettings restoreSettings) throws ThreemaException {
+        DistributionListMessageModel messageModel = new DistributionListMessageModel();
+        this.fillMessageModel(messageModel, row, restoreSettings);
+
+        messageModel.setIdentity(row.getString(Tags.TAG_MESSAGE_IDENTITY));
+
+        return messageModel;
+    }
+
+    private boolean processCsvFile(
+        @NonNull FileHeader fileHeader,
+        @NonNull ProcessCsvFile processCsvFile
+    ) throws IOException, RestoreCanceledException {
+        try (ZipInputStream inputStream = this.zipFile.getInputStream(fileHeader);
+             InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
+             CSVReader csvReader = new CSVReader(inputStreamReader, true)) {
+            CSVRow row;
+            while ((row = csvReader.readNextRow()) != null) {
+                processCsvFile.row(row);
+            }
+        }
+        return true;
+    }
+
+    private void initProgress(long steps) {
+        this.currentProgressStep = 0;
+        this.progressSteps = steps;
+        this.latestPercentStep = 0;
+        this.startTime = System.currentTimeMillis();
+
+        this.handleProgress();
+    }
+
+    private void updateProgress(long increment) throws RestoreCanceledException {
+        if (isCanceled) {
+            throw new RestoreCanceledException();
+        }
+
+        if (writeToDb) {
+            this.currentProgressStep += increment;
+            handleProgress();
+        }
+    }
+
+    /**
+     * only call progress on 100 steps
+     */
+    private void handleProgress() {
+        int p = (int) (100d / (double) this.progressSteps * (double) this.currentProgressStep);
+        if (p > this.latestPercentStep) {
+            this.latestPercentStep = p;
+            String remainingTimeText = getRemainingTimeText(latestPercentStep, 100);
+            updatePersistentNotification(latestPercentStep, 100, false, remainingTimeText);
+            LocalBroadcastManager.getInstance(this)
+                .sendBroadcast(new Intent(RESTORE_PROGRESS_INTENT)
+                    .putExtra(RESTORE_PROGRESS, latestPercentStep)
+                    .putExtra(RESTORE_PROGRESS_STEPS, 100)
+                    .putExtra(RESTORE_PROGRESS_MESSAGE, remainingTimeText)
+                );
+        }
+    }
+
+    public void onFinished(String message) {
+        logger.info("onFinished success = {}", restoreSuccess);
+
+        cancelPersistentNotification();
+
+        if (restoreSuccess && userService.hasIdentity()) {
+            notificationPreferenceService.setWizardRunning(true);
+
+            showRestoreSuccessNotification();
+
+            // try to reopen connection
+            try {
+                if (!serviceManager.getConnection().isRunning()) {
+                    serviceManager.startConnection();
+                }
+            } catch (Exception e) {
+                logger.error("Exception", e);
+            }
+
+            if (wakeLock != null && wakeLock.isHeld()) {
+                logger.debug("releasing wakelock");
+                wakeLock.release();
+            }
+
+            stopForeground(true);
+
+            isRunning = false;
+
+            // Send broadcast after isRunning has been set to false to indicate that there is no
+            // backup being restored anymore
+            LocalBroadcastManager.getInstance(this)
+                .sendBroadcast(new Intent(RESTORE_PROGRESS_INTENT)
+                    .putExtra(RESTORE_PROGRESS, 100)
+                    .putExtra(RESTORE_PROGRESS_STEPS, 100)
+                );
+
+            if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
+                ConfigUtils.scheduleAppRestart(getApplicationContext(), 2 * (int) DateUtils.SECOND_IN_MILLIS, getApplicationContext().getResources().getString(R.string.ipv6_restart_now));
+            }
+            stopSelf();
+        } else {
+            showRestoreErrorNotification(message);
+
+            // Send broadcast so that the BackupRestoreProgressActivity can display the message
+            LocalBroadcastManager.getInstance(this).sendBroadcast(
+                new Intent(RESTORE_PROGRESS_INTENT).putExtra(RESTORE_PROGRESS_ERROR_MESSAGE, message)
+            );
+
+            new DeleteIdentityAsyncTask(null, () -> {
+                isRunning = false;
+
+                System.exit(0);
+            }).execute();
+        }
+    }
+
+    private Notification getPersistentNotification() {
+        logger.debug("getPersistentNotification");
+
+        Intent cancelIntent = new Intent(this, RestoreService.class);
+        cancelIntent.putExtra(EXTRA_ID_CANCEL, true);
+        PendingIntent cancelPendingIntent;
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            cancelPendingIntent = PendingIntent.getForegroundService(this, (int) System.currentTimeMillis(), cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
+        } else {
+            cancelPendingIntent = PendingIntent.getService(this, (int) System.currentTimeMillis(), cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
+        }
+
+        notificationBuilder = new NotificationCompat.Builder(this, NotificationChannels.NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS)
+            .setContentTitle(getString(R.string.restoring_backup))
+            .setContentText(getString(R.string.please_wait))
+            .setOngoing(true)
+            .setSmallIcon(R.drawable.ic_notification_small)
+            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+            .addAction(R.drawable.ic_close_white_24dp, getString(R.string.cancel), cancelPendingIntent);
+
+        return notificationBuilder.build();
+    }
+
+    @SuppressLint("MissingPermission")
+    private void updatePersistentNotification(int currentStep, int steps, boolean indeterminate, @Nullable final String remainingTimeText) {
+        logger.debug("updatePersistentNotification {} of {}", currentStep, steps);
+
+        if (remainingTimeText != null) {
+            notificationBuilder.setContentText(remainingTimeText);
+        }
+
+        notificationBuilder.setProgress(steps, currentStep, indeterminate);
+        notificationManagerCompat.notify(RESTORE_NOTIFICATION_ID, notificationBuilder.build());
+    }
+
+    private String getRemainingTimeText(int currentStep, int steps) {
+        final long millisPassed = System.currentTimeMillis() - startTime;
+        final long millisRemaining = millisPassed * steps / currentStep - millisPassed;
+        String timeRemaining = StringConversionUtil.secondsToString(millisRemaining / DateUtils.SECOND_IN_MILLIS, false);
+        return String.format(getString(R.string.time_remaining), timeRemaining);
+    }
+
+
+    private void cancelPersistentNotification() {
+        notificationManagerCompat.cancel(RESTORE_NOTIFICATION_ID);
+    }
+
+    @SuppressLint("MissingPermission")
+    private void showRestoreErrorNotification(String message) {
+        String contentText;
+
+        if (!TestUtil.isEmptyOrNull(message)) {
+            contentText = message;
+        } else {
+            contentText = getString(R.string.restore_error_body);
+        }
+
+        NotificationCompat.Builder builder =
+            new NotificationCompat.Builder(this, NotificationChannels.NOTIFICATION_CHANNEL_ALERT)
+                .setSmallIcon(R.drawable.ic_notification_small)
+                .setTicker(getString(R.string.restore_error_body))
+                .setContentTitle(getString(R.string.restoring_backup))
+                .setContentText(contentText)
+                .setDefaults(Notification.DEFAULT_LIGHTS|Notification.DEFAULT_SOUND|Notification.DEFAULT_VIBRATE)
+                .setPriority(NotificationCompat.PRIORITY_MAX)
+                .setStyle(new NotificationCompat.BigTextStyle().bigText(contentText))
+                .setAutoCancel(false);
+
+        notificationManagerCompat.notify(RESTORE_COMPLETION_NOTIFICATION_ID, builder.build());
+    }
+
+
+    @SuppressLint("MissingPermission")
+    private void showRestoreSuccessNotification() {
+        String text;
+
+        NotificationCompat.Builder builder =
+            new NotificationCompat.Builder(this, NotificationChannels.NOTIFICATION_CHANNEL_ALERT)
+                .setSmallIcon(R.drawable.ic_notification_small)
+                .setTicker(getString(R.string.restore_success_body))
+                .setContentTitle(getString(R.string.restoring_backup))
+                .setDefaults(Notification.DEFAULT_LIGHTS|Notification.DEFAULT_SOUND|Notification.DEFAULT_VIBRATE)
+                .setPriority(NotificationCompat.PRIORITY_MAX)
+                .setAutoCancel(true);
+
+        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
+            // Android Q does not allow restart in the background
+            Intent backupIntent = new Intent(this, HomeActivity.class);
+            PendingIntent pendingIntent = PendingIntent.getActivity(this, (int)System.currentTimeMillis(), backupIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
+
+            builder.setContentIntent(pendingIntent);
+
+            text = getString(R.string.restore_success_body) + "\n" + getString(R.string.tap_to_start, getString(R.string.app_name));
+        } else {
+            text = getString(R.string.restore_success_body);
+        }
+
+        builder.setContentText(text);
+        builder.setStyle(new NotificationCompat.BigTextStyle().bigText(text));
+
+        notificationManagerCompat.notify(RESTORE_COMPLETION_NOTIFICATION_ID, builder.build());
+    }
 }

+ 50 - 49
app/src/main/java/ch/threema/app/backuprestore/csv/RestoreSettings.java

@@ -25,58 +25,59 @@ import java.util.ArrayList;
 import java.util.List;
 
 public class RestoreSettings {
-	/**
-	 *
-	 * 7: Added local contact avatar
-	 * 8: Add file message support
-	 * 9: add queued field to every message
-	 * 10: add captions to message model
-	 * 11: add profile pics
-	 * 12: voip status messages (not implemented)
-	 * 13: add hidden flag to contacts
-	 * 15: add quoted message id to messages
-	 * 16: added read and delivered date
-	 * 17: group message states (ack / dec) and group descriptions
-	 * 18: contact forward security flag
-	 * 19: add random contact id
-	 * 20: add message display type (starred etc.)
-	 * 21: refactored group status messages
-	 * 22: add lastUpdate and remove isQueued flag
-	 * 23: add editedAt
-	 * 24: add deletedAt
-	 * 25: add group user state
-	 */
-	public static final int CURRENT_VERSION = 25;
-	private int version;
+    /**
+     *
+     * 7: Added local contact avatar
+     * 8: Add file message support
+     * 9: add queued field to every message
+     * 10: add captions to message model
+     * 11: add profile pics
+     * 12: voip status messages (not implemented)
+     * 13: add hidden flag to contacts
+     * 15: add quoted message id to messages
+     * 16: added read and delivered date
+     * 17: group message states (ack / dec) and group descriptions
+     * 18: contact forward security flag
+     * 19: add random contact id
+     * 20: add message display type (starred etc.)
+     * 21: refactored group status messages
+     * 22: add lastUpdate and remove isQueued flag
+     * 23: add editedAt
+     * 24: add deletedAt
+     * 25: add group user state
+     * 26: add reactions
+     */
+    public static final int CURRENT_VERSION = 26;
+    private int version;
 
-	public RestoreSettings(int version) {
-		this.version = version;
-	}
+    public RestoreSettings(int version) {
+        this.version = version;
+    }
 
-	public RestoreSettings() {
-		this(1);
-	}
+    public RestoreSettings() {
+        this(1);
+    }
 
-	public boolean isUnsupportedVersion() {
-		return version > CURRENT_VERSION;
-	}
+    public boolean isUnsupportedVersion() {
+        return version > CURRENT_VERSION;
+    }
 
-	public int getVersion() {
-		return this.version;
-	}
-	public void parse(List<String[]> strings) {
-		for(String[] row: strings) {
-			if(row.length == 2) {
-				if(row[0].equals(Tags.TAG_INFO_VERSION)) {
-					this.version = Integer.parseInt(row[1]);
-				}
-			}
-		}
-	}
+    public int getVersion() {
+        return this.version;
+    }
+    public void parse(List<String[]> strings) {
+        for(String[] row: strings) {
+            if(row.length == 2) {
+                if(row[0].equals(Tags.TAG_INFO_VERSION)) {
+                    this.version = Integer.parseInt(row[1]);
+                }
+            }
+        }
+    }
 
-	public List<String[]> toList() {
-		List<String[]> l = new ArrayList<>();
-		l.add(new String[]{Tags.TAG_INFO_VERSION, String.valueOf(this.version)});
-		return l;
-	}
+    public List<String[]> toList() {
+        List<String[]> l = new ArrayList<>();
+        l.add(new String[]{Tags.TAG_INFO_VERSION, String.valueOf(this.version)});
+        return l;
+    }
 }

+ 122 - 110
app/src/main/java/ch/threema/app/backuprestore/csv/Tags.java

@@ -22,126 +22,138 @@
 package ch.threema.app.backuprestore.csv;
 
 public abstract class Tags {
-	public static final String SETTINGS_FILE_NAME = "settings";
-	public static final String IDENTITY_FILE_NAME = "identity";
-	public static final String CONTACTS_FILE_NAME = "contacts";
-	public static final String GROUPS_FILE_NAME = "groups";
-	public static final String MESSAGE_FILE_PREFIX = "message_";
-	public static final String GROUP_MESSAGE_FILE_PREFIX = "group_message_";
-	public static final String DISTRIBUTION_LIST_MESSAGE_FILE_PREFIX = "distribution_list_message_";
-	public static final String MESSAGE_MEDIA_FILE_PREFIX = "message_media_";
-	public static final String GROUP_MESSAGE_MEDIA_FILE_PREFIX = "group_message_media_";
-	public static final String MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX = "message_thumbnail_";
-	public static final String GROUP_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX = "group_message_thumbnail_";
-	public static final String CONTACT_AVATAR_FILE_PREFIX = "contact_avatar_";
-	public static final String CONTACT_AVATAR_FILE_SUFFIX_ME = "me";
-	public static final String CONTACT_PROFILE_PIC_FILE_PREFIX = "contact_profile_pic_";
-	// do not rename csp nonces file to preserve backwards compatibility
-	public static final String NONCE_FILE_NAME_CSP = "nonces";
-	public static final String NONCE_FILE_NAME_D2D = "nonces_d2d";
-	public static final String NONCE_COUNTS_FILE = "nonce_counts";
+    public static final String SETTINGS_FILE_NAME = "settings";
+    public static final String IDENTITY_FILE_NAME = "identity";
+    public static final String CONTACTS_FILE_NAME = "contacts";
+    public static final String GROUPS_FILE_NAME = "groups";
+    public static final String MESSAGE_FILE_PREFIX = "message_";
+    public static final String GROUP_MESSAGE_FILE_PREFIX = "group_message_";
+    public static final String DISTRIBUTION_LIST_MESSAGE_FILE_PREFIX = "distribution_list_message_";
+    public static final String MESSAGE_MEDIA_FILE_PREFIX = "message_media_";
+    public static final String GROUP_MESSAGE_MEDIA_FILE_PREFIX = "group_message_media_";
+    public static final String MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX = "message_thumbnail_";
+    public static final String GROUP_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX = "group_message_thumbnail_";
+    public static final String CONTACT_AVATAR_FILE_PREFIX = "contact_avatar_";
+    public static final String CONTACT_AVATAR_FILE_SUFFIX_ME = "me";
+    public static final String CONTACT_PROFILE_PIC_FILE_PREFIX = "contact_profile_pic_";
+    public static final String CONTACT_REACTIONS_FILE_NAME = "contact_reactions";
+    public static final String GROUP_REACTIONS_FILE_NAME = "group_reactions";
+    public static final String REACTION_COUNTS_FILE = "reaction_counts";
+    // do not rename csp nonces file to preserve backwards compatibility
+    public static final String NONCE_FILE_NAME_CSP = "nonces";
+    public static final String NONCE_FILE_NAME_D2D = "nonces_d2d";
+    public static final String NONCE_COUNTS_FILE = "nonce_counts";
 
-	public static final String DISTRIBUTION_LIST_MESSAGE_MEDIA_FILE_PREFIX = "distribution_list_message_media_";
-	public static final String DISTRIBUTION_LIST_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX = "distribution_list_thumbnail_";
+    public static final String DISTRIBUTION_LIST_MESSAGE_MEDIA_FILE_PREFIX = "distribution_list_message_media_";
+    public static final String DISTRIBUTION_LIST_MESSAGE_MEDIA_THUMBNAIL_FILE_PREFIX = "distribution_list_thumbnail_";
 
-	public static final String GROUP_AVATAR_PREFIX = "group_avatar_";
-	public static final String DISTRIBUTION_LISTS_FILE_NAME = "distribution_list";
-	public static final String BALLOT_FILE_NAME = "ballot";
-	public static final String BALLOT_CHOICE_FILE_NAME = "ballot_choice";
-	public static final String BALLOT_VOTE_FILE_NAME = "ballot_vote";
-	public static final String CSV_FILE_POSTFIX = ".csv";
+    public static final String GROUP_AVATAR_PREFIX = "group_avatar_";
+    public static final String DISTRIBUTION_LISTS_FILE_NAME = "distribution_list";
+    public static final String BALLOT_FILE_NAME = "ballot";
+    public static final String BALLOT_CHOICE_FILE_NAME = "ballot_choice";
+    public static final String BALLOT_VOTE_FILE_NAME = "ballot_vote";
+    public static final String CSV_FILE_POSTFIX = ".csv";
 
-	public static final String TAG_INFO_VERSION = "version";
+    public static final String TAG_INFO_VERSION = "version";
 
-	public static final String TAG_NONCES = "nonces";
-	public static final String TAG_NONCE_COUNT_CSP = "csp";
-	public static final String TAG_NONCE_COUNT_D2D = "d2d";
+    public static final String TAG_NONCES = "nonces";
+    public static final String TAG_NONCE_COUNT_CSP = "csp";
+    public static final String TAG_NONCE_COUNT_D2D = "d2d";
 
-	public static final String TAG_CONTACT_IDENTITY = "identity";
-	public static final String TAG_CONTACT_FIRST_NAME = "firstname";
-	public static final String TAG_CONTACT_LAST_NAME = "lastname";
-	public static final String TAG_CONTACT_PUBLIC_KEY = "publickey";
-	public static final String TAG_CONTACT_NICK_NAME = "nick_name";
-	public static final String TAG_CONTACT_VERIFICATION_LEVEL = "verification";
-	public static final String TAG_CONTACT_ANDROID_CONTACT_ID = "acid";
-	public static final String TAG_CONTACT_LAST_UPDATE = "last_update";
-	public static final String TAG_CONTACT_HIDDEN = "hidden";
-	public static final String TAG_CONTACT_ARCHIVED = "archived";
-	public static final String TAG_CONTACT_IDENTITY_ID = "identity_id"; // a unique ID representing the identity of a contact
+    public static final String TAG_CONTACT_IDENTITY = "identity";
+    public static final String TAG_CONTACT_FIRST_NAME = "firstname";
+    public static final String TAG_CONTACT_LAST_NAME = "lastname";
+    public static final String TAG_CONTACT_PUBLIC_KEY = "publickey";
+    public static final String TAG_CONTACT_NICK_NAME = "nick_name";
+    public static final String TAG_CONTACT_VERIFICATION_LEVEL = "verification";
+    public static final String TAG_CONTACT_ANDROID_CONTACT_ID = "acid";
+    public static final String TAG_CONTACT_LAST_UPDATE = "last_update";
+    public static final String TAG_CONTACT_HIDDEN = "hidden";
+    public static final String TAG_CONTACT_ARCHIVED = "archived";
+    public static final String TAG_CONTACT_IDENTITY_ID = "identity_id"; // a unique ID representing the identity of a contact
 
-	public static final String TAG_GROUP_ID = "id";
-	public static final String TAG_GROUP_CREATOR = "creator";
-	public static final String TAG_GROUP_NAME = "groupname";
-	public static final String TAG_GROUP_CREATED_AT = "created_at";
-	public static final String TAG_GROUP_LAST_UPDATE = "last_update";
-	public static final String TAG_GROUP_MEMBERS = "members";
-	public static final String TAG_GROUP_DELETED = "deleted";
-	public static final String TAG_GROUP_ARCHIVED = "archived";
-	public static final String TAG_GROUP_DESC = "groupDesc";
-	public static final String TAG_GROUP_DESC_TIMESTAMP = "groupDescTimestamp";
-	public static final String TAG_GROUP_UID = "group_uid";
-	public static final String TAG_GROUP_USER_STATE = "user_state";
+    public static final String TAG_GROUP_ID = "id";
+    public static final String TAG_GROUP_CREATOR = "creator";
+    public static final String TAG_GROUP_NAME = "groupname";
+    public static final String TAG_GROUP_CREATED_AT = "created_at";
+    public static final String TAG_GROUP_LAST_UPDATE = "last_update";
+    public static final String TAG_GROUP_MEMBERS = "members";
+    public static final String TAG_GROUP_DELETED = "deleted";
+    public static final String TAG_GROUP_ARCHIVED = "archived";
+    public static final String TAG_GROUP_DESC = "groupDesc";
+    public static final String TAG_GROUP_DESC_TIMESTAMP = "groupDescTimestamp";
+    public static final String TAG_GROUP_UID = "group_uid";
+    public static final String TAG_GROUP_USER_STATE = "user_state";
 
-	public static final String TAG_MESSAGE_UID = "uid";
-	public static final String TAG_MESSAGE_IDENTITY = "identity";
-	public static final String TAG_MESSAGE_BODY = "body";
-	public static final String TAG_MESSAGE_TYPE = "type";
-	public static final String TAG_MESSAGE_POSTED_AT = "posted_at";
-	public static final String TAG_MESSAGE_API_MESSAGE_ID = "apiid";
-	public static final String TAG_MESSAGE_IS_OUTBOX = "isoutbox";
-	public static final String TAG_MESSAGE_IS_READ = "isread";
-	public static final String TAG_MESSAGE_IS_SAVED = "issaved";
-	public static final String TAG_MESSAGE_CREATED_AT = "created_at";
-	public static final String TAG_MESSAGE_MODIFIED_AT = "modified_at";
-	public static final String TAG_MESSAGE_DELIVERED_AT = "delivered_at";
-	public static final String TAG_MESSAGE_READ_AT = "read_at";
-	public static final String TAG_MESSAGE_EDITED_AT = "edited_at";
-	public static final String TAG_MESSAGE_DELETED_AT = "deleted_at";
-	public static final String TAG_GROUP_MESSAGE_STATES = "g_msg_states";
+    public static final String TAG_MESSAGE_UID = "uid";
+    public static final String TAG_MESSAGE_IDENTITY = "identity";
+    public static final String TAG_MESSAGE_BODY = "body";
+    public static final String TAG_MESSAGE_TYPE = "type";
+    public static final String TAG_MESSAGE_POSTED_AT = "posted_at";
+    public static final String TAG_MESSAGE_API_MESSAGE_ID = "apiid";
+    public static final String TAG_MESSAGE_IS_OUTBOX = "isoutbox";
+    public static final String TAG_MESSAGE_IS_READ = "isread";
+    public static final String TAG_MESSAGE_IS_SAVED = "issaved";
+    public static final String TAG_MESSAGE_CREATED_AT = "created_at";
+    public static final String TAG_MESSAGE_MODIFIED_AT = "modified_at";
+    public static final String TAG_MESSAGE_DELIVERED_AT = "delivered_at";
+    public static final String TAG_MESSAGE_READ_AT = "read_at";
+    public static final String TAG_MESSAGE_EDITED_AT = "edited_at";
+    public static final String TAG_MESSAGE_DELETED_AT = "deleted_at";
+    public static final String TAG_GROUP_MESSAGE_STATES = "g_msg_states";
 
-	public static final String TAG_MESSAGE_MESSAGE_STATE = "messagestae";
-	public static final String TAG_MESSAGE_IS_STATUS_MESSAGE = "isstatusmessage";
-	public static final String TAG_MESSAGE_IS_QUEUED = "isqueued";
-	public static final String TAG_MESSAGE_CAPTION = "caption";
-	public static final String TAG_MESSAGE_QUOTED_MESSAGE_ID = "quoted_message_apiid";
-	public static final String TAG_MESSAGE_DISPLAY_TAGS = "display_tags";
+    public static final String TAG_MESSAGE_MESSAGE_STATE = "messagestae";
+    public static final String TAG_MESSAGE_IS_STATUS_MESSAGE = "isstatusmessage";
+    public static final String TAG_MESSAGE_CAPTION = "caption";
+    public static final String TAG_MESSAGE_QUOTED_MESSAGE_ID = "quoted_message_apiid";
+    public static final String TAG_MESSAGE_DISPLAY_TAGS = "display_tags";
 
-	public static final String TAG_DISTRIBUTION_LIST_ID = "id";
-	public static final String TAG_DISTRIBUTION_LIST_NAME = "distribution_list_name";
-	public static final String TAG_DISTRIBUTION_CREATED_AT = "created_at";
-	public static final String TAG_DISTRIBUTION_LAST_UPDATE = "last_update";
-	public static final String TAG_DISTRIBUTION_MEMBERS = "distribution_members";
-	public static final String TAG_DISTRIBUTION_LIST_ARCHIVED = "archived";
+    public static final String TAG_DISTRIBUTION_LIST_ID = "id";
+    public static final String TAG_DISTRIBUTION_LIST_NAME = "distribution_list_name";
+    public static final String TAG_DISTRIBUTION_CREATED_AT = "created_at";
+    public static final String TAG_DISTRIBUTION_LAST_UPDATE = "last_update";
+    public static final String TAG_DISTRIBUTION_MEMBERS = "distribution_members";
+    public static final String TAG_DISTRIBUTION_LIST_ARCHIVED = "archived";
 
-	public static final String TAG_BALLOT_ID = "id";
-	public static final String TAG_BALLOT_API_ID = "aid";
-	public static final String TAG_BALLOT_API_CREATOR = "creator";
-	public static final String TAG_BALLOT_REF = "ref";
-	public static final String TAG_BALLOT_REF_ID = "ref_id";
-	public static final String TAG_BALLOT_NAME = "name";
-	public static final String TAG_BALLOT_STATE = "state";
-	public static final String TAG_BALLOT_ASSESSMENT = "assessment";
-	public static final String TAG_BALLOT_TYPE = "type";
-	public static final String TAG_BALLOT_C_TYPE = "choice_type";
-	public static final String TAG_BALLOT_LAST_VIEWED_AT = "last_viewed_at";
-	public static final String TAG_BALLOT_CREATED_AT = "created_at";
-	public static final String TAG_BALLOT_MODIFIED_AT = "modified_at";
+    public static final String TAG_BALLOT_ID = "id";
+    public static final String TAG_BALLOT_API_ID = "aid";
+    public static final String TAG_BALLOT_API_CREATOR = "creator";
+    public static final String TAG_BALLOT_REF = "ref";
+    public static final String TAG_BALLOT_REF_ID = "ref_id";
+    public static final String TAG_BALLOT_NAME = "name";
+    public static final String TAG_BALLOT_STATE = "state";
+    public static final String TAG_BALLOT_ASSESSMENT = "assessment";
+    public static final String TAG_BALLOT_TYPE = "type";
+    public static final String TAG_BALLOT_C_TYPE = "choice_type";
+    public static final String TAG_BALLOT_LAST_VIEWED_AT = "last_viewed_at";
+    public static final String TAG_BALLOT_CREATED_AT = "created_at";
+    public static final String TAG_BALLOT_MODIFIED_AT = "modified_at";
 
-	public static final String TAG_BALLOT_CHOICE_ID = "id";
-	public static final String TAG_BALLOT_CHOICE_BALLOT_UID = "ballot";
-	public static final String TAG_BALLOT_CHOICE_API_ID = "aid";
-	public static final String TAG_BALLOT_CHOICE_TYPE = "type";
-	public static final String TAG_BALLOT_CHOICE_NAME = "name";
-	public static final String TAG_BALLOT_CHOICE_VOTE_COUNT= "vote_count";
-	public static final String TAG_BALLOT_CHOICE_ORDER = "order";
-	public static final String TAG_BALLOT_CHOICE_CREATED_AT = "created_at";
-	public static final String TAG_BALLOT_CHOICE_MODIFIED_AT = "modified_at";
+    public static final String TAG_BALLOT_CHOICE_ID = "id";
+    public static final String TAG_BALLOT_CHOICE_BALLOT_UID = "ballot";
+    public static final String TAG_BALLOT_CHOICE_API_ID = "aid";
+    public static final String TAG_BALLOT_CHOICE_TYPE = "type";
+    public static final String TAG_BALLOT_CHOICE_NAME = "name";
+    public static final String TAG_BALLOT_CHOICE_VOTE_COUNT= "vote_count";
+    public static final String TAG_BALLOT_CHOICE_ORDER = "order";
+    public static final String TAG_BALLOT_CHOICE_CREATED_AT = "created_at";
+    public static final String TAG_BALLOT_CHOICE_MODIFIED_AT = "modified_at";
 
-	public static final String TAG_BALLOT_VOTE_ID = "id";
-	public static final String TAG_BALLOT_VOTE_BALLOT_UID = "ballot_uid";
-	public static final String TAG_BALLOT_VOTE_CHOICE_UID = "choice_uid";
-	public static final String TAG_BALLOT_VOTE_IDENTITY = "identity";
-	public static final String TAG_BALLOT_VOTE_CHOICE = "choice";
-	public static final String TAG_BALLOT_VOTE_CREATED_AT = "created_at";
-	public static final String TAG_BALLOT_VOTE_MODIFIED_AT = "modified_at";
+    public static final String TAG_BALLOT_VOTE_ID = "id";
+    public static final String TAG_BALLOT_VOTE_BALLOT_UID = "ballot_uid";
+    public static final String TAG_BALLOT_VOTE_CHOICE_UID = "choice_uid";
+    public static final String TAG_BALLOT_VOTE_IDENTITY = "identity";
+    public static final String TAG_BALLOT_VOTE_CHOICE = "choice";
+    public static final String TAG_BALLOT_VOTE_CREATED_AT = "created_at";
+    public static final String TAG_BALLOT_VOTE_MODIFIED_AT = "modified_at";
+
+    public static final String TAG_REACTION_CONTACT_IDENTITY = "identity";
+    public static final String TAG_REACTION_API_GROUP_ID = "api_group_id";
+    public static final String TAG_REACTION_GROUP_CREATOR_IDENTITY = "group_creator_identity";
+    public static final String TAG_REACTION_API_MESSAGE_ID = "api_message_id";
+    public static final String TAG_REACTION_SENDER_IDENTITY = "sender_identity";
+    public static final String TAG_REACTION_EMOJI_SEQUENCE = "emoji_sequence";
+    public static final String TAG_REACTION_REACTED_AT = "reacted_at";
+    public static final String TAG_REACTION_COUNT_CONTACTS = "contactReactions";
+    public static final String TAG_REACTION_COUNT_GROUPS = "groupReactions";
 }

+ 1 - 1
app/src/main/java/ch/threema/app/dialogs/RingtoneSelectorDialog.java

@@ -266,7 +266,7 @@ public class RingtoneSelectorDialog extends ThreemaDialogFragment {
 				extras.addRow(new String[]{CURSOR_DEFAULT_ID, defaultUriString});
 			}
 
-			if (showSilent && existingUri != null && existingUri.toString().equals("")) {
+			if (showSilent && existingUri != null && existingUri.toString().isEmpty()) {
 				// silent default
 				selectedIndex = 0;
 			} else {

+ 16 - 1
app/src/main/java/ch/threema/app/dialogs/SimpleStringAlertDialog.java

@@ -24,15 +24,19 @@ package ch.threema.app.dialogs;
 import android.app.Activity;
 import android.content.DialogInterface;
 import android.os.Bundle;
+import android.view.View;
+import android.widget.TextView;
 
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
 import androidx.appcompat.app.AppCompatDialog;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentTransaction;
 import ch.threema.app.R;
+import ch.threema.app.emojis.EmojiMarkupUtil;
 import ch.threema.app.utils.TestUtil;
 
 public class SimpleStringAlertDialog extends ThreemaDialogFragment {
@@ -112,8 +116,19 @@ public class SimpleStringAlertDialog extends ThreemaDialogFragment {
 		} else {
 			builder.setMessage(message);
 		}
+		AlertDialog alertDialog = builder.create();
+		alertDialog.setOnShowListener((dialog) -> applyEmojis(alertDialog));
+		return alertDialog;
+	}
 
-		return builder.create();
+	private void applyEmojis(AlertDialog alertDialog) {
+		View messageView = alertDialog.findViewById(android.R.id.message);
+		if (messageView instanceof TextView) {
+			TextView textView = (TextView) messageView;
+			textView.setText(
+					EmojiMarkupUtil.getInstance().addTextSpans(getContext(), textView.getText(), textView, true, true, false, false)
+			);
+		}
 	}
 
 	public SimpleStringAlertDialog setOnDismissRunnable(Runnable onDismissRunnable) {

+ 196 - 0
app/src/main/java/ch/threema/app/emojireactions/EmojiHintPopupManager.kt

@@ -0,0 +1,196 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2014-2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.emojireactions
+
+import android.app.Activity
+import android.content.Context
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.view.View
+import androidx.core.content.edit
+import androidx.core.view.children
+import androidx.lifecycle.LifecycleOwner
+import androidx.preference.PreferenceManager
+import ch.threema.app.R
+import ch.threema.app.ui.ConversationListView
+import ch.threema.app.ui.TooltipPopup
+import ch.threema.app.ui.getBottomCenterLocation
+import ch.threema.app.ui.getLocation
+import ch.threema.app.ui.getTopCenterLocation
+import ch.threema.app.ui.listitemholder.ComposeMessageHolder
+import kotlin.properties.Delegates
+
+class EmojiHintPopupManager(
+    private val appContext: Context,
+    private val getActivity: () -> Activity?,
+    private val getConversationListView: () -> ConversationListView?,
+) {
+    private val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(appContext)
+
+    private var shownCounter: Int
+        get() = sharedPreferences.getInt(
+            appContext.getString(R.string.preferences__tooltip_emoji_reactions_shown_counter),
+            0,
+        )
+        set(value) {
+            sharedPreferences.edit {
+                putInt(
+                    appContext.getString(R.string.preferences__tooltip_emoji_reactions_shown_counter),
+                    value,
+                )
+            }
+        }
+    private var shouldShowPopup = shownCounter < MAX_TIMES_SHOWN
+
+    private val handler = Handler(Looper.getMainLooper())
+    private val showPopupRunnable = Runnable(::showPopup)
+    private var tooltipPopup: TooltipPopup? = null
+
+    var isScrolling: Boolean by Delegates.observable(false) { _, _, _ ->
+        showOrDismissIfNecessary()
+    }
+
+    fun showOrDismissIfNecessary() {
+        if (isScrolling || !shouldShowPopup) {
+            dismiss()
+        } else {
+            handler.removeCallbacks(showPopupRunnable)
+            handler.postDelayed(showPopupRunnable, POPUP_DELAY)
+        }
+    }
+
+    private fun dismiss() {
+        handler.removeCallbacksAndMessages(null)
+        tooltipPopup?.dismiss(true)
+        tooltipPopup = null
+    }
+
+    private fun showPopup() {
+        val messageHolder = findSuitableMessageHolder()
+            ?: return
+        val emojiButton = messageHolder.emojiReactionGroup?.firstReactionButton
+            ?: return
+
+        val activity = getActivity() ?: return
+
+        val screenHeight: Int
+        val screenWidth: Int
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+            val windowBounds = activity.windowManager.currentWindowMetrics.bounds
+            screenWidth = windowBounds.right
+            screenHeight = windowBounds.bottom
+        } else {
+            screenWidth = activity.resources.displayMetrics.widthPixels
+            screenHeight = activity.resources.displayMetrics.heightPixels
+        }
+
+        val alignment: TooltipPopup.Alignment
+        var location = emojiButton.getBottomCenterLocation()
+        val verticalRatio = location[1] / screenHeight.toFloat()
+        if (verticalRatio !in VALID_VERTICAL_SCREEN_RANGE) {
+            // don't display the popup if it's too close to (or beyond) the top or bottom
+            // edge of the screen, as there might not be enough space to show it, or the
+            // anchor view itself might not even be visible
+            return
+        }
+
+        if (location[1] > screenHeight / 2) {
+            location = emojiButton.getTopCenterLocation()
+            alignment = if (location[0] > screenWidth / 2) {
+                TooltipPopup.Alignment.ABOVE_ANCHOR_ARROW_RIGHT
+            } else {
+                TooltipPopup.Alignment.ABOVE_ANCHOR_ARROW_LEFT
+            }
+        } else {
+            alignment = if (location[0] > screenWidth / 2) {
+                TooltipPopup.Alignment.BELOW_ANCHOR_ARROW_RIGHT
+            } else {
+                TooltipPopup.Alignment.BELOW_ANCHOR_ARROW_LEFT
+            }
+        }
+
+        val tooltipPopup = TooltipPopup(
+            context = activity,
+            preferenceKey = R.string.preferences__tooltip_emoji_reactions_shown,
+            lifecycleOwner = activity as? LifecycleOwner,
+        )
+        tooltipPopup.listener = object : TooltipPopup.TooltipPopupListener() {
+            override fun onClicked(tooltipPopup: TooltipPopup) {
+                tooltipPopup.dismiss()
+                shownCounter++
+            }
+
+            override fun onTimedOut(tooltipPopup: TooltipPopup) {
+                shownCounter++
+            }
+
+            override fun onShown(tooltipPopup: TooltipPopup) {
+                pollAnchorLocation(emojiButton, emojiButton.getLocation())
+            }
+        }
+
+        tooltipPopup.show(
+            activity = activity,
+            anchor = emojiButton,
+            title = activity.getString(R.string.emoji_reactions_popup_hint_title),
+            text = activity.getString(R.string.emoji_reactions_popup_hint_text),
+            alignment = alignment,
+            originLocation = location,
+            timeoutMs = POPUP_TIMEOUT,
+        )
+
+        this.tooltipPopup = tooltipPopup
+        shouldShowPopup = false
+    }
+
+    private fun findSuitableMessageHolder(): ComposeMessageHolder? =
+        getConversationListView()
+            ?.children
+            ?.mapNotNull { view ->
+                (view.tag as? ComposeMessageHolder)
+            }
+            ?.firstOrNull { messageHolder ->
+                messageHolder.emojiReactionGroup?.firstReactionButton != null
+            }
+
+    private fun pollAnchorLocation(view: View, originalLocation: IntArray) {
+        val newLocation = view.getLocation()
+        if (!newLocation.contentEquals(originalLocation)) {
+            dismiss()
+        } else {
+            handler.postDelayed({ pollAnchorLocation(view, originalLocation) }, ANCHOR_MOVEMENT_POLL_INTERVAL)
+        }
+    }
+
+    fun onDestroy() {
+        dismiss()
+    }
+
+    companion object {
+        private const val POPUP_DELAY = 1000L
+        private const val ANCHOR_MOVEMENT_POLL_INTERVAL = 750L
+        private const val POPUP_TIMEOUT = 8000
+        private const val MAX_TIMES_SHOWN = 2
+        private val VALID_VERTICAL_SCREEN_RANGE = 0.05..0.98
+    }
+}

+ 12 - 4
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionGroup.kt

@@ -53,6 +53,8 @@ class EmojiReactionGroup : LinearLayoutCompat, OnEmojiReactionButtonClickListene
     var onEmojiReactionGroupClickListener: OnEmojiReactionGroupClickListener? = null
     val userService = ThreemaApplication.requireServiceManager().userService
     var reactions: List<EmojiReactionData> = ArrayList()
+    var firstReactionButton: View? = null
+        private set
     private var listViewWidth = -1
     private var messageBubbleWidth = -1
     private val buttonWidth = resources.getDimensionPixelSize(R.dimen.emojireactions_width)
@@ -143,6 +145,7 @@ class EmojiReactionGroup : LinearLayoutCompat, OnEmojiReactionButtonClickListene
 
                 removeAllViewsInLayout()
 
+                var firstButton: View? = null
                 var usedWidth = 0
                 for ((index, buttonInfo) in newButtonInfoList.withIndex()) {
                     val emojiReactionButton = createEmojiReactionButton(buttonInfo)
@@ -157,14 +160,19 @@ class EmojiReactionGroup : LinearLayoutCompat, OnEmojiReactionButtonClickListene
                         break
                     }
 
+                    if (firstButton == null) {
+                        firstButton = emojiReactionButton
+                    }
+
                     emojiReactionButton.onEmojiReactionButtonClickListener = this
                     addViewInLayout(emojiReactionButton, -1, emojiReactionButton.layoutParams)
                 }
+                firstReactionButton = firstButton
 
-                messageReceiver?.
-                    takeIf { it.emojiReactionSupport != MessageReceiver.Reactions_NONE }?.
-                    takeIf { newButtonInfoList.isNotEmpty() }?.
-                    let {
+                messageReceiver
+                    ?.takeIf { it.emojiReactionSupport != MessageReceiver.Reactions_NONE }
+                    ?.takeIf { newButtonInfoList.isNotEmpty() }
+                    ?.let {
                         createAndAddSelectEmojiButton()
                     }
 

+ 5 - 6
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsOverviewActivity.kt

@@ -37,6 +37,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
 import androidx.core.content.ContextCompat
 import androidx.core.view.WindowCompat
 import androidx.core.view.children
+import androidx.core.view.isVisible
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.repeatOnLifecycle
@@ -176,13 +177,11 @@ class EmojiReactionsOverviewActivity : ThreemaToolbarActivity() {
      * Show an infobox if there are new-style emoji reactions but we can't send them (V1)
      */
     private fun setupInfoBox() {
-        if (!ConfigUtils.canSendEmojiReactions() && items.isNotEmpty()) {
-
-            val count = items.filter { item ->
-                item.emojiSequence != EmojiUtil.THUMBS_UP_SEQUENCE && item.emojiSequence != EmojiUtil.THUMBS_DOWN_SEQUENCE
+        if (!ConfigUtils.canSendEmojiReactions()) {
+            infoBox.isVisible = items.any { item ->
+                item.emojiSequence != EmojiUtil.THUMBS_UP_SEQUENCE &&
+                        item.emojiSequence != EmojiUtil.THUMBS_DOWN_SEQUENCE
             }
-
-            infoBox.visibility = if (count.isEmpty()) View.GONE else View.VISIBLE
         }
     }
 

+ 18 - 15
app/src/main/java/ch/threema/app/emojireactions/EmojiReactionsPopup.kt

@@ -30,7 +30,9 @@ import android.view.ViewTreeObserver.OnGlobalLayoutListener
 import android.widget.FrameLayout
 import android.widget.ImageView
 import android.widget.PopupWindow
+import androidx.annotation.StringRes
 import androidx.core.content.res.ResourcesCompat
+import androidx.core.view.isVisible
 import androidx.fragment.app.FragmentManager
 import ch.threema.app.R
 import ch.threema.app.ThreemaApplication
@@ -54,7 +56,8 @@ class EmojiReactionsPopup(
     val context: Context,
     private val parentView: View,
     val fragmentManager: FragmentManager,
-    private val isSendingReactionsAllowed: Boolean
+    private val isSendingReactionsAllowed: Boolean,
+    private val shouldHideUnsupportedReactions: Boolean,
 ) :
     PopupWindow(context), View.OnClickListener {
 
@@ -92,12 +95,12 @@ class EmojiReactionsPopup(
         addReactionButton.tag = topReactions.size
         addReactionButton.setOnClickListener(this)
         if (!isSendingReactionsAllowed) {
-            if (ConfigUtils.canSendEmojiReactions()) {
+            if (ConfigUtils.canSendEmojiReactions() && !shouldHideUnsupportedReactions) {
                 // V2 clients: display implausible buttons as disabled but still clickable
                 addReactionButton.alpha = FAKE_DISABLE_ALPHA
             } else {
-                // V1 clients: do not display implausible buttons
-                addReactionButton.visibility = View.GONE
+                // V1 clients, or gateway chat: do not display implausible buttons
+                addReactionButton.isVisible = false
             }
         }
 
@@ -114,7 +117,7 @@ class EmojiReactionsPopup(
     }
 
     private fun setupTopReactions() {
-        for ((index, topReaction) in topReactions.withIndex()) {
+        topReactions.forEachIndexed { index, topReaction ->
             val emojiItemView: EmojiItemView = contentView.findViewById(topReaction.resourceId)
 
             applyEmojiDiversity(topReaction)
@@ -122,13 +125,13 @@ class EmojiReactionsPopup(
             emojiItemView.setOnClickListener(this)
             emojiItemView.setEmoji(topReaction.emojiSequence, false, 0)
 
-            if (isDisabledButton(index)) {
-                if (ConfigUtils.canSendEmojiReactions()) {
+            if (isDisabledOrHiddenButton(index)) {
+                if (ConfigUtils.canSendEmojiReactions() && !shouldHideUnsupportedReactions) {
                     // V2 clients: display implausible buttons as disabled but still clickable
                     emojiItemView.alpha = FAKE_DISABLE_ALPHA
                 } else {
                     // V1 clients: do not display implausible buttons
-                    emojiItemView.visibility = View.GONE
+                    emojiItemView.isVisible = false
                 }
             }
 
@@ -189,9 +192,9 @@ class EmojiReactionsPopup(
                         } else {
                             topReaction.emojiItemView?.setBackgroundColor(backgroundColor)
                         }
-                        AnimationUtil.bubbleAnimate(addReactionButton, animationDelay)
                     }
                 }
+                AnimationUtil.bubbleAnimate(addReactionButton, animationDelay)
             }
         })
     }
@@ -204,11 +207,11 @@ class EmojiReactionsPopup(
     }
 
     /**
-     * Check if the button with the supplied index should be fake-disabled
+     * Check if the button with the supplied index is unsupported and
+     * should therefore be fake-disabled or hidden
      */
-    private fun isDisabledButton(index: Int): Boolean {
-        return !isSendingReactionsAllowed && index >= 2
-    }
+    private fun isDisabledOrHiddenButton(index: Int): Boolean =
+        !isSendingReactionsAllowed && index >= 2
 
     override fun dismiss() {
         if (isDismissing) {
@@ -227,7 +230,7 @@ class EmojiReactionsPopup(
     override fun onClick(v: View) {
         emojiReactionsPopupListener?.let { listener ->
             this.messageModel?.let { message ->
-                if (isDisabledButton(v.tag as Int)) {
+                if (isDisabledOrHiddenButton(v.tag as Int)) {
                     onDisabledButtonClicked()
                     return
                 } else {
@@ -283,7 +286,7 @@ class EmojiReactionsPopup(
         )?.show(fragmentManager, "imp")
     }
 
-    private fun createAlertDialogIfBodySet(titleResId: Int, body: String?): SimpleStringAlertDialog? {
+    private fun createAlertDialogIfBodySet(@StringRes titleResId: Int, body: String?): SimpleStringAlertDialog? {
         return body?.let {
             SimpleStringAlertDialog.newInstance(titleResId, it)
         }

+ 1 - 1
app/src/main/java/ch/threema/app/emojis/EmojiImageSpan.java

@@ -38,7 +38,7 @@ public class EmojiImageSpan extends ImageSpan {
 		this.scale = scale;
 
 		fm = tv.getPaint().getFontMetricsInt();
-		size = fm != null ? (Math.abs(fm.descent) + Math.abs(fm.ascent)) * scale: 64 * scale;
+		size = fm != null ? (Math.abs(fm.descent) + Math.abs(fm.ascent)) * scale : 64 * scale;
 		getDrawable().setBounds(0, 0, size, size);
 	}
 

+ 19 - 8
app/src/main/java/ch/threema/app/emojis/EmojiMarkupUtil.java

@@ -39,6 +39,7 @@ import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.services.ContactService;
@@ -46,6 +47,7 @@ import ch.threema.app.services.UserService;
 import ch.threema.app.ui.MentionClickableSpan;
 import ch.threema.app.ui.MentionSpan;
 import ch.threema.app.utils.ConfigUtils;
+import ch.threema.app.utils.LazyProperty;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.TestUtil;
 
@@ -57,14 +59,12 @@ public class EmojiMarkupUtil {
 	protected static final String MENTION_REGEX = MENTION_INDICATOR + "\\[[0-9A-Z*@]{8}\\]";
 	private final Pattern mention;
 
-	// Singleton stuff
-	private static EmojiMarkupUtil sInstance = null;
+	@NonNull
+	private static final LazyProperty<EmojiMarkupUtil> instance = new LazyProperty<>(EmojiMarkupUtil::new);
 
-	public static synchronized EmojiMarkupUtil getInstance() {
-		if (sInstance == null) {
-			sInstance = new EmojiMarkupUtil();
-		}
-		return sInstance;
+	@NonNull
+	public static EmojiMarkupUtil getInstance() {
+		return instance.get();
 	}
 
 	private EmojiMarkupUtil() {
@@ -119,7 +119,18 @@ public class EmojiMarkupUtil {
 	 * @return CharSequence with spans applied, if any
 	 */
     @NonNull
-	public CharSequence addTextSpans(Context context, CharSequence text, TextView textView, boolean ignoreMarkup, boolean ignoreMentions, boolean singleScale, boolean overrideEmojiStyleSetting) {
+	public CharSequence addTextSpans(
+			@Nullable
+			Context context,
+			@Nullable
+			CharSequence text,
+			@Nullable
+			TextView textView,
+			boolean ignoreMarkup,
+			boolean ignoreMentions,
+			boolean singleScale,
+			boolean overrideEmojiStyleSetting
+	) {
 		if (text == null) {
 			return "";
 		}

+ 3 - 11
app/src/main/java/ch/threema/app/emojis/EmojiTextView.java

@@ -31,8 +31,6 @@ import androidx.appcompat.widget.AppCompatTextView;
 
 public class EmojiTextView extends AppCompatTextView {
 
-	protected final EmojiMarkupUtil emojiMarkupUtil;
-
 	public EmojiTextView(Context context) {
 		this(context, null);
 	}
@@ -43,17 +41,11 @@ public class EmojiTextView extends AppCompatTextView {
 
 	public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) {
 		super(context, attrs, defStyleAttr);
-
-		emojiMarkupUtil = EmojiMarkupUtil.getInstance();
 	}
 
 	@Override
 	public void setText(@Nullable CharSequence text, BufferType type) {
-		if (emojiMarkupUtil != null) {
-			super.setText(emojiMarkupUtil.addTextSpans(getContext(), text, this, true, true, false, true), type);
-		} else {
-			super.setText(text, type);
-		}
+		super.setText(EmojiMarkupUtil.getInstance().addTextSpans(getContext(), text, this, true, true, false, true), type);
 	}
 
 	/**
@@ -64,8 +56,8 @@ public class EmojiTextView extends AppCompatTextView {
 	 * @return the text that was actually set
 	 */
 	public CharSequence setSingleEmojiSequence(@Nullable CharSequence emojiSequence) {
-		if (emojiMarkupUtil != null && EmojiUtil.isFullyQualifiedEmoji(emojiSequence)) {
-			CharSequence emojifiedSequence = emojiMarkupUtil.addTextSpans(getContext(), emojiSequence, this, true, true, false, true);
+		if (EmojiUtil.isFullyQualifiedEmoji(emojiSequence)) {
+			CharSequence emojifiedSequence = EmojiMarkupUtil.getInstance().addTextSpans(getContext(), emojiSequence, this, true, true, false, true);
             super.setText(emojifiedSequence, BufferType.SPANNABLE);
             return getText();
 		}

+ 14 - 14
app/src/main/java/ch/threema/app/emojis/EmojiUtil.java

@@ -37,29 +37,29 @@ public class EmojiUtil {
     private static final Logger logger = LoggingUtil.getThreemaLogger("EmojiUtil");
 
 	public static final String REPLACEMENT_CHARACTER = "\uFFFD";
-	public static final String THUMBS_UP_SEQUENCE = "\uD83D\uDC4D";
-	public static final String THUMBS_DOWN_SEQUENCE = "\uD83D\uDC4E";
+	public static final String THUMBS_UP_SEQUENCE = "\uD83D\uDC4D"; // 👍
+	public static final String THUMBS_DOWN_SEQUENCE = "\uD83D\uDC4E"; // 👎
 	public static final String HEART_SEQUENCE = "\u2764\uFE0F"; // ❤️
 	public static final String TEARS_OF_JOY_SEQUENCE = "\uD83D\uDE02"; // 😂
 	public static final String CRYING_SEQUENCE = "\uD83D\uDE22"; // 😢
 	public static final String FOLDED_HANDS_SEQUENCE = "\uD83D\uDE4F"; // 🙏
 
 	private static final Set<String> THUMBS_UP_VARIANTS = Set.of(
-		"\ud83d\udc4d",
-		"\ud83d\udc4d\ud83c\udffb",
-		"\ud83d\udc4d\ud83c\udffc",
-		"\ud83d\udc4d\ud83c\udffd",
-		"\ud83d\udc4d\ud83c\udffe",
-		"\ud83d\udc4d\ud83c\udfff"
+		"\ud83d\udc4d", // 👍
+		"\ud83d\udc4d\ud83c\udffb", // 👍🏻
+		"\ud83d\udc4d\ud83c\udffc", // 👍🏼
+		"\ud83d\udc4d\ud83c\udffd", // 👍🏽
+		"\ud83d\udc4d\ud83c\udffe", // 👍🏾
+		"\ud83d\udc4d\ud83c\udfff" // 👍🏿
 	);
 
 	private static final Set<String> THUMBS_DOWN_VARIANTS = Set.of(
-		"\ud83d\udc4e",
-		"\ud83d\udc4e\ud83c\udffb",
-		"\ud83d\udc4e\ud83c\udffc",
-		"\ud83d\udc4e\ud83c\udffd",
-		"\ud83d\udc4e\ud83c\udffe",
-		"\ud83d\udc4e\ud83c\udfff"
+		"\ud83d\udc4e", // 👎
+		"\ud83d\udc4e\ud83c\udffb", // 👎🏻
+		"\ud83d\udc4e\ud83c\udffc", // 👎🏼
+		"\ud83d\udc4e\ud83c\udffd", // 👎🏽
+		"\ud83d\udc4e\ud83c\udffe", // 👎🏾
+		"\ud83d\udc4e\ud83c\udfff" // 👎🏿
 	);
 
     private static WeakReference<Set<String>> FULLY_QUALIFIED_EMOJI = new WeakReference<>(null);

+ 331 - 334
app/src/main/java/ch/threema/app/fragments/BackupDataFragment.java

@@ -54,7 +54,6 @@ import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.DisableBatteryOptimizationsActivity;
 import ch.threema.app.backuprestore.BackupRestoreDataConfig;
-import ch.threema.app.backuprestore.BackupRestoreDataService;
 import ch.threema.app.backuprestore.csv.BackupService;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.PasswordEntryDialog;
@@ -68,337 +67,335 @@ import ch.threema.app.utils.TestUtil;
 import ch.threema.base.utils.LoggingUtil;
 
 public class BackupDataFragment extends Fragment implements
-		GenericAlertDialog.DialogClickListener,
-		PasswordEntryDialog.PasswordEntryDialogClickListener {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("BackupDataFragment");
-
-	public static final int REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS = 441;
-	private static final int REQUEST_CODE_DOCUMENT_TREE = 7222;
-
-	private static final int PERMISSION_REQUEST_STORAGE_DOBACKUP = 2;
-
-	private static final String DIALOG_TAG_ENERGY_SAVING_REMINDER = "esr";
-	private static final String DIALOG_TAG_DISABLE_ENERGY_SAVING = "des";
-	private static final String DIALOG_TAG_PASSWORD = "pwd";
-	private static final String DIALOG_TAG_PATH_INTRO = "pathintro";
-
-	private BackupRestoreDataService backupRestoreDataService;
-	private View fragmentView;
-	private ExtendedFloatingActionButton fab;
-	private FileService fileService;
-	private PreferenceService preferenceService;
-	private Uri backupUri;
-	private TextView pathTextView;
-	private NestedScrollView scrollView;
-	private MaterialButton pathChangeButton;
-
-	private boolean launchedFromFAB = false;
-
-	@Override
-	public void onDestroyView() {
-		this.fab.setOnClickListener(null);
-		this.fab = null;
-		this.scrollView.setOnScrollChangeListener((NestedScrollView.OnScrollChangeListener) null);
-		this.scrollView = null;
-		this.pathChangeButton.setOnClickListener(null);
-		this.pathChangeButton = null;
-		this.pathTextView = null;
-
-		fragmentView.findViewById(R.id.info).setOnClickListener(null);
-		fragmentView.findViewById(R.id.restore).setOnClickListener(null);
-
-		this.fragmentView = null;
-
-		super.onDestroyView();
-	}
-
-	@Override
-	public void onCreate(@Nullable Bundle savedInstanceState) {
-		super.onCreate(savedInstanceState);
-
-		setRetainInstance(true);
-
-		try {
-			ServiceManager serviceManager = ThreemaApplication.getServiceManager();
-			this.fileService = serviceManager.getFileService();
-			this.preferenceService = serviceManager.getPreferenceService();
-			this.backupRestoreDataService = serviceManager.getBackupRestoreDataService();
-		} catch (Exception e) {
-			logger.error("Exception", e);
-			getActivity().finish();
-		}
-
-		backupUri = fileService.getBackupUri();
-	}
-
-	@Nullable
-	@Override
-	public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
-		if (this.fragmentView == null) {
-			this.fragmentView = inflater.inflate(R.layout.fragment_backup_data, container, false);
-
-			fab = fragmentView.findViewById(R.id.floating);
-			fab.setOnClickListener(new View.OnClickListener() {
-				@Override
-				public void onClick(View v) {
-					launchedFromFAB = true;
-					initiateBackup();
-				}
-			});
-			fab.show();
-
-			scrollView = fragmentView.findViewById(R.id.scroll_parent);
-			scrollView.setOnScrollChangeListener(new NestedScrollView.OnScrollChangeListener() {
-				@Override
-				public void onScrollChange(NestedScrollView v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
-					if (v.getTop() == scrollY) {
-						fab.extend();
-					} else {
-						fab.shrink();
-					}
-				}
-			});
-
-			fragmentView.findViewById(R.id.info).setOnClickListener(v -> onInfoButtonClicked(v));
-			fragmentView.findViewById(R.id.restore).setOnClickListener(v -> onRestoreButtonClicked(v));
-
-			pathChangeButton = fragmentView.findViewById(R.id.backup_path_change_btn);
-			pathChangeButton.findViewById(R.id.backup_path_change_btn).setOnClickListener(new View.OnClickListener() {
-				@Override
-				public void onClick(View v) {
-					launchedFromFAB = false;
-					showPathSelectionIntro();
-				}
-			});
-
-			pathTextView = fragmentView.findViewById(R.id.backup_path);
-			pathTextView.setText(getDirectoryName(backupUri));
-		}
-
-		Date backupDate = preferenceService.getLastDataBackupDate();
-		if (backupDate != null) {
-			this.fragmentView.findViewById(R.id.last_backup_layout).setVisibility(View.VISIBLE);
-			this.fragmentView.findViewById(R.id.intro_layout).setVisibility(View.GONE);
-			((TextView) this.fragmentView.findViewById(R.id.last_backup_date)).setText(LocaleUtil.formatTimeStampStringAbsolute(getContext(), backupDate.getTime()));
-		} else {
-			this.fragmentView.findViewById(R.id.last_backup_layout).setVisibility(View.GONE);
-			this.fragmentView.findViewById(R.id.intro_layout).setVisibility(View.VISIBLE);
-		}
-
-		return this.fragmentView;
-	}
-
-	/**
-	 * Get the name of a directory from an Uri selected with Intent.ACTION_OPEN_DOCUMENT_TREE
-	 * @param directoryTreeUri Uri returned by ACTION_OPEN_DOCUMENT_TREE
-	 * @return Name of directory, a localized string "not set" if an empty Uri was provided, or the complete Uri as a String as a fallback
-	 */
-	private @NonNull String getDirectoryName(Uri directoryTreeUri) {
-		if (directoryTreeUri == null) {
-			return getString(R.string.not_set);
-		} else {
-			try {
-				DocumentFile documentFile = DocumentFile.fromTreeUri(getContext(), directoryTreeUri);
-				if (documentFile != null && documentFile.isDirectory()) {
-					String name = documentFile.getName();
-					if (!TestUtil.isEmptyOrNull(name)) {
-						return name;
-					}
-				}
-			} catch (Exception ignored) {}
-		}
-		return directoryTreeUri.toString();
-	}
-
-	private void showPathSelectionIntro() {
-		GenericAlertDialog dialog = GenericAlertDialog.newInstance(R.string.set_backup_path, R.string.set_backup_path_intro, R.string.ok, R.string.cancel);
-		dialog.setTargetFragment(this);
-		dialog.show(getFragmentManager(), DIALOG_TAG_PATH_INTRO);
-	}
-
-	@UiThread
-	private void launchDocumentTree() {
-		try {
-			Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
-			// undocumented APIs according to https://issuetracker.google.com/issues/72053350
-			i.putExtra("android.content.extra.SHOW_ADVANCED", true);
-			i.putExtra("android.content.extra.FANCY", true);
-			i.putExtra("android.content.extra.SHOW_FILESIZE", true);
-			startActivityForResult(i, REQUEST_CODE_DOCUMENT_TREE);
-		} catch (Exception e) {
-			Toast.makeText(getContext(), "Your device is missing an activity for Intent.ACTION_OPEN_DOCUMENT_TREE. Contact the manufacturer of the device.", Toast.LENGTH_SHORT).show();
-			logger.error("Broken device. No Activity for Intent.ACTION_OPEN_DOCUMENT_TREE", e);
-		}
-	}
-
-	private void initiateBackup() {
-		if (BackupService.isRunning()) {
-			//show toast
-			Toast.makeText(ThreemaApplication.getAppContext(), R.string.backup_in_progress, Toast.LENGTH_SHORT).show();
-		} else {
-			if (backupUri == null) {
-				showPathSelectionIntro();
-			} else {
-				DocumentFile documentFile = null;
-				try {
-					documentFile = DocumentFile.fromTreeUri(ThreemaApplication.getAppContext(), backupUri);
-				} catch (IllegalArgumentException e) {
-					logger.error("DocumentFile.fromTreeUri failed", e);
-				}
-				if (documentFile == null || !documentFile.exists()) {
-					showPathSelectionIntro();
-				} else {
-					checkBatteryOptimizations();
-				}
-			}
-		}
-	}
-
-	private void checkBatteryOptimizations() {
-		if (ConfigUtils.requestWriteStoragePermissions(getActivity(), this, PERMISSION_REQUEST_STORAGE_DOBACKUP)) {
-			Intent intent = new Intent(getActivity(), DisableBatteryOptimizationsActivity.class);
-			intent.putExtra(DisableBatteryOptimizationsActivity.EXTRA_NAME, getString(R.string.backup_data));
-			startActivityForResult(intent, REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS);
-		}
-	}
-
-	private void launchDataBackup(String password, boolean includeMedia) {
-		if (TestUtil.required(this.backupRestoreDataService, password)) {
-			final BackupRestoreDataConfig backupRestoreDataConfig = new BackupRestoreDataConfig(password);
-			backupRestoreDataConfig
-					.setBackupContactAndMessages(true)
-					.setBackupIdentity(true)
-					.setBackupAvatars(true)
-					.setBackupMedia(includeMedia)
-					.setBackupThumbnails(includeMedia)
-					.setBackupVideoAndFiles(includeMedia)
-					.setBackupNonces(true);
-
-			Intent intent = new Intent(getActivity(), BackupService.class);
-			intent.putExtra(BackupService.EXTRA_BACKUP_RESTORE_DATA_CONFIG, backupRestoreDataConfig);
-			ContextCompat.startForegroundService(getActivity(), intent);
-			Toast.makeText(getActivity(), R.string.backup_started, Toast.LENGTH_SHORT).show();
-			getActivity().finishAffinity();
-		}
-	}
-
-	private void doBackup() {
-		DialogFragment dialogFragment = PasswordEntryDialog.newInstance(
-				R.string.backup_data_new,
-				R.string.backup_data_password_msg,
-				R.string.password_hint,
-				R.string.ok,
-				R.string.cancel,
-				ThreemaApplication.MIN_PW_LENGTH_BACKUP,
-				ThreemaApplication.MAX_PW_LENGTH_BACKUP,
-				R.string.backup_password_again_summary,
-				0,
-				R.string.backup_data_media,
-				R.string.backup_data_media_confirm);
-		dialogFragment.setTargetFragment(this, 0);
-		dialogFragment.show(getFragmentManager(), DIALOG_TAG_PASSWORD);
-	}
-
-	@Override
-	public void onYes(String tag, String text, boolean isChecked, Object data) {
-		switch (tag) {
-			case DIALOG_TAG_PASSWORD:
-				launchDataBackup(text, isChecked);
-				break;
-		}
-	}
-
-	@Override
-	public void onNo(String tag) {}
-
-	@Override
-	public void onYes(String tag, Object data) {
-		switch (tag) {
-			case DIALOG_TAG_ENERGY_SAVING_REMINDER:
-				doBackup();
-				break;
-			case DIALOG_TAG_PATH_INTRO:
-				launchDocumentTree();
-				break;
-			default:
-				break;
-		}
-	}
-
-	@Override
-	public void onNo(String tag, Object data) {
-		switch (tag) {
-			case DIALOG_TAG_DISABLE_ENERGY_SAVING:
-				doBackup();
-				break;
-			case DIALOG_TAG_ENERGY_SAVING_REMINDER:
-				break;
-			default:
-				break;
-		}
-	}
-
-	@Override
-	public void onRequestPermissionsResult(int requestCode,
-	                                       @NonNull String permissions[], @NonNull int[] grantResults) {
-		switch (requestCode) {
-			case PERMISSION_REQUEST_STORAGE_DOBACKUP:
-				if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
-					checkBatteryOptimizations();
-				} else {
-					if (!shouldShowRequestPermissionRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
-						ConfigUtils.showPermissionRationale(getContext(), fragmentView, R.string.permission_storage_required);
-					}
-				}
-				break;
-		}
-	}
-
-	@UiThread
-	private void onInfoButtonClicked(View v) {
-		SimpleStringAlertDialog.newInstance(R.string.backup_data, R.string.data_backup_explain).show(getFragmentManager(), "tse");
-	}
-
-	private void onRestoreButtonClicked(View v) {
-		SimpleStringAlertDialog.newInstance(R.string.restore, R.string.restore_data_backup_explain).show(getFragmentManager(), "re");
-	}
-
-	@Override
-	public void onActivityResult(int requestCode, int resultCode, Intent data) {
-		super.onActivityResult(requestCode, resultCode, data);
-
-		switch (requestCode) {
-			case REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS:
-				GenericAlertDialog dialog;
-				dialog = GenericAlertDialog.newInstance(R.string.backup_data_title, R.string.restore_disable_energy_saving, R.string.ok, R.string.cancel);
-				dialog.setTargetFragment(this, 0);
-				dialog.show(getFragmentManager(), DIALOG_TAG_ENERGY_SAVING_REMINDER);
-				break;
-			case REQUEST_CODE_DOCUMENT_TREE:
-				if (resultCode == RESULT_OK) {
-					if (data != null) {
-						Uri treeUri = data.getData();
-						if (treeUri != null) {
-							// Persist access permissions.
-							final int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
-							try {
-								getContext().getContentResolver().takePersistableUriPermission(treeUri, takeFlags);
-							} catch (SecurityException e) {
-								logger.error("Exception", e);
-							}
-							backupUri = treeUri;
-							preferenceService.setDataBackupUri(treeUri);
-							pathTextView.setText(getDirectoryName(treeUri));
-
-							if (launchedFromFAB) {
-								checkBatteryOptimizations();
-							}
-
-							return;
-						}
-					}
-					Toast.makeText(getContext(), "Unable to set new path", Toast.LENGTH_LONG).show();
-				}
-		}
-	}
+    GenericAlertDialog.DialogClickListener,
+    PasswordEntryDialog.PasswordEntryDialogClickListener {
+    private static final Logger logger = LoggingUtil.getThreemaLogger("BackupDataFragment");
+
+    public static final int REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS = 441;
+    private static final int REQUEST_CODE_DOCUMENT_TREE = 7222;
+
+    private static final int PERMISSION_REQUEST_STORAGE_DOBACKUP = 2;
+
+    private static final String DIALOG_TAG_ENERGY_SAVING_REMINDER = "esr";
+    private static final String DIALOG_TAG_DISABLE_ENERGY_SAVING = "des";
+    private static final String DIALOG_TAG_PASSWORD = "pwd";
+    private static final String DIALOG_TAG_PATH_INTRO = "pathintro";
+
+    private View fragmentView;
+    private ExtendedFloatingActionButton fab;
+    private FileService fileService;
+    private PreferenceService preferenceService;
+    private Uri backupUri;
+    private TextView pathTextView;
+    private NestedScrollView scrollView;
+    private MaterialButton pathChangeButton;
+
+    private boolean launchedFromFAB = false;
+
+    @Override
+    public void onDestroyView() {
+        this.fab.setOnClickListener(null);
+        this.fab = null;
+        this.scrollView.setOnScrollChangeListener((NestedScrollView.OnScrollChangeListener) null);
+        this.scrollView = null;
+        this.pathChangeButton.setOnClickListener(null);
+        this.pathChangeButton = null;
+        this.pathTextView = null;
+
+        fragmentView.findViewById(R.id.info).setOnClickListener(null);
+        fragmentView.findViewById(R.id.restore).setOnClickListener(null);
+
+        this.fragmentView = null;
+
+        super.onDestroyView();
+    }
+
+    @Override
+    public void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        setRetainInstance(true);
+
+        try {
+            ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+            this.fileService = serviceManager.getFileService();
+            this.preferenceService = serviceManager.getPreferenceService();
+        } catch (Exception e) {
+            logger.error("Exception", e);
+            getActivity().finish();
+        }
+
+        backupUri = fileService.getBackupUri();
+    }
+
+    @Nullable
+    @Override
+    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+        if (this.fragmentView == null) {
+            this.fragmentView = inflater.inflate(R.layout.fragment_backup_data, container, false);
+
+            fab = fragmentView.findViewById(R.id.floating);
+            fab.setOnClickListener(new View.OnClickListener() {
+                @Override
+                public void onClick(View v) {
+                    launchedFromFAB = true;
+                    initiateBackup();
+                }
+            });
+            fab.show();
+
+            scrollView = fragmentView.findViewById(R.id.scroll_parent);
+            scrollView.setOnScrollChangeListener(new NestedScrollView.OnScrollChangeListener() {
+                @Override
+                public void onScrollChange(NestedScrollView v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
+                    if (v.getTop() == scrollY) {
+                        fab.extend();
+                    } else {
+                        fab.shrink();
+                    }
+                }
+            });
+
+            fragmentView.findViewById(R.id.info).setOnClickListener(v -> onInfoButtonClicked(v));
+            fragmentView.findViewById(R.id.restore).setOnClickListener(v -> onRestoreButtonClicked(v));
+
+            pathChangeButton = fragmentView.findViewById(R.id.backup_path_change_btn);
+            pathChangeButton.findViewById(R.id.backup_path_change_btn).setOnClickListener(new View.OnClickListener() {
+                @Override
+                public void onClick(View v) {
+                    launchedFromFAB = false;
+                    showPathSelectionIntro();
+                }
+            });
+
+            pathTextView = fragmentView.findViewById(R.id.backup_path);
+            pathTextView.setText(getDirectoryName(backupUri));
+        }
+
+        Date backupDate = preferenceService.getLastDataBackupDate();
+        if (backupDate != null) {
+            this.fragmentView.findViewById(R.id.last_backup_layout).setVisibility(View.VISIBLE);
+            this.fragmentView.findViewById(R.id.intro_layout).setVisibility(View.GONE);
+            ((TextView) this.fragmentView.findViewById(R.id.last_backup_date)).setText(LocaleUtil.formatTimeStampStringAbsolute(getContext(), backupDate.getTime()));
+        } else {
+            this.fragmentView.findViewById(R.id.last_backup_layout).setVisibility(View.GONE);
+            this.fragmentView.findViewById(R.id.intro_layout).setVisibility(View.VISIBLE);
+        }
+
+        return this.fragmentView;
+    }
+
+    /**
+     * Get the name of a directory from an Uri selected with Intent.ACTION_OPEN_DOCUMENT_TREE
+     * @param directoryTreeUri Uri returned by ACTION_OPEN_DOCUMENT_TREE
+     * @return Name of directory, a localized string "not set" if an empty Uri was provided, or the complete Uri as a String as a fallback
+     */
+    private @NonNull String getDirectoryName(Uri directoryTreeUri) {
+        if (directoryTreeUri == null) {
+            return getString(R.string.not_set);
+        } else {
+            try {
+                DocumentFile documentFile = DocumentFile.fromTreeUri(getContext(), directoryTreeUri);
+                if (documentFile != null && documentFile.isDirectory()) {
+                    String name = documentFile.getName();
+                    if (!TestUtil.isEmptyOrNull(name)) {
+                        return name;
+                    }
+                }
+            } catch (Exception ignored) {}
+        }
+        return directoryTreeUri.toString();
+    }
+
+    private void showPathSelectionIntro() {
+        GenericAlertDialog dialog = GenericAlertDialog.newInstance(R.string.set_backup_path, R.string.set_backup_path_intro, R.string.ok, R.string.cancel);
+        dialog.setTargetFragment(this);
+        dialog.show(getFragmentManager(), DIALOG_TAG_PATH_INTRO);
+    }
+
+    @UiThread
+    private void launchDocumentTree() {
+        try {
+            Intent i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+            // undocumented APIs according to https://issuetracker.google.com/issues/72053350
+            i.putExtra("android.content.extra.SHOW_ADVANCED", true);
+            i.putExtra("android.content.extra.FANCY", true);
+            i.putExtra("android.content.extra.SHOW_FILESIZE", true);
+            startActivityForResult(i, REQUEST_CODE_DOCUMENT_TREE);
+        } catch (Exception e) {
+            Toast.makeText(getContext(), "Your device is missing an activity for Intent.ACTION_OPEN_DOCUMENT_TREE. Contact the manufacturer of the device.", Toast.LENGTH_SHORT).show();
+            logger.error("Broken device. No Activity for Intent.ACTION_OPEN_DOCUMENT_TREE", e);
+        }
+    }
+
+    private void initiateBackup() {
+        if (BackupService.isRunning()) {
+            //show toast
+            Toast.makeText(ThreemaApplication.getAppContext(), R.string.backup_in_progress, Toast.LENGTH_SHORT).show();
+        } else {
+            if (backupUri == null) {
+                showPathSelectionIntro();
+            } else {
+                DocumentFile documentFile = null;
+                try {
+                    documentFile = DocumentFile.fromTreeUri(ThreemaApplication.getAppContext(), backupUri);
+                } catch (IllegalArgumentException e) {
+                    logger.error("DocumentFile.fromTreeUri failed", e);
+                }
+                if (documentFile == null || !documentFile.exists()) {
+                    showPathSelectionIntro();
+                } else {
+                    checkBatteryOptimizations();
+                }
+            }
+        }
+    }
+
+    private void checkBatteryOptimizations() {
+        if (ConfigUtils.requestWriteStoragePermissions(getActivity(), this, PERMISSION_REQUEST_STORAGE_DOBACKUP)) {
+            Intent intent = new Intent(getActivity(), DisableBatteryOptimizationsActivity.class);
+            intent.putExtra(DisableBatteryOptimizationsActivity.EXTRA_NAME, getString(R.string.backup_data));
+            startActivityForResult(intent, REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS);
+        }
+    }
+
+    private void launchDataBackup(@Nullable String password, boolean includeMedia) {
+        if (password != null) {
+            final BackupRestoreDataConfig backupRestoreDataConfig = new BackupRestoreDataConfig(password);
+            backupRestoreDataConfig
+                .setBackupContactAndMessages(true)
+                .setBackupIdentity(true)
+                .setBackupAvatars(true)
+                .setBackupMedia(includeMedia)
+                .setBackupThumbnails(includeMedia)
+                .setBackupNonces(true)
+                .setBackupReactions(true);
+
+            Intent intent = new Intent(getActivity(), BackupService.class);
+            intent.putExtra(BackupService.EXTRA_BACKUP_RESTORE_DATA_CONFIG, backupRestoreDataConfig);
+            ContextCompat.startForegroundService(getActivity(), intent);
+            Toast.makeText(getActivity(), R.string.backup_started, Toast.LENGTH_SHORT).show();
+            getActivity().finishAffinity();
+        }
+    }
+
+    private void doBackup() {
+        DialogFragment dialogFragment = PasswordEntryDialog.newInstance(
+            R.string.backup_data_new,
+            R.string.backup_data_password_msg,
+            R.string.password_hint,
+            R.string.ok,
+            R.string.cancel,
+            ThreemaApplication.MIN_PW_LENGTH_BACKUP,
+            ThreemaApplication.MAX_PW_LENGTH_BACKUP,
+            R.string.backup_password_again_summary,
+            0,
+            R.string.backup_data_media,
+            R.string.backup_data_media_confirm);
+        dialogFragment.setTargetFragment(this, 0);
+        dialogFragment.show(getFragmentManager(), DIALOG_TAG_PASSWORD);
+    }
+
+    @Override
+    public void onYes(String tag, String text, boolean isChecked, Object data) {
+        switch (tag) {
+            case DIALOG_TAG_PASSWORD:
+                launchDataBackup(text, isChecked);
+                break;
+        }
+    }
+
+    @Override
+    public void onNo(String tag) {}
+
+    @Override
+    public void onYes(String tag, Object data) {
+        switch (tag) {
+            case DIALOG_TAG_ENERGY_SAVING_REMINDER:
+                doBackup();
+                break;
+            case DIALOG_TAG_PATH_INTRO:
+                launchDocumentTree();
+                break;
+            default:
+                break;
+        }
+    }
+
+    @Override
+    public void onNo(String tag, Object data) {
+        switch (tag) {
+            case DIALOG_TAG_DISABLE_ENERGY_SAVING:
+                doBackup();
+                break;
+            case DIALOG_TAG_ENERGY_SAVING_REMINDER:
+                break;
+            default:
+                break;
+        }
+    }
+
+    @Override
+    public void onRequestPermissionsResult(int requestCode,
+                                           @NonNull String permissions[], @NonNull int[] grantResults) {
+        switch (requestCode) {
+            case PERMISSION_REQUEST_STORAGE_DOBACKUP:
+                if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+                    checkBatteryOptimizations();
+                } else {
+                    if (!shouldShowRequestPermissionRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+                        ConfigUtils.showPermissionRationale(getContext(), fragmentView, R.string.permission_storage_required);
+                    }
+                }
+                break;
+        }
+    }
+
+    @UiThread
+    private void onInfoButtonClicked(View v) {
+        SimpleStringAlertDialog.newInstance(R.string.backup_data, R.string.data_backup_explain).show(getFragmentManager(), "tse");
+    }
+
+    private void onRestoreButtonClicked(View v) {
+        SimpleStringAlertDialog.newInstance(R.string.restore, R.string.restore_data_backup_explain).show(getFragmentManager(), "re");
+    }
+
+    @Override
+    public void onActivityResult(int requestCode, int resultCode, Intent data) {
+        super.onActivityResult(requestCode, resultCode, data);
+
+        switch (requestCode) {
+            case REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS:
+                GenericAlertDialog dialog;
+                dialog = GenericAlertDialog.newInstance(R.string.backup_data_title, R.string.restore_disable_energy_saving, R.string.ok, R.string.cancel);
+                dialog.setTargetFragment(this, 0);
+                dialog.show(getFragmentManager(), DIALOG_TAG_ENERGY_SAVING_REMINDER);
+                break;
+            case REQUEST_CODE_DOCUMENT_TREE:
+                if (resultCode == RESULT_OK) {
+                    if (data != null) {
+                        Uri treeUri = data.getData();
+                        if (treeUri != null) {
+                            // Persist access permissions.
+                            final int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
+                            try {
+                                getContext().getContentResolver().takePersistableUriPermission(treeUri, takeFlags);
+                            } catch (SecurityException e) {
+                                logger.error("Exception", e);
+                            }
+                            backupUri = treeUri;
+                            preferenceService.setDataBackupUri(treeUri);
+                            pathTextView.setText(getDirectoryName(treeUri));
+
+                            if (launchedFromFAB) {
+                                checkBatteryOptimizations();
+                            }
+
+                            return;
+                        }
+                    }
+                    Toast.makeText(getContext(), "Unable to set new path", Toast.LENGTH_LONG).show();
+                }
+        }
+    }
 }

+ 30 - 3
app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java

@@ -167,6 +167,7 @@ import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.ResendGroupMessageDialog;
 import ch.threema.app.dialogs.SelectorDialog;
 import ch.threema.app.dialogs.SimpleStringAlertDialog;
+import ch.threema.app.emojireactions.EmojiHintPopupManager;
 import ch.threema.app.emojireactions.EmojiReactionsOverviewActivity;
 import ch.threema.app.emojireactions.EmojiReactionsPickerActivity;
 import ch.threema.app.emojireactions.EmojiReactionsPopup;
@@ -308,6 +309,7 @@ import static androidx.core.view.WindowInsetsAnimationCompat.Callback.DISPATCH_M
 import static ch.threema.app.ThreemaApplication.getAppContext;
 import static ch.threema.app.activities.HomeActivity.THREEMA_CHANNEL_IDENTITY;
 import static ch.threema.app.adapters.ComposeMessageAdapter.MIN_CONSTRAINT_LENGTH;
+import static ch.threema.app.messagereceiver.MessageReceiverExtensionsKt.isGatewayChat;
 import static ch.threema.app.preference.SettingsAdvancedOptionsFragment.THREEMA_SUPPORT_IDENTITY;
 import static ch.threema.app.services.messageplayer.MessagePlayer.SOURCE_AUDIORECORDER;
 import static ch.threema.app.services.messageplayer.MessagePlayer.SOURCE_LIFECYCLE;
@@ -520,6 +522,11 @@ public class ComposeMessageFragment extends Fragment implements
 	private OngoingCallNoticeView ongoingCallNotice;
 	private GroupCallObserver groupCallObserver;
 	private ScrollButtonManager scrollButtonManager;
+	private final EmojiHintPopupManager emojiHintPopupManager = new EmojiHintPopupManager(
+			getAppContext(),
+			() -> activity,
+			() -> convListView
+	);
 
 	@SuppressLint("SimpleDateFormat")
 	private final SimpleDateFormat dayFormatter = new SimpleDateFormat("yyyyMMdd");
@@ -1293,6 +1300,8 @@ public class ComposeMessageFragment extends Fragment implements
 			}
 		}
 
+		emojiHintPopupManager.showOrDismissIfNecessary();
+
 		return this.fragmentView;
 	}
 
@@ -1661,6 +1670,7 @@ public class ComposeMessageFragment extends Fragment implements
 			if (scrollButtonManager != null) {
 				scrollButtonManager.hideAllButtons();
 			}
+			emojiHintPopupManager.onDestroy();
 
 			dismissTooltipPopup(workTooltipPopup, true);
 			workTooltipPopup = null;
@@ -1757,6 +1767,9 @@ public class ComposeMessageFragment extends Fragment implements
 						scrollButtonManager.hideButton(ScrollButtonManager.TYPE_UP);
 					}
 				}
+				emojiHintPopupManager.setScrolling(
+						scrollState != AbsListView.OnScrollListener.SCROLL_STATE_IDLE
+				);
 			}
 
 			@Override
@@ -2235,8 +2248,14 @@ public class ComposeMessageFragment extends Fragment implements
 									location[0] += actionBarAvatarView.getWidth() / 2;
 									location[1] += actionBarAvatarView.getHeight();
 
-									workTooltipPopup = new TooltipPopup(getActivity(), R.string.preferences__tooltip_work_hint_shown, this, new Intent(getActivity(), WorkExplainActivity.class), R.drawable.ic_badge_work_24dp);
-									workTooltipPopup.show(getActivity(), actionBarAvatarView, getString(R.string.tooltip_work_hint), TooltipPopup.ALIGN_BELOW_ANCHOR_ARROW_LEFT, location, 4000);
+									workTooltipPopup = new TooltipPopup(getActivity(), R.string.preferences__tooltip_work_hint_shown, this, R.drawable.ic_badge_work_24dp);
+									workTooltipPopup.setListener(new TooltipPopup.TooltipPopupListener() {
+										@Override
+										public void onClicked(@NonNull TooltipPopup tooltipPopup) {
+											startActivity(new Intent(getActivity(), WorkExplainActivity.class));
+										}
+									});
+									workTooltipPopup.show(getActivity(), actionBarAvatarView, null, getString(R.string.tooltip_work_hint), TooltipPopup.Alignment.BELOW_ANCHOR_ARROW_LEFT, location, 4000);
 								}
 							}, 1000);
 						}
@@ -3717,7 +3736,15 @@ public class ComposeMessageFragment extends Fragment implements
 			return;
 		}
 
-		emojiReactionsPopup = new EmojiReactionsPopup(requireContext(), convListView, getParentFragmentManager(), !isReactionsSupportNone);
+		boolean isGatewayChat = isGatewayChat(messageReceiver);
+		boolean isSendingReactionsAllowed = !isReactionsSupportNone && !isGatewayChat;
+		emojiReactionsPopup = new EmojiReactionsPopup(
+				requireContext(),
+				convListView,
+				getParentFragmentManager(),
+				isSendingReactionsAllowed,
+				isGatewayChat
+		);
 		emojiReactionsPopup.setListener(new EmojiReactionsPopup.EmojiReactionsPopupListener() {
 			@Override
 			public void onTopReactionClicked(@NonNull final AbstractMessageModel messageModel, @NonNull final String emojiSequence) {

+ 0 - 15
app/src/main/java/ch/threema/app/managers/ServiceManager.java

@@ -39,8 +39,6 @@ import ch.threema.app.BuildFlavor;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.backuprestore.BackupChatService;
 import ch.threema.app.backuprestore.BackupChatServiceImpl;
-import ch.threema.app.backuprestore.BackupRestoreDataService;
-import ch.threema.app.backuprestore.csv.BackupRestoreDataServiceImpl;
 import ch.threema.app.connection.CspD2mDualConnectionSupplier;
 import ch.threema.app.emojis.EmojiRecent;
 import ch.threema.app.emojis.EmojiService;
@@ -206,8 +204,6 @@ public class ServiceManager {
     @Nullable
     private LicenseService licenseService;
     @Nullable
-    private BackupRestoreDataService backupRestoreDataService;
-    @Nullable
     private GroupService groupService;
     @Nullable
     private GroupInviteService groupInviteService;
@@ -585,17 +581,6 @@ public class ServiceManager {
         return this.avatarCacheService;
     }
 
-    /**
-     * @return service to backup or restore data (conversations and contacts)
-     */
-    public @NonNull BackupRestoreDataService getBackupRestoreDataService() throws FileSystemNotPresentException {
-        if (this.backupRestoreDataService == null) {
-            this.backupRestoreDataService = new BackupRestoreDataServiceImpl(this.getFileService());
-        }
-
-        return this.backupRestoreDataService;
-    }
-
     @NonNull
     public LicenseService getLicenseService() throws FileSystemNotPresentException {
         if (this.licenseService == null) {

+ 31 - 0
app/src/main/java/ch/threema/app/messagereceiver/MessageReceiverExtensions.kt

@@ -0,0 +1,31 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.messagereceiver
+
+import ch.threema.app.utils.ContactUtil
+
+fun MessageReceiver<*>.isGatewayChat(): Boolean =
+    if (this is ContactMessageReceiver) {
+        ContactUtil.isGatewayContact(contact)
+    } else {
+        false
+    }

+ 4 - 1
app/src/main/java/ch/threema/app/notifications/NotificationChannels.kt

@@ -37,6 +37,7 @@ import androidx.core.app.NotificationChannelCompat
 import androidx.core.app.NotificationChannelGroupCompat
 import androidx.core.app.NotificationCompat
 import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.edit
 import androidx.preference.PreferenceManager
 import ch.threema.app.R
 import ch.threema.app.ThreemaApplication
@@ -137,7 +138,9 @@ object NotificationChannels {
                 logger.info("Upgrading notification channels and groups from version {} to {}", previousVersion, CHANNEL_SETUP_VERSION)
                 upgradeGroupsAndChannelsToVersion1(appContext, notificationManagerCompat)
             }
-            sharedPreferences.edit().putInt(appContext.getString(R.string.preferences__notification_channels_version), CHANNEL_SETUP_VERSION).apply()
+            sharedPreferences.edit {
+                putInt(appContext.getString(R.string.preferences__notification_channels_version), CHANNEL_SETUP_VERSION)
+            }
         }
 
         createOrRefreshChannelsAndGroups(appContext, notificationManagerCompat)

+ 4 - 1
app/src/main/java/ch/threema/app/preference/SettingsAppearanceFragment.kt

@@ -22,6 +22,7 @@
 package ch.threema.app.preference
 
 import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.content.edit
 import androidx.core.os.LocaleListCompat
 import androidx.preference.CheckBoxPreference
 import androidx.preference.DropDownPreference
@@ -108,7 +109,9 @@ class SettingsAppearanceFragment : ThreemaPreferenceFragment() {
                         DynamicColors.applyToActivitiesIfAvailable(requireActivity().application, dynamicColorsOptions)
 
                         // we need to set the new preference synchronously here because we exit the app before returning the result of this listener
-                        sharedPreferences?.edit()?.putBoolean(getString(R.string.preferences__dynamic_color), newCheckedValue)?.commit()
+                        sharedPreferences?.edit(commit = true) {
+                            putBoolean(getString(R.string.preferences__dynamic_color), newCheckedValue)
+                        }
 
                         ConfigUtils.recreateActivity(requireActivity())
                         Runtime.getRuntime().exit(0)

+ 53 - 8
app/src/main/java/ch/threema/app/preference/SettingsDeveloperFragment.java

@@ -24,6 +24,7 @@ package ch.threema.app.preference;
 import android.annotation.SuppressLint;
 import android.app.Activity;
 import android.content.Intent;
+import android.content.SharedPreferences;
 import android.os.AsyncTask;
 import android.widget.Toast;
 
@@ -36,6 +37,7 @@ import androidx.annotation.UiThread;
 import androidx.annotation.WorkerThread;
 import androidx.preference.CheckBoxPreference;
 import androidx.preference.Preference;
+import androidx.preference.PreferenceManager;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -50,6 +52,7 @@ import ch.threema.app.exceptions.PolicyViolationException;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.messagereceiver.ContactMessageReceiver;
 import ch.threema.app.multidevice.MultiDeviceManager;
+import ch.threema.app.preference.developer.ContentCreator;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.PreferenceService;
@@ -93,6 +96,18 @@ public class SettingsDeveloperFragment extends ThreemaPreferenceFragment {
         initMdSetting();
         initConversationSetting();
 
+        // Reset reaction tooltip
+        getPref(getResources().getString(R.string.preferences__dev_reset_reaction_tooltip_shown))
+            .setOnPreferenceClickListener(this::resetReactionTooltipShown);
+
+        // Generate messages with reactions
+        final Preference generateReactionsPreference = getPref(R.string.preferences__dev_create_messages_with_reactions);
+        generateReactionsPreference.setOnPreferenceClickListener(this::generateReactionMessages);
+
+        // Generate nonces
+        final Preference generateCspNoncesPreference = getPref(R.string.preferences__dev_create_nonces);
+        generateCspNoncesPreference.setOnPreferenceClickListener(this::generateNonces);
+
         // Generate VoIP messages
         final Preference generateVoipPreference = getPref(getResources().getString(R.string.preferences__generate_voip_messages));
         generateVoipPreference.setSummary("Create the test identity " + TEST_IDENTITY_1
@@ -119,14 +134,26 @@ public class SettingsDeveloperFragment extends ThreemaPreferenceFragment {
     }
 
     @UiThread
-    private void showOk(CharSequence msg) {
-        Toast.makeText(this.getContext(), msg, Toast.LENGTH_LONG).show();
+    private void showToast(CharSequence msg) {
+        Toast.makeText(getContext(), msg, Toast.LENGTH_LONG).show();
     }
 
     @UiThread
     private void showError(Exception e) {
         logger.error("Exception", e);
-        Toast.makeText(this.getContext(), e.toString(), Toast.LENGTH_LONG).show();
+        showToast(e.toString());
+    }
+
+    private boolean resetReactionTooltipShown(Preference ignored) {
+        logger.info("Reset emoji reaction tooltip");
+        String key = getString(R.string.preferences__tooltip_emoji_reactions_shown);
+        SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext());
+        sharedPreferences.edit()
+            .putBoolean(getString(R.string.preferences__tooltip_emoji_reactions_shown), false)
+            .putInt(getString(R.string.preferences__tooltip_emoji_reactions_shown_counter), 0)
+            .apply();
+        showToast("Reaction tooltip reset");
+        return true;
     }
 
     @WorkerThread
@@ -161,6 +188,24 @@ public class SettingsDeveloperFragment extends ThreemaPreferenceFragment {
         }
     }
 
+    @UiThread
+    private boolean generateReactionMessages(Preference ignored) {
+        ContentCreator.createReactionSpam(
+            ThreemaApplication.requireServiceManager(),
+            getParentFragmentManager()
+        );
+        return true;
+    }
+
+    @UiThread
+    private boolean generateNonces(Preference ignored) {
+        ContentCreator.createNonces(
+            ThreemaApplication.requireServiceManager(),
+            getParentFragmentManager()
+        );
+        return true;
+    }
+
     @UiThread
     @SuppressLint("StaticFieldLeak")
     private boolean generateVoipMessages(Preference preference) {
@@ -218,7 +263,7 @@ public class SettingsDeveloperFragment extends ThreemaPreferenceFragment {
             @Override
             protected void onPostExecute(@Nullable Exception e) {
                 if (e == null) {
-                    showOk("Test messages created!");
+                    showToast("Test messages created!");
                 } else {
                     showError(e);
                 }
@@ -250,7 +295,7 @@ public class SettingsDeveloperFragment extends ThreemaPreferenceFragment {
                     messageRecursive.setToIdentity(userService.getIdentity());
                     messageRecursive.setDate(new Date());
                     messageRecursive.setMessageId(messageIdRecursive);
-                    messageRecursive.setText("> quote #" + messageIdRecursive.toString() + "\n\na quote that references itself");
+                    messageRecursive.setText("> quote #" + messageIdRecursive + "\n\na quote that references itself");
                     messageService.processIncomingContactMessage(messageRecursive);
 
                     // Create cross-chat quote
@@ -268,7 +313,7 @@ public class SettingsDeveloperFragment extends ThreemaPreferenceFragment {
                     messageChat1.setToIdentity(userService.getIdentity());
                     messageChat1.setDate(new Date());
                     messageChat1.setMessageId(messageIdCrossChat1);
-                    messageChat1.setText("> quote #" + messageIdCrossChat2.toString() + "\n\nOMG!");
+                    messageChat1.setText("> quote #" + messageIdCrossChat2 + "\n\nOMG!");
                     messageService.processIncomingContactMessage(messageChat1);
 
                     messageService.createStatusMessage("Done creating test quotes", receiver1);
@@ -282,7 +327,7 @@ public class SettingsDeveloperFragment extends ThreemaPreferenceFragment {
             @Override
             protected void onPostExecute(@Nullable Exception e) {
                 if (e == null) {
-                    showOk("Test quotes created!");
+                    showToast("Test quotes created!");
                 } else {
                     showError(e);
                 }
@@ -295,7 +340,7 @@ public class SettingsDeveloperFragment extends ThreemaPreferenceFragment {
     @SuppressLint("StaticFieldLeak")
     private boolean hideDeveloperMenu(Preference preference) {
         this.preferenceService.setShowDeveloperMenu(false);
-        this.showOk("Not everybody can be a craaazy developer!");
+        showToast("Not everybody can be a craaazy developer!");
         final Activity activity = this.getActivity();
         if (activity != null) {
             activity.finish();

+ 357 - 0
app/src/main/java/ch/threema/app/preference/developer/ContentCreator.kt

@@ -0,0 +1,357 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.preference.developer
+
+import androidx.annotation.AnyThread
+import androidx.annotation.WorkerThread
+import androidx.fragment.app.FragmentManager
+import ch.threema.app.R
+import ch.threema.app.dialogs.GenericAlertDialog
+import ch.threema.app.dialogs.GenericProgressDialog
+import ch.threema.app.managers.ServiceManager
+import ch.threema.base.crypto.HashedNonce
+import ch.threema.base.crypto.NonceFactory
+import ch.threema.base.crypto.NonceScope
+import ch.threema.base.utils.LoggingUtil
+import ch.threema.data.storage.DbEmojiReaction
+import ch.threema.domain.models.MessageId
+import ch.threema.storage.models.AbstractMessageModel
+import ch.threema.storage.models.ContactModel
+import ch.threema.storage.models.GroupMessageModel
+import ch.threema.storage.models.GroupModel
+import ch.threema.storage.models.MessageModel
+import ch.threema.storage.models.MessageState
+import ch.threema.storage.models.MessageType
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.util.Date
+import java.util.UUID
+import kotlin.random.Random
+import kotlin.random.nextInt
+
+private val logger = LoggingUtil.getThreemaLogger("ContentCreator")
+
+private const val AMOUNT_OF_NONCES = 50_000
+private const val SPAM_MESSAGES_PER_CONVERSATION = 1000
+
+/**
+ * Chats with names that start with this prefix will be used when messages are created.
+ * For groups this is the group name, for contacts the first name has to start with the prefix
+ */
+private const val SPAM_CHATS_PREFIX = "\uD83D\uDC7E" // 👾
+
+
+object ContentCreator {
+    @JvmStatic
+    @AnyThread
+    fun createReactionSpam(serviceManager: ServiceManager, fragmentManager: FragmentManager) {
+        CoroutineScope(Dispatchers.Default).launch {
+            val goOn = confirm(fragmentManager, "Create loads of messages with reactions and/or ACK/DEC for any contact/group whose name starts with '$SPAM_CHATS_PREFIX'?")
+            if (!goOn) {
+                return@launch
+            }
+            withGenericProgress(fragmentManager, "Creating reaction spam...") {
+                val contacts = serviceManager.contactService.all
+                    .filter { isSpamChat(it.firstName) }
+                createContactReactionSpam(contacts, serviceManager)
+
+                val groups = serviceManager.groupService.all
+                    .filter { isSpamChat(it.name) }
+                createGroupReactionSpam(groups, serviceManager)
+            }
+        }
+    }
+
+    private fun createGroupReactionSpam(
+        groups: List<GroupModel>,
+        serviceManager: ServiceManager
+    ) {
+        val reactions = mutableListOf<DbEmojiReaction>()
+
+        groups.forEach { groupModel ->
+            repeat(SPAM_MESSAGES_PER_CONVERSATION) {
+                logger.debug("Group spam message #{}", it)
+                reactions.addAll(createGroupReactionSpam(groupModel, serviceManager))
+            }
+        }
+
+        serviceManager.modelRepositories.emojiReaction.restoreGroupReactions { insertHandle ->
+            reactions.shuffled().forEach { insertHandle.insert(it) }
+        }
+    }
+
+    private fun createGroupReactionSpam(
+        groupModel: GroupModel,
+        serviceManager: ServiceManager,
+    ): List<DbEmojiReaction> {
+        logger.info("Create messages with reaction/ack/dec in group {}", groupModel.id)
+
+        val groupService = serviceManager.groupService
+        val userIdentity = serviceManager.userService.identity
+        val groupMessageModelFactory = serviceManager.databaseServiceNew.groupMessageModelFactory
+
+        val members = groupService.getGroupIdentities(groupModel).toList()
+
+        val reactionIdentities = mutableListOf<String>()
+        val groupMessageStates = mutableMapOf<String, Any>()
+
+        members.forEach { memberIdentity ->
+            if (Random.nextDouble() > 0.3) {
+                // add reactions
+                reactionIdentities.add(memberIdentity)
+            } else {
+                groupMessageStates[memberIdentity] = if (Random.nextBoolean()) {
+                    MessageState.USERACK.toString()
+                } else {
+                    MessageState.USERDEC.toString()
+                }
+            }
+        }
+
+        val reactions = createReactions(reactionIdentities)
+
+        val senderIdentity = members.random()
+        val message = createGroupMessage(
+            createGroupText(groupMessageStates, reactions),
+            senderIdentity,
+            userIdentity,
+            groupMessageStates,
+            groupModel
+        )
+
+        groupMessageModelFactory.create(message)
+        return reactions.toDbReactions(message.id)
+    }
+
+    private fun createGroupText(messageStates: Map<String, Any>, reactions: List<Pair<String, Set<String>>>): String {
+        val stateTexts = messageStates
+            .map { (identity, state) -> "@[$identity]: $state" }
+        val reactionTexts = reactions
+            .map { (identity, reactions) -> "@[$identity]: ${reactions.joinToString(", ")}" }
+        return (stateTexts + reactionTexts).joinToString("\n")
+    }
+
+    private fun createContactReactionSpam(
+        contacts: List<ContactModel>,
+        serviceManager: ServiceManager
+    ) {
+        val reactions = mutableListOf<DbEmojiReaction>()
+
+        contacts.forEach { contactModel ->
+            repeat(SPAM_MESSAGES_PER_CONVERSATION) {
+                logger.debug("Contact spam message #{}", it)
+                reactions.addAll(createContactReactionSpam(contactModel, serviceManager))
+            }
+        }
+
+        serviceManager.modelRepositories.emojiReaction.restoreContactReactions { insertHandle ->
+            reactions.shuffled().forEach { insertHandle.insert(it) }
+        }
+    }
+
+    private fun createContactReactionSpam(
+        contactModel: ContactModel,
+        serviceManager: ServiceManager
+    ): List<DbEmojiReaction> {
+        logger.info("Create ack/dec messages for contact {}", contactModel)
+
+        val userIdentity = serviceManager.userService.identity
+        val messageModelFactory = serviceManager.databaseServiceNew.messageModelFactory
+
+        val hasUserReactions = Random.nextBoolean()
+        val hasContactReactions = Random.nextBoolean()
+        val hasAckDec = (!hasContactReactions && !hasUserReactions)
+            || ((!hasContactReactions || !hasUserReactions) && Random.nextBoolean())
+
+        val state = if (!hasAckDec) {
+            null
+        } else if (Random.nextBoolean()) {
+            MessageState.USERACK
+        } else {
+            MessageState.USERDEC
+        }
+
+        val reactionsIdentities = mutableListOf<String>()
+
+        if (hasUserReactions) {
+            reactionsIdentities.add(userIdentity)
+        }
+        if (hasContactReactions) {
+            reactionsIdentities.add(contactModel.identity)
+        }
+
+        val reactions = createReactions(reactionsIdentities)
+
+        val message = createContactMessage(
+            createContactText(state, reactions),
+            isOutbox = Random.nextBoolean(),
+            state = state,
+            contactModel
+        )
+        messageModelFactory.create(message)
+        return reactions.toDbReactions(message.id)
+    }
+
+    private fun createContactText(state: MessageState?, reactions: List<Pair<String, Set<String>>>): String {
+        val stateText = state?.let { "State: $it" }
+        val reactionTexts = reactions
+            .map { (identity, reactions) -> "@[$identity]: ${reactions.joinToString(", ")}" }
+        return (listOfNotNull(stateText) + reactionTexts).joinToString("\n")
+    }
+
+    private fun List<Pair<String, Set<String>>>.toDbReactions(messageId: Int): List<DbEmojiReaction> {
+        return flatMap { (identity, reactions) ->
+            reactions.map{ reaction -> DbEmojiReaction(
+                messageId,
+                identity,
+                reaction,
+                Date()
+            ) }
+        }
+    }
+
+    private fun createReactions(identities: List<String>): List<Pair<String, Set<String>>> {
+        val availableReactions = getReactionSequences(identities.size * 3)
+        return identities.map { identity ->
+            val numberOfReactions = Random.nextInt(1 .. 3)
+            identity to availableReactions.shuffled().take(numberOfReactions).toSet()
+        }.filter { it.second.isNotEmpty() }
+    }
+
+    private fun isSpamChat(identifier: String?)
+        = identifier?.startsWith(SPAM_CHATS_PREFIX) == true
+
+    private fun createContactMessage(
+        text: String,
+        isOutbox: Boolean,
+        state: MessageState?,
+        contactModel: ContactModel,
+    ): MessageModel = MessageModel().apply {
+        identity = contactModel.identity
+        enrichTextMessage(text, isOutbox, state)
+    }
+
+    private fun createGroupMessage(
+        text: String,
+        senderIdentity: String,
+        userIdentity: String,
+        groupMessageStates: Map<String, Any>,
+        groupModel: GroupModel,
+    ): GroupMessageModel = GroupMessageModel().apply {
+        groupId = groupModel.id
+        identity = senderIdentity
+        this.groupMessageStates  = groupMessageStates.toMap()
+        enrichTextMessage(
+            text,
+            senderIdentity == userIdentity
+        )
+    }
+
+    private fun AbstractMessageModel.enrichTextMessage(text: String, isOutbox: Boolean, state: MessageState? = null) {
+        val theDate = Date()
+        uid = UUID.randomUUID().toString()
+        apiMessageId = MessageId().toString()
+        this.isOutbox = isOutbox
+        this.type = MessageType.TEXT
+        bodyAndQuotedMessageId = text
+        isRead = true
+        this.state = state ?: if (isOutbox) {
+            MessageState.DELIVERED
+        } else {
+            MessageState.READ
+        }
+        postedAt = theDate
+        createdAt = theDate
+        isSaved = true
+    }
+
+    private fun getReactionSequences(n: Int): List<String> = setOf(
+        "👍", "👎", "🪒", "🌛", "🧲", "🇹🇹", "🧽", "🧎🏻‍♀️", "🧏🏽‍♀️", "🧝🏻‍♂️",
+        "👩🏿‍🚒", "🏌️‍♂️", "👨🏻", "🤸‍♂️", "👩🏿‍🦰", "👨🏼‍🦼", "🕹️", "🍾", "🇨🇫", "🍫",
+        "🧀", "🍔", "🕵🏼‍♂️", "👨🏻‍🏫", "🤷🏻‍♀️", "🧯", "🩼", "✍🏾", "🦶🏻", "🏊🏻‍♀️",
+        "😔", "⌛", "👮🏿‍♂️", "☔", "🧎🏿‍➡️", "🕡", "👑", "🧖🏾", "🧑🏻‍🔬", "🐧",
+        "🧑🏾‍🎤", "🧑🏻‍🦲", "⛲", "👇🏻", "⛹🏼", "🌦️", "🙋🏾", "🦸🏼‍♂️", "👩🏻‍🎤", "🏊🏿",
+        "👮🏾‍♂️", "📵", "🧖🏻", "🇱🇹", "👨🏻‍❤️‍👨🏿", "👦🏼", "🚶🏽‍➡️", "🥏", "🏹", "🧑🏻‍🎨",
+        "🏄🏿", "🇦🇶", "🧑🏿‍🎄", "👩🏾‍🍳", "📳", "🫱🏼‍🫲🏽", "👨‍👧‍👦", "👩🏽‍❤️‍💋‍👩🏿", "🌐", "🫃🏾",
+        "💅🏿", "🤰🏻", "🧎🏽", "🏃🏿‍♂️", "👨🏼‍🚒", "🦇", "✈️", "👩🏽‍🤝‍👨🏿", "🐎", "🏒",
+        "👈🏾", "🇱🇺", "🫙", "🇸🇿", "🧍🏼‍♂️", "💁🏼‍♂️", "🧑🏿‍🔧", "👨🏽‍🍳", "🦵🏽", "🧙🏿‍♂️",
+        "🧙‍♀️", "💆🏾‍♀️", "↔️", "🧑🏿‍🦲", "🫴🏼", "🤚", "🫱🏼", "🏌🏾‍♂️", "🥦", "🤛🏻",
+        "\uD83E\uDEC6"
+    ).shuffled().take(n)
+
+    @JvmStatic
+    @AnyThread
+    fun createNonces(serviceManager: ServiceManager, fragmentManager: FragmentManager) {
+        CoroutineScope(Dispatchers.Default).launch {
+            val goOn = confirm(
+                fragmentManager,
+                "Generate $AMOUNT_OF_NONCES nonces for each scope ${NonceScope.CSP} and ${NonceScope.D2D}?"
+            )
+            if (!goOn) {
+                return@launch
+            }
+            withGenericProgress(fragmentManager, "Generate random nonces") {
+                createNonces(NonceScope.CSP, serviceManager.nonceFactory)
+                createNonces(NonceScope.D2D, serviceManager.nonceFactory)
+            }
+        }
+    }
+
+    @WorkerThread
+    private fun createNonces(scope: NonceScope, nonceFactory: NonceFactory) {
+        logger.info("Generate random nonces for scope {}", scope)
+        val nonces = (0 until AMOUNT_OF_NONCES).asSequence()
+            .map { nonceFactory.next(scope) }
+            // We skip hashing of nonces for faster generation of test data
+            .map { HashedNonce(it.bytes) }.toList()
+        val success = nonceFactory.insertHashedNonces(scope, nonces)
+        logger.info("Generate {} nonces success={}", nonces.size, success)
+    }
+
+    private suspend fun confirm(fragmentManager: FragmentManager, message: String): Boolean {
+        val dialog = GenericAlertDialog.newInstance("Continue?", message, R.string.ok, R.string.no)
+        val result = CompletableDeferred<Boolean>()
+        dialog.setCallback(object : GenericAlertDialog.DialogClickListener {
+            override fun onYes(tag: String?, data: Any?) {
+                result.complete(true)
+            }
+
+            override fun onNo(tag: String?, data: Any?) {
+                result.complete(false)
+            }
+        })
+        dialog.show(fragmentManager, "CONTENT_CREATOR_CONFIRM_DIALOG")
+        return result.await()
+    }
+
+    private fun withGenericProgress(fragmentManager: FragmentManager, message: String, block: () -> Unit) {
+        val dialog = GenericProgressDialog.newInstance(null, message)
+        dialog.show(fragmentManager, "CONTENT_CREATOR_PROGRESS_DIALOG")
+        try {
+            block()
+        } finally {
+            dialog.dismiss()
+        }
+    }
+}

+ 10 - 36
app/src/main/java/ch/threema/app/services/FileServiceImpl.java

@@ -101,6 +101,7 @@ import ch.threema.app.utils.IconUtil;
 import ch.threema.app.utils.LogUtil;
 import ch.threema.app.utils.MessageUtil;
 import ch.threema.app.utils.MimeUtil;
+import ch.threema.app.utils.RingtoneChecker;
 import ch.threema.app.utils.RingtoneUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.SecureDeleteUtil;
@@ -183,42 +184,15 @@ public class FileServiceImpl implements FileService {
 		}
 	}
 
-	/*
-	 * Check if current ringtone prefs point to a valid ringtone or if an update is needed
-	 */
-	private boolean needRingtonePreferencesUpdate(ContentResolver contentResolver) {
-		SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this.context);
-		String uriString = sharedPreferences.getString(this.context.getString(R.string.preferences__voip_ringtone), null);
-
-		// check if we need to update preferences to point to new file
-		if (TestUtil.isEmptyOrNull(uriString)) {
-			// silent ringtone -> OK
-			return false;
-		} else if (uriString.equals(ServicesConstants.PREFERENCES_NULL)) {
-			Uri oldUri = Uri.parse(uriString);
-			if (oldUri.toString().equals("content://settings/system/ringtone")) {
-				// default system ringtone -> OK
-				return false;
-			}
-
-			String[] projection = {MediaStore.MediaColumns.DATA, MediaStore.Audio.Media.IS_RINGTONE};
-			try (Cursor cursor = contentResolver.query(oldUri, projection, null, null, null)) {
-				if (cursor != null) {
-					if (cursor.moveToFirst()) {
-						String path = cursor.getString(0);
-						int isRingtone = cursor.getInt(1);
-						// if preferences point to a valid file -> OK
-						if (path != null && new File(path).exists() && isRingtone == 1) {
-							return false;
-						}
-					}
-				}
-			} catch (Exception e) {
-				// continue by resetting ringtone prefs
-			}
-		}
-		return true;
-	}
+    /*
+     * Check if current ringtone prefs point to a valid ringtone or if an update is needed
+     */
+    private boolean needRingtonePreferencesUpdate(ContentResolver contentResolver) {
+        SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this.context);
+        String uriString = sharedPreferences.getString(this.context.getString(R.string.preferences__voip_ringtone), null);
+        RingtoneChecker ringtoneChecker = new RingtoneChecker(contentResolver);
+        return !ringtoneChecker.isValidRingtoneUri(uriString);
+    }
 
 	@Deprecated
 	public File getBackupPath() {

+ 0 - 3
app/src/main/java/ch/threema/app/services/MessageServiceImpl.java

@@ -2765,9 +2765,6 @@ public class MessageServiceImpl implements MessageService {
 				AbstractMessageModel m = messageModels.get(n);
 
 				if(m != null) {
-					if (m.isDeleted()) {
-						continue;
-					}
 					if(m.isOutbox()) {
 						break;
 					}

+ 87 - 84
app/src/main/java/ch/threema/app/services/notification/NotificationServiceImpl.java

@@ -142,7 +142,7 @@ public class NotificationServiceImpl implements NotificationService {
     private final NotificationManagerCompat notificationManagerCompat;
     private final int pendingIntentFlags;
 
-    private final LinkedList<ConversationNotification> conversationNotifications = new LinkedList<>();
+    private final LinkedList<ConversationNotification> conversationNotificationsCache = new LinkedList<>();
     private MessageReceiver visibleConversationReceiver;
 
     @NonNull
@@ -341,7 +341,7 @@ public class NotificationServiceImpl implements NotificationService {
             return;
         }
 
-        synchronized (this.conversationNotifications) {
+        synchronized (this.conversationNotificationsCache) {
             //check if current receiver is the receiver of the group
             if (this.visibleConversationReceiver != null &&
                 conversationNotification.getGroup().messageReceiver.isEqual(this.visibleConversationReceiver)) {
@@ -350,67 +350,70 @@ public class NotificationServiceImpl implements NotificationService {
                 return;
             }
 
-            String uniqueId = null;
-            //check if notification not exist
-            if (
-                Functional.select(
-                    this.conversationNotifications,
-                    conversationNotification1 -> TestUtil.compare(conversationNotification1.getUid(), conversationNotification.getUid())
-                ) == null
-            ) {
-                uniqueId = conversationNotification.getGroup().messageReceiver.getUniqueIdString();
+            @Nullable String uniqueMessageReceiverId = null;
+
+            // If the conversationNotification does not already exist in local cache,
+            // and the receiver is not muted, we add it to the beginning of the cached list
+            final boolean notificationAlreadyExistsInCache = this.conversationNotificationsCache.stream().anyMatch(
+                cachedConversationNotification -> cachedConversationNotification.getUid().equals(conversationNotification.getUid())
+            );
+            if (!notificationAlreadyExistsInCache) {
+                uniqueMessageReceiverId = conversationNotification.getGroup().messageReceiver.getUniqueIdString();
                 if (!DNDUtil.getInstance().isMuted(conversationNotification.getGroup().messageReceiver, conversationNotification.getRawMessage())) {
-                    this.conversationNotifications.addFirst(conversationNotification);
+                    this.conversationNotificationsCache.addFirst(conversationNotification);
                 }
             } else if (updateExisting) {
-                uniqueId = conversationNotification.getGroup().messageReceiver.getUniqueIdString();
+                uniqueMessageReceiverId = conversationNotification.getGroup().messageReceiver.getUniqueIdString();
             }
 
             Map<String, ConversationNotificationGroup> uniqueNotificationGroups = new HashMap<>();
 
             //to refactor on merge update and add
-            final ConversationNotificationGroup newestGroup = conversationNotification.getGroup();
+            final ConversationNotificationGroup currentNotificationsGroup = conversationNotification.getGroup();
 
             int numberOfNotificationsForCurrentChat = 0;
 
-            ListIterator<ConversationNotification> iterator = this.conversationNotifications.listIterator();
-            while (iterator.hasNext()) {
-                ConversationNotification notification = iterator.next();
-                ConversationNotificationGroup group = notification.getGroup();
-                uniqueNotificationGroups.put(group.uid, group);
-                boolean isMessageDeleted = conversationNotification.isMessageDeleted();
+            ListIterator<ConversationNotification> cacheIterator = this.conversationNotificationsCache.listIterator();
+            while (cacheIterator.hasNext()) {
 
-                if (group.equals(newestGroup) && !isMessageDeleted) {
-                    numberOfNotificationsForCurrentChat++;
-                }
+                ConversationNotification cachedConversationNotification = cacheIterator.next();
 
-                if (conversationNotification.getUid().equals(notification.getUid()) && updateExisting) {
+                ConversationNotificationGroup cachedConversationNotificationsGroup = cachedConversationNotification.getGroup();
+                uniqueNotificationGroups.put(cachedConversationNotificationsGroup.uid, cachedConversationNotificationsGroup);
+
+                boolean isMessageDeleted = conversationNotification.isMessageDeleted();
+                boolean didRemoveNotificationFromCache = false;
+
+                if (conversationNotification.getUid().equals(cachedConversationNotification.getUid()) && updateExisting) {
                     if (isMessageDeleted) {
-                        iterator.remove();
+                        cacheIterator.remove();
+                        didRemoveNotificationFromCache = true;
                     } else {
-                        iterator.set(conversationNotification);
+                        cacheIterator.set(conversationNotification);
                     }
                 }
+
+                if (cachedConversationNotificationsGroup.equals(currentNotificationsGroup) && !didRemoveNotificationFromCache) {
+                    numberOfNotificationsForCurrentChat++;
+                }
             }
 
-            if (this.conversationNotifications
-                .stream()
-                .noneMatch(notification ->
-                    Objects.equals(notification.getGroup().uid, conversationNotification.getGroup().uid)
-                )
-            ) {
-                this.conversationNotifications.add(conversationNotification);
+            final boolean cacheDoesNotContainCurrentNotificationsGroup = this.conversationNotificationsCache.stream().noneMatch(
+                cachedConversationNotification -> cachedConversationNotification.getGroup().uid.equals(conversationNotification.getGroup().uid)
+            );
+            if (cacheDoesNotContainCurrentNotificationsGroup) {
+                this.conversationNotificationsCache.add(conversationNotification);
                 cancelConversationNotification(conversationNotification.getUid());
                 return;
             }
 
-            if (!TestUtil.required(conversationNotification, newestGroup)) {
+            if (!TestUtil.required(conversationNotification, currentNotificationsGroup)) {
                 logger.info("No notification - missing data");
                 return;
             }
 
             if (updateExisting) {
-                if (!this.preferenceService.isShowMessagePreview() || hiddenChatsListService.has(uniqueId)) {
+                if (!this.preferenceService.isShowMessagePreview() || hiddenChatsListService.has(uniqueMessageReceiverId)) {
                     return;
                 }
 
@@ -419,15 +422,15 @@ public class NotificationServiceImpl implements NotificationService {
                 }
             }
 
-            final String latestFullName = newestGroup.name;
-            boolean isGroupChat = newestGroup.messageReceiver instanceof GroupMessageReceiver;
+            final String latestFullName = currentNotificationsGroup.name;
+            boolean isGroupChat = currentNotificationsGroup.messageReceiver instanceof GroupMessageReceiver;
             String parentChannelId = isGroupChat
                 ? NotificationChannels.NOTIFICATION_CHANNEL_GROUP_CHATS_DEFAULT
                 : NotificationChannels.NOTIFICATION_CHANNEL_CHATS_DEFAULT;
-            String channelId = uniqueId != null && NotificationChannels.INSTANCE.exists(context, uniqueId) ? uniqueId : parentChannelId;
-            int unreadMessagesCount = this.conversationNotifications.size();
-            int unreadConversationsCount = uniqueNotificationGroups.size();
-            NotificationSchema notificationSchema = this.createNotificationSchema(newestGroup, conversationNotification.getRawMessage());
+            final int totalUnreadMessagesCount = this.conversationNotificationsCache.size();
+            final int totalUnreadConversationsCount = uniqueNotificationGroups.size();
+            String channelId = uniqueMessageReceiverId != null && NotificationChannels.INSTANCE.exists(context, uniqueMessageReceiverId) ? uniqueMessageReceiverId : parentChannelId;
+            NotificationSchema notificationSchema = this.createNotificationSchema(currentNotificationsGroup, conversationNotification.getRawMessage());
 
             if (notificationSchema == null) {
                 logger.warn("No notification - no notification schema");
@@ -444,18 +447,18 @@ public class NotificationServiceImpl implements NotificationService {
 
             CharSequence tickerText;
             CharSequence singleMessageText;
-            String summaryText = unreadConversationsCount > 1 ?
-                ConfigUtils.getSafeQuantityString(context, R.plurals.new_messages_in_chats, unreadMessagesCount, unreadMessagesCount, unreadConversationsCount) :
-                ConfigUtils.getSafeQuantityString(context, R.plurals.new_messages, unreadMessagesCount, unreadMessagesCount);
+            String summaryText = totalUnreadConversationsCount > 1 ?
+                ConfigUtils.getSafeQuantityString(context, R.plurals.new_messages_in_chats, totalUnreadMessagesCount, totalUnreadMessagesCount, totalUnreadConversationsCount) :
+                ConfigUtils.getSafeQuantityString(context, R.plurals.new_messages, totalUnreadMessagesCount, totalUnreadMessagesCount);
             String contentTitle;
             Intent notificationIntent;
 
             /* set avatar, intent and contentTitle */
             notificationIntent = new Intent(context, ComposeMessageActivity.class);
-            newestGroup.messageReceiver.prepareIntent(notificationIntent);
+            currentNotificationsGroup.messageReceiver.prepareIntent(notificationIntent);
             contentTitle = latestFullName;
 
-            if (hiddenChatsListService.has(uniqueId)) {
+            if (hiddenChatsListService.has(uniqueMessageReceiverId)) {
                 tickerText = summaryText;
                 singleMessageText = summaryText;
             } else {
@@ -474,33 +477,33 @@ public class NotificationServiceImpl implements NotificationService {
 
             /* ************ ANDROID AUTO ************* */
 
-            int conversationId = newestGroup.notificationId * 10;
+            int conversationId = currentNotificationsGroup.notificationId * 10;
 
             Intent replyIntent = new Intent(context, NotificationActionService.class);
             replyIntent.setAction(NotificationActionService.ACTION_REPLY);
-            IntentDataUtil.addMessageReceiverToIntent(replyIntent, newestGroup.messageReceiver);
+            IntentDataUtil.addMessageReceiverToIntent(replyIntent, currentNotificationsGroup.messageReceiver);
             PendingIntent replyPendingIntent = PendingIntent.getService(context, conversationId, replyIntent, pendingIntentFlags);
 
             Intent markReadIntent = new Intent(context, NotificationActionService.class);
             markReadIntent.setAction(NotificationActionService.ACTION_MARK_AS_READ);
-            IntentDataUtil.addMessageReceiverToIntent(markReadIntent, newestGroup.messageReceiver);
+            IntentDataUtil.addMessageReceiverToIntent(markReadIntent, currentNotificationsGroup.messageReceiver);
             PendingIntent markReadPendingIntent = PendingIntent.getService(context, conversationId + 1, markReadIntent, pendingIntentFlags);
 
             Intent ackIntent = new Intent(context, NotificationActionService.class);
             ackIntent.setAction(NotificationActionService.ACTION_ACK);
-            IntentDataUtil.addMessageReceiverToIntent(ackIntent, newestGroup.messageReceiver);
+            IntentDataUtil.addMessageReceiverToIntent(ackIntent, currentNotificationsGroup.messageReceiver);
             ackIntent.putExtra(ThreemaApplication.INTENT_DATA_MESSAGE_ID, conversationNotification.getId());
             PendingIntent ackPendingIntent = PendingIntent.getService(context, conversationId + 2, ackIntent, pendingIntentFlags);
 
             Intent decIntent = new Intent(context, NotificationActionService.class);
             decIntent.setAction(NotificationActionService.ACTION_DEC);
-            IntentDataUtil.addMessageReceiverToIntent(decIntent, newestGroup.messageReceiver);
+            IntentDataUtil.addMessageReceiverToIntent(decIntent, currentNotificationsGroup.messageReceiver);
             decIntent.putExtra(ThreemaApplication.INTENT_DATA_MESSAGE_ID, conversationNotification.getId());
             PendingIntent decPendingIntent = PendingIntent.getService(context, conversationId + 3, decIntent, pendingIntentFlags);
 
             long timestamp = System.currentTimeMillis();
-            boolean onlyAlertOnce = (timestamp - newestGroup.lastNotificationDate) < NOTIFY_AGAIN_TIMEOUT;
-            newestGroup.lastNotificationDate = timestamp;
+            boolean onlyAlertOnce = (timestamp - currentNotificationsGroup.lastNotificationDate) < NOTIFY_AGAIN_TIMEOUT;
+            currentNotificationsGroup.lastNotificationDate = timestamp;
 
             final NotificationCompat.Builder builder;
 
@@ -511,7 +514,7 @@ public class NotificationServiceImpl implements NotificationService {
                 numberOfNotificationsForCurrentChat
             );
 
-            if (!this.preferenceService.isShowMessagePreview() || hiddenChatsListService.has(uniqueId)) {
+            if (!this.preferenceService.isShowMessagePreview() || hiddenChatsListService.has(uniqueMessageReceiverId)) {
                 singleMessageText = summaryText;
                 tickerText = summaryText;
             }
@@ -529,8 +532,8 @@ public class NotificationServiceImpl implements NotificationService {
                 .setContentText(singleMessageText)
                 .setTicker(tickerText)
                 .setSmallIcon(R.drawable.ic_notification_small)
-                .setLargeIcon(newestGroup.loadAvatar())
-                .setGroup(newestGroup.uid)
+                .setLargeIcon(currentNotificationsGroup.loadAvatar())
+                .setGroup(currentNotificationsGroup.uid)
                 .setGroupSummary(false)
                 .setOnlyAlertOnce(onlyAlertOnce)
                 .setPriority(this.preferenceService.getNotificationPriority())
@@ -548,14 +551,14 @@ public class NotificationServiceImpl implements NotificationService {
             // Add identity to notification for system DND priority override
             builder.addPerson(conversationNotification.getSenderPerson());
 
-            if (this.preferenceService.isShowMessagePreview() && !hiddenChatsListService.has(uniqueId)) {
-                builder.setStyle(getMessagingStyle(newestGroup, getConversationNotificationsForGroup(newestGroup)));
-                if (uniqueId != null) {
-                    builder.setShortcutId(uniqueId);
-                    builder.setLocusId(new LocusIdCompat(uniqueId));
+            if (this.preferenceService.isShowMessagePreview() && !hiddenChatsListService.has(uniqueMessageReceiverId)) {
+                builder.setStyle(getMessagingStyle(currentNotificationsGroup, getConversationNotificationsForGroup(currentNotificationsGroup)));
+                if (uniqueMessageReceiverId != null) {
+                    builder.setShortcutId(uniqueMessageReceiverId);
+                    builder.setLocusId(new LocusIdCompat(uniqueMessageReceiverId));
                 }
-                addConversationNotificationActions(builder, replyPendingIntent, ackPendingIntent, markReadPendingIntent, conversationNotification, numberOfNotificationsForCurrentChat, unreadConversationsCount, uniqueId, newestGroup);
-                addWearableExtender(builder, newestGroup, ackPendingIntent, decPendingIntent, replyPendingIntent, markReadPendingIntent, numberOfNotificationsForCurrentChat, uniqueId);
+                addConversationNotificationActions(builder, replyPendingIntent, ackPendingIntent, markReadPendingIntent, conversationNotification, numberOfNotificationsForCurrentChat, totalUnreadConversationsCount, uniqueMessageReceiverId, currentNotificationsGroup);
+                addWearableExtender(builder, currentNotificationsGroup, ackPendingIntent, decPendingIntent, replyPendingIntent, markReadPendingIntent, numberOfNotificationsForCurrentChat, uniqueMessageReceiverId);
             }
 
             builder.setContentIntent(openPendingIntent);
@@ -563,13 +566,13 @@ public class NotificationServiceImpl implements NotificationService {
             if (updateExisting) {
                 List<StatusBarNotification> notifications = notificationManagerCompat.getActiveNotifications();
                 for (StatusBarNotification notification : notifications) {
-                    if (notification.getId() == newestGroup.notificationId) {
-                        this.notify(newestGroup.notificationId, builder, notificationSchema, parentChannelId);
+                    if (notification.getId() == currentNotificationsGroup.notificationId) {
+                        this.notify(currentNotificationsGroup.notificationId, builder, notificationSchema, parentChannelId);
                         break;
                     }
                 }
             } else {
-                this.notify(newestGroup.notificationId, builder, notificationSchema, parentChannelId);
+                this.notify(currentNotificationsGroup.notificationId, builder, notificationSchema, parentChannelId);
             }
 
             logger.info(
@@ -578,7 +581,7 @@ public class NotificationServiceImpl implements NotificationService {
                 notificationSchema.soundUri != null ? notificationSchema.soundUri.toString() : null
             );
 
-            showIconBadge(unreadMessagesCount);
+            showIconBadge(totalUnreadMessagesCount);
         }
     }
 
@@ -655,7 +658,7 @@ public class NotificationServiceImpl implements NotificationService {
     @NonNull
     private ArrayList<ConversationNotification> getConversationNotificationsForGroup(ConversationNotificationGroup group) {
         ArrayList<ConversationNotification> notifications = new ArrayList<>();
-        for (ConversationNotification notification : conversationNotifications) {
+        for (ConversationNotification notification : conversationNotificationsCache) {
             if (notification.getGroup().uid.equals(group.uid)) {
                 notifications.add(notification);
             }
@@ -816,8 +819,8 @@ public class NotificationServiceImpl implements NotificationService {
     @Override
     public void cancelConversationNotificationsOnLockApp() {
         // cancel cached notification ids if still available
-        if (!conversationNotifications.isEmpty()) {
-            boolean containedAnyNotificationToAnUnMutedReceiver = conversationNotifications
+        if (!conversationNotificationsCache.isEmpty()) {
+            boolean containedAnyNotificationToAnUnMutedReceiver = conversationNotificationsCache
                 .stream()
                 .anyMatch(conversationNotification ->
                     !DNDUtil.getInstance().isMuted(
@@ -859,11 +862,11 @@ public class NotificationServiceImpl implements NotificationService {
             logger.warn("Unique id array must not be null! Ignoring.");
             return;
         }
-        synchronized (this.conversationNotifications) {
+        synchronized (this.conversationNotificationsCache) {
             logger.info("Cancel {} conversation notifications", uids.length);
             for (final String uid : uids) {
                 ConversationNotification conversationNotification = Functional.select(
-                    this.conversationNotifications,
+                    this.conversationNotificationsCache,
                     conversationNotification1 -> TestUtil.compare(conversationNotification1.getUid(), uid)
                 );
 
@@ -875,10 +878,10 @@ public class NotificationServiceImpl implements NotificationService {
                 }
             }
 
-            showIconBadge(this.conversationNotifications.size());
+            showIconBadge(this.conversationNotificationsCache.size());
 
             // no unread conversations left. make sure PIN locked notification is canceled as well
-            if (this.conversationNotifications.isEmpty()) {
+            if (this.conversationNotificationsCache.isEmpty()) {
                 cancelPinLockedNewMessagesNotification();
             }
         }
@@ -890,7 +893,7 @@ public class NotificationServiceImpl implements NotificationService {
         if (conversationNotification == null) {
             return;
         }
-        synchronized (this.conversationNotifications) {
+        synchronized (this.conversationNotificationsCache) {
             logger.info("Destroy notification {}", conversationNotification.getUid());
             cancel(conversationNotification.getGroup().notificationId);
             conversationNotification.destroy();
@@ -901,12 +904,12 @@ public class NotificationServiceImpl implements NotificationService {
     public void cancelAllCachedConversationNotifications() {
         this.cancel(ThreemaApplication.NEW_MESSAGE_NOTIFICATION_ID);
 
-        synchronized (this.conversationNotifications) {
-            if (!conversationNotifications.isEmpty()) {
-                for (ConversationNotification conversationNotification : conversationNotifications) {
+        synchronized (this.conversationNotificationsCache) {
+            if (!conversationNotificationsCache.isEmpty()) {
+                for (ConversationNotification conversationNotification : conversationNotificationsCache) {
                     this.cancelAndDestroyConversationNotification(conversationNotification);
                 }
-                conversationNotifications.clear();
+                conversationNotificationsCache.clear();
             }
         }
     }
@@ -955,9 +958,9 @@ public class NotificationServiceImpl implements NotificationService {
     @Override
     public void cancelCachedConversationNotifications() {
         /* called when pin lock becomes active */
-        synchronized (this.conversationNotifications) {
+        synchronized (this.conversationNotificationsCache) {
             cancelAllCachedConversationNotifications();
-            showIconBadge(this.conversationNotifications.size());
+            showIconBadge(this.conversationNotificationsCache.size());
         }
     }
 
@@ -1329,8 +1332,8 @@ public class NotificationServiceImpl implements NotificationService {
         }
 
         //remove all cached notifications from the receiver
-        synchronized (this.conversationNotifications) {
-            for (Iterator<ConversationNotification> iterator = this.conversationNotifications.iterator(); iterator.hasNext(); ) {
+        synchronized (this.conversationNotificationsCache) {
+            for (Iterator<ConversationNotification> iterator = this.conversationNotificationsCache.iterator(); iterator.hasNext(); ) {
                 ConversationNotification conversationNotification = iterator.next();
                 if (conversationNotification != null
                     && conversationNotification.getGroup() != null
@@ -1340,7 +1343,7 @@ public class NotificationServiceImpl implements NotificationService {
                     this.cancelAndDestroyConversationNotification(conversationNotification);
                 }
             }
-            showIconBadge(conversationNotifications.size());
+            showIconBadge(conversationNotificationsCache.size());
         }
         this.cancel(ThreemaApplication.NEW_MESSAGE_NOTIFICATION_ID);
     }

+ 7 - 6
app/src/main/java/ch/threema/app/tasks/SendPushTokenTask.kt

@@ -21,6 +21,7 @@
 
 package ch.threema.app.tasks
 
+import androidx.core.content.edit
 import androidx.preference.PreferenceManager
 import ch.threema.app.R
 import ch.threema.app.managers.ServiceManager
@@ -67,12 +68,12 @@ class SendPushTokenTask(
         }
 
         PreferenceManager.getDefaultSharedPreferences(serviceManager.context)
-            .edit()
-            .putLong(
-                serviceManager.context.getString(R.string.preferences__token_sent_date),
-                sentTime
-            )
-            .apply()
+            .edit {
+                putLong(
+                    serviceManager.context.getString(R.string.preferences__token_sent_date),
+                    sentTime,
+                )
+            }
 
         // Used in the Webclient Sessions
         serviceManager.getPreferenceService().setPushToken(token)

+ 11 - 0
app/src/main/java/ch/threema/app/ui/MentionSpan.java

@@ -103,6 +103,17 @@ public class MentionSpan extends ReplacementSpan {
 		if (!TestUtil.isBlankOrNull(text) && end - start == 11) {
 			String labelText = getMentionLabelText(text, start, end);
 			if (!TestUtil.isEmptyOrNull(labelText)) {
+				if (fm != null) {
+					// The span is not rendered if it spans the entire text and no height is set. Therefore, we need to apply the font metrics
+					// here such that a height can be calculated.
+					final Paint.FontMetrics fontMetrics = paint.getFontMetrics();
+					fm.ascent = (int) fontMetrics.ascent;
+					fm.bottom = (int) fontMetrics.bottom;
+					fm.descent = (int) fontMetrics.descent;
+					fm.leading = (int) fontMetrics.leading;
+					fm.top = (int) fontMetrics.top;
+				}
+
 				width = (int) paint.measureText(MENTION_INDICATOR + labelText) + (PADDING * 2);
 				return width;
 			}

+ 0 - 273
app/src/main/java/ch/threema/app/ui/TooltipPopup.java

@@ -1,273 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2018-2024 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.ui;
-
-import android.app.Activity;
-import android.content.Context;
-import android.content.Intent;
-import android.content.SharedPreferences;
-import android.graphics.drawable.BitmapDrawable;
-import android.os.Handler;
-import android.view.Gravity;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.WindowManager;
-import android.widget.FrameLayout;
-import android.widget.ImageView;
-import android.widget.PopupWindow;
-
-import androidx.annotation.DrawableRes;
-import androidx.annotation.LayoutRes;
-import androidx.annotation.NonNull;
-import androidx.lifecycle.DefaultLifecycleObserver;
-import androidx.lifecycle.LifecycleOwner;
-import androidx.preference.PreferenceManager;
-
-import com.google.android.material.card.MaterialCardView;
-
-import ch.threema.app.R;
-import ch.threema.app.emojis.EmojiTextView;
-import ch.threema.app.utils.ConfigUtils;
-
-public class TooltipPopup extends PopupWindow implements DefaultLifecycleObserver {
-
-	public static final int ALIGN_ABOVE_ANCHOR_ARROW_LEFT = 1;
-	public static final int ALIGN_BELOW_ANCHOR_ARROW_RIGHT = 2;
-	public static final int ALIGN_BELOW_ANCHOR_ARROW_LEFT = 3;
-	public static final int ALIGN_ABOVE_ANCHOR_ARROW_RIGHT = 4;
-
-	private final Context context;
-	private View popupLayout;
-	private EmojiTextView textView;
-	private final String preferenceString;
-	private Handler timeoutHandler;
-	private final Runnable dismissRunnable = () -> dismiss(false);
-
-	private @DrawableRes int icon = 0;
-
-	public TooltipPopup(Context context, int preferenceKey, LifecycleOwner lifecycleOwner) {
-		super(context);
-
-		if (lifecycleOwner != null) {
-			lifecycleOwner.getLifecycle().addObserver(this);
-		}
-
-		if (preferenceKey == 0) {
-			this.preferenceString = null;
-		} else {
-			this.preferenceString = context.getString(preferenceKey);
-		}
-		this.context = context;
-
-		init(context, null);
-	}
-
-	public TooltipPopup(Context context, int preferenceKey, LifecycleOwner lifecycleOwner, Intent launchIntent, @DrawableRes int icon) {
-		super(context);
-
-		if (lifecycleOwner != null) {
-			lifecycleOwner.getLifecycle().addObserver(this);
-		}
-
-		if (preferenceKey == 0) {
-			this.preferenceString = null;
-		} else {
-			this.preferenceString = context.getString(preferenceKey);
-		}
-		this.context = context;
-		this.icon = icon;
-
-		init(context, launchIntent);
-	}
-
-	private void init(Context context, Intent launchIntent) {
-		LayoutInflater layoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
-
-		this.popupLayout = layoutInflater.inflate(R.layout.popup_tooltip, null, false);
-		this.textView = popupLayout.findViewById(R.id.label);
-
-		setContentView(popupLayout);
-		setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED);
-		setAnimationStyle(R.style.TooltipAnimation);
-		setFocusable(false);
-		setTouchable(true);
-		setOutsideTouchable(false);
-		setBackgroundDrawable(new BitmapDrawable());
-
-		popupLayout.setOnClickListener(v -> {
-			if (launchIntent != null) {
-				context.startActivity(launchIntent);
-				if (context instanceof Activity) {
-					((Activity) context).overridePendingTransition(0, 0);
-				}
-			} else {
-				dismissForever();
-			}
-		});
-
-		View closeButton = popupLayout.findViewById(R.id.close_button);
-		if (closeButton != null) {
-			if (preferenceString == null) {
-				closeButton.setVisibility(View.GONE);
-			} else {
-				closeButton.setOnClickListener(v -> dismissForever());
-			}
-		}
-	}
-
-	public static boolean isDismissed(Context context, String preferenceString) {
-		if (preferenceString == null) {
-			return false;
-		}
-
-		SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
-		if (sharedPreferences != null) {
-			return sharedPreferences.getBoolean(preferenceString, false);
-		}
-		return false;
-	}
-
-	public void dismissForever() {
-		if (preferenceString != null) {
-			SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
-			if (sharedPreferences != null) {
-				sharedPreferences.edit().putBoolean(preferenceString, true).apply();
-			}
-		}
-
-		dismiss(false);
-	}
-
-	public void dismiss(boolean immediate) {
-		if (immediate) {
-			setAnimationStyle(0);
-		}
-
-		if (timeoutHandler != null) {
-			timeoutHandler.removeCallbacks(dismissRunnable);
-			timeoutHandler = null;
-		}
-
-		this.dismiss();
-	}
-
-	/**
-	 * Show a tooltip at the specified location pointing to a specified anchor view
-	 * @param activity Activity context
-	 * @param anchor Anchor / parent view to of this tooltip
-	 * @param text Text to show in tooltip
-	 * @param align Where to align the tooltip and where the arrow should be shown
-	 * @param originLocation The location on screen where the tip of the arrow should point to
-	 * @param timeoutMs How long the tooltip should be shown until it fades out
-	 */
-	public void show(Activity activity, final View anchor, String text, int align, int[] originLocation, int timeoutMs) {
-		if (isDismissed(context, preferenceString)) {
-			return;
-		}
-
-		this.textView.setText(text);
-
-		int screenHeight = activity.getWindowManager().getDefaultDisplay().getHeight();
-		int screenWidth = activity.getWindowManager().getDefaultDisplay().getWidth();
-		int maxWidth = context.getResources().getDimensionPixelSize(R.dimen.tooltip_max_width);
-		int arrowInset = context.getResources().getDimensionPixelSize(R.dimen.tooltip_popup_arrow_inset);
-		int marginOnOtherEdge = context.getResources().getDimensionPixelSize(R.dimen.tooltip_margin_on_other_edge);
-		int arrowOffset = (context.getResources().getDimensionPixelSize(R.dimen.identity_popup_arrow_width) / 2) + arrowInset;
-		int popupX, popupY, popupWidth, anchorGravity, contentGravity;
-
-		if (align == ALIGN_ABOVE_ANCHOR_ARROW_LEFT) {
-			this.popupLayout.findViewById(R.id.arrow_bottom_left).setVisibility(View.VISIBLE);
-			popupX = Math.max(0, originLocation[0] - arrowOffset); // left edge of popup
-			popupY = screenHeight - originLocation[1] + ConfigUtils.getNavigationBarHeight(activity);
-			popupWidth = Math.min(screenWidth - popupX - marginOnOtherEdge, maxWidth);
-			anchorGravity = Gravity.LEFT | Gravity.BOTTOM;
-			contentGravity = Gravity.LEFT;
-		} else if (align == ALIGN_ABOVE_ANCHOR_ARROW_RIGHT) {
-			this.popupLayout.findViewById(R.id.arrow_bottom_right).setVisibility(View.VISIBLE);
-			popupX = Math.min(screenWidth, originLocation[0] + arrowOffset);
-			popupY = screenHeight - originLocation[1] + ConfigUtils.getNavigationBarHeight(activity);
-			popupWidth = Math.min(popupX - marginOnOtherEdge, maxWidth);
-			popupX -= popupWidth;
-			anchorGravity = Gravity.LEFT | Gravity.BOTTOM;
-			contentGravity = Gravity.RIGHT;
-		} else if (align == ALIGN_BELOW_ANCHOR_ARROW_LEFT) {
-			this.popupLayout.findViewById(R.id.arrow_top_left).setVisibility(View.VISIBLE);
-			popupX = Math.max(0, originLocation[0] - arrowOffset); // left edge of popup
-			popupY = originLocation[1];
-			popupWidth = Math.min(screenWidth - popupX - marginOnOtherEdge, maxWidth);
-			anchorGravity = Gravity.LEFT | Gravity.TOP;
-			contentGravity = Gravity.LEFT;
-		} else { // arrow right
-			this.popupLayout.findViewById(R.id.arrow_top_right).setVisibility(View.VISIBLE);
-			popupX = Math.min(screenWidth, originLocation[0] + arrowOffset); // right edge of popup
-			popupY = originLocation[1];
-			popupWidth = Math.min(popupX - marginOnOtherEdge, maxWidth);
-			popupX -= popupWidth;
-			anchorGravity = Gravity.LEFT | Gravity.TOP;
-			contentGravity = Gravity.RIGHT;
-		}
-
-		this.setWidth(popupWidth);
-		MaterialCardView contentLayout = this.popupLayout.findViewById(R.id.content);
-		FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) contentLayout.getLayoutParams();
-		params.gravity = contentGravity;
-		contentLayout.setLayoutParams(params);
-
-		if (activity.isFinishing() || activity.isDestroyed()) {
-			return;
-		}
-		try {
-			showAtLocation(anchor, anchorGravity, popupX, popupY);
-		} catch (WindowManager.BadTokenException e) {
-			return;
-		}
-
-		ImageView iconView = this.popupLayout.findViewById(R.id.icon);
-		if (icon != 0) {
-			iconView.setImageResource(icon);
-			iconView.setVisibility(View.VISIBLE);
-		} else {
-			iconView.setVisibility(View.GONE);
-		}
-
-		if (timeoutMs > 0) {
-			if (timeoutHandler == null) {
-				timeoutHandler = new Handler();
-			}
-			timeoutHandler.removeCallbacks(dismissRunnable);
-			timeoutHandler.postDelayed(dismissRunnable, timeoutMs);
-		}
-	}
-
-	/**
-	 * Notifies that {@code ON_PAUSE} event occurred.
-	 * <p>
-	 * This method will be called before the {@link LifecycleOwner}'s {@code onPause} method
-	 * is called.
-	 *
-	 * @param owner the component, whose state was changed
-	 */
-	@Override
-	public void onPause(@NonNull LifecycleOwner owner) {
-		dismiss(true);
-	}
-}

+ 302 - 0
app/src/main/java/ch/threema/app/ui/TooltipPopup.kt

@@ -0,0 +1,302 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2018-2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.ui
+
+import android.app.Activity
+import android.content.Context
+import android.graphics.drawable.ColorDrawable
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager.BadTokenException
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.PopupWindow
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.core.content.edit
+import androidx.core.content.getSystemService
+import androidx.core.view.isVisible
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.preference.PreferenceManager
+import ch.threema.app.R
+import ch.threema.app.emojis.EmojiTextView
+import ch.threema.app.utils.ConfigUtils
+import com.google.android.material.card.MaterialCardView
+
+
+class TooltipPopup
+@JvmOverloads
+constructor(
+    private val context: Context,
+    @StringRes
+    preferenceKey: Int = 0,
+    lifecycleOwner: LifecycleOwner? = null,
+    @DrawableRes
+    private val icon: Int = 0,
+    private val showCloseButton: Boolean = true,
+) : PopupWindow(context), DefaultLifecycleObserver {
+    private var popupLayout: View
+    private var titleView: EmojiTextView
+    private var textView: EmojiTextView
+    private var preferenceString: String? = null
+    private var timeoutHandler: Handler? = null
+    private val dismissRunnable = Runnable {
+        listener.onTimedOut(this)
+        dismiss(false)
+    }
+
+    var listener: TooltipPopupListener = TooltipPopupListener()
+
+    init {
+        lifecycleOwner?.lifecycle?.addObserver(this)
+        preferenceString = if (preferenceKey != 0) {
+            context.getString(preferenceKey)
+        } else {
+            null
+        }
+
+        val layoutInflater = context.getSystemService<LayoutInflater>()!!
+        popupLayout = layoutInflater.inflate(R.layout.popup_tooltip, null, false)!!
+        titleView = popupLayout.findViewById(R.id.title)
+        textView = popupLayout.findViewById(R.id.label)
+        contentView = popupLayout
+        inputMethodMode = INPUT_METHOD_NOT_NEEDED
+        animationStyle = R.style.TooltipAnimation
+        isFocusable = false
+        isTouchable = true
+        isOutsideTouchable = false
+        setBackgroundDrawable(ColorDrawable())
+
+        popupLayout.setOnClickListener {
+            listener.onClicked(this)
+        }
+
+        popupLayout.findViewById<View>(R.id.close_button)
+            ?.let { closeButton ->
+                closeButton.isVisible = showCloseButton
+                closeButton.setOnClickListener {
+                    listener.onCloseButtonClicked(this)
+                }
+            }
+    }
+
+    fun dismissForever() {
+        if (preferenceString != null) {
+            PreferenceManager.getDefaultSharedPreferences(context)
+                ?.edit {
+                    putBoolean(preferenceString, true)
+                }
+        }
+
+        dismiss(false)
+    }
+
+    fun dismiss(immediate: Boolean) {
+        if (immediate) {
+            animationStyle = 0
+        }
+
+        timeoutHandler?.removeCallbacks(dismissRunnable)
+        timeoutHandler = null
+
+        this.dismiss()
+        listener.onDismissed(this)
+    }
+
+    /**
+     * Show a tooltip at the specified location pointing to a specified anchor view
+     *
+     * @param activity       Activity context
+     * @param anchor         Anchor / parent view to of this tooltip
+     * @param title          Optional title text to show in tooltip
+     * @param text           Text to show in tooltip
+     * @param alignment         Where to align the tooltip and where the arrow should be shown
+     * @param originLocation The location on screen where the tip of the arrow should point to
+     * @param timeoutMs      How long the tooltip should be shown until it fades out
+     */
+    fun show(
+        activity: Activity,
+        anchor: View,
+        title: String? = null,
+        text: String?,
+        alignment: Alignment,
+        originLocation: IntArray,
+        timeoutMs: Int = 0,
+    ) {
+        if (isForeverDismissed()) {
+            return
+        }
+
+        if (title != null) {
+            titleView.text = title
+            titleView.isVisible = true
+        } else {
+            titleView.isVisible = false
+        }
+        textView.text = text
+
+        val resources = context.resources
+
+        val screenHeight: Int
+        val screenWidth: Int
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+            val windowMetrics = activity.windowManager.currentWindowMetrics
+            val rect = windowMetrics.bounds
+            screenWidth = rect.right
+            screenHeight = rect.bottom
+        } else {
+            screenWidth = resources.displayMetrics.widthPixels
+            screenHeight = resources.displayMetrics.heightPixels + ConfigUtils.getNavigationBarHeight(activity)
+        }
+        val maxWidth = resources.getDimensionPixelSize(R.dimen.tooltip_max_width)
+        val arrowInset = resources.getDimensionPixelSize(R.dimen.tooltip_popup_arrow_inset)
+        val marginOnOtherEdge = resources.getDimensionPixelSize(R.dimen.tooltip_margin_on_other_edge)
+        val arrowOffset = (resources.getDimensionPixelSize(R.dimen.identity_popup_arrow_width) / 2) + arrowInset
+        var popupX: Int
+        val popupY: Int
+        val popupWidth: Int
+        val anchorGravity: Int
+        val contentGravity: Int
+
+        when (alignment) {
+            Alignment.ABOVE_ANCHOR_ARROW_LEFT -> {
+                popupLayout.findViewById<View>(R.id.arrow_bottom_left).isVisible = true
+                popupX = (originLocation[0] - arrowOffset).coerceAtLeast(0)
+                popupY = screenHeight - originLocation[1]
+                popupWidth = (screenWidth - popupX - marginOnOtherEdge).coerceAtMost(maxWidth)
+                anchorGravity = Gravity.LEFT or Gravity.BOTTOM
+                contentGravity = Gravity.LEFT
+            }
+
+            Alignment.ABOVE_ANCHOR_ARROW_RIGHT -> {
+                popupLayout.findViewById<View>(R.id.arrow_bottom_right).isVisible = true
+                popupX = (originLocation[0] + arrowOffset).coerceAtMost(screenWidth)
+                popupY = screenHeight - originLocation[1]
+                popupWidth = (popupX - marginOnOtherEdge).coerceAtMost(maxWidth)
+                popupX -= popupWidth
+                anchorGravity = Gravity.LEFT or Gravity.BOTTOM
+                contentGravity = Gravity.RIGHT
+            }
+
+            Alignment.BELOW_ANCHOR_ARROW_LEFT -> {
+                popupLayout.findViewById<View>(R.id.arrow_top_left).isVisible = true
+                popupX = (originLocation[0] - arrowOffset).coerceAtLeast(0)
+                popupY = originLocation[1]
+                popupWidth = (screenWidth - popupX - marginOnOtherEdge).coerceAtMost(maxWidth)
+                anchorGravity = Gravity.LEFT or Gravity.TOP
+                contentGravity = Gravity.LEFT
+            }
+
+            Alignment.BELOW_ANCHOR_ARROW_RIGHT -> {
+                popupLayout.findViewById<View>(R.id.arrow_top_right).isVisible = true
+                popupX = (originLocation[0] + arrowOffset).coerceAtMost(screenWidth)
+                popupY = originLocation[1]
+                popupWidth = (popupX - marginOnOtherEdge).coerceAtMost(maxWidth)
+                popupX -= popupWidth
+                anchorGravity = Gravity.LEFT or Gravity.TOP
+                contentGravity = Gravity.RIGHT
+            }
+        }
+
+        this.width = popupWidth
+        this.height = ViewGroup.LayoutParams.WRAP_CONTENT
+        val contentLayout = popupLayout.findViewById<MaterialCardView>(R.id.content)
+        val params = contentLayout.layoutParams as FrameLayout.LayoutParams
+        params.gravity = contentGravity
+        contentLayout.layoutParams = params
+
+        if (activity.isFinishing || activity.isDestroyed) {
+            return
+        }
+        try {
+            showAtLocation(anchor, anchorGravity, popupX, popupY)
+        } catch (e: BadTokenException) {
+            return
+        }
+
+        val iconView = popupLayout.findViewById<ImageView>(R.id.icon)
+        if (icon != 0) {
+            iconView.setImageResource(icon)
+            iconView.isVisible = true
+        } else {
+            iconView.isVisible = false
+        }
+
+        listener.onShown(this)
+
+        if (timeoutMs > 0) {
+            if (timeoutHandler == null) {
+                timeoutHandler = Handler(Looper.getMainLooper())
+            }
+            timeoutHandler?.removeCallbacks(dismissRunnable)
+            timeoutHandler?.postDelayed(dismissRunnable, timeoutMs.toLong())
+        }
+    }
+
+    private fun isForeverDismissed(): Boolean {
+        if (preferenceString == null) {
+            return false
+        }
+        return PreferenceManager.getDefaultSharedPreferences(context)
+            ?.getBoolean(preferenceString, false)
+            ?: false
+    }
+
+    /**
+     * Notifies that `ON_PAUSE` event occurred.
+     *
+     * This method will be called before the [LifecycleOwner]'s `onPause` method
+     * is called.
+     *
+     * @param owner the component, whose state was changed
+     */
+    override fun onPause(owner: LifecycleOwner) {
+        dismiss(true)
+    }
+
+    open class TooltipPopupListener {
+        open fun onShown(tooltipPopup: TooltipPopup) {}
+        open fun onClicked(tooltipPopup: TooltipPopup) {
+            tooltipPopup.dismissForever()
+        }
+
+        open fun onCloseButtonClicked(tooltipPopup: TooltipPopup) {
+            tooltipPopup.dismissForever()
+        }
+
+        open fun onTimedOut(tooltipPopup: TooltipPopup) {}
+        open fun onDismissed(tooltipPopup: TooltipPopup) {}
+    }
+
+    enum class Alignment {
+        ABOVE_ANCHOR_ARROW_LEFT,
+        ABOVE_ANCHOR_ARROW_RIGHT,
+        BELOW_ANCHOR_ARROW_LEFT,
+        BELOW_ANCHOR_ARROW_RIGHT,
+    }
+}

+ 41 - 0
app/src/main/java/ch/threema/app/ui/ViewExtensions.kt

@@ -0,0 +1,41 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2014-2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.ui
+
+import android.view.View
+
+/**
+ * Gets the coordinates of this view in the coordinate space of the window that contains the view.
+ */
+fun View.getLocation(xOffset: Int = 0, yOffset: Int = 0): IntArray {
+    val location = IntArray(2)
+    getLocationInWindow(location)
+    location[0] += xOffset
+    location[1] += yOffset
+    return location
+}
+
+fun View.getTopCenterLocation(): IntArray =
+    getLocation(xOffset = width / 2)
+
+fun View.getBottomCenterLocation(): IntArray =
+    getLocation(xOffset = width / 2, yOffset = height)

+ 1 - 1
app/src/main/java/ch/threema/app/utils/CSVWriter.java

@@ -33,7 +33,7 @@ public class CSVWriter extends au.com.bytecode.opencsv.CSVWriter {
 	public CSVWriter(Writer writer, String[] header) {
 		super(writer);
 		this.header = header;
-		//write directly
+		// write directly
 		this.writeNext(this.header);
 	}
 

+ 87 - 0
app/src/main/java/ch/threema/app/utils/Counter.kt

@@ -0,0 +1,87 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2025-2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.utils
+
+/**
+ * A [Counter] that can be used whenever things have to be counted and for any reason
+ * a simple variable will not do.
+ * It also allows counting 'steps' where a step consist of multiple counts. The step size
+ * can be defined using the [stepSize] constructor parameter.
+ *
+ * @param stepSize The number of counts that make a step. Must be greater than zero.
+ *                 Defaults to one.
+ *
+ * @throws IllegalArgumentException if [stepSize] is <= 0
+ */
+class Counter(private val stepSize: Long) {
+    init {
+        require(stepSize > 0) { "stepSize must be > 0" }
+    }
+
+    private var _count = 0L
+    private var _steps = 0L
+
+    constructor() : this(1)
+
+    /**
+     * @return the current count of this [Counter]
+     */
+    val count: Long
+        get() = _count
+
+    /**
+     * @return The current number of steps this counter has encountered so far.
+     *         Note that the number of steps can be reset to zero, when
+     *         [getAndResetSteps] is used.
+     */
+    val steps: Long
+        get() = _steps
+
+    /**
+     * Increment the value of this counter by one.
+     */
+    fun count() {
+        _count++
+        if ((_count % stepSize) == 0L) {
+            _steps++
+        }
+    }
+
+    /**
+     * Get the counted steps if they exceed [threshold].
+     * If the [threshold] is exceeded the step counter is reset to zero.
+     *
+     * @return number of steps if [threshold] is exceeded, 0L otherwise.
+     */
+    fun getAndResetSteps(threshold: Long): Long {
+        if (_steps < threshold) {
+            return 0L
+        }
+        val steps = _steps
+        _steps = 0
+        return steps
+    }
+
+    override fun toString(): String {
+        return "$_count"
+    }
+}

+ 156 - 0
app/src/main/java/ch/threema/app/utils/FileHandlingZipOutputStream.kt

@@ -0,0 +1,156 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.utils
+
+import ch.threema.base.ThreemaException
+import ch.threema.base.utils.LoggingUtil
+import net.lingala.zip4j.io.outputstream.ZipOutputStream
+import net.lingala.zip4j.model.ZipParameters
+import net.lingala.zip4j.model.enums.AesKeyStrength
+import net.lingala.zip4j.model.enums.CompressionLevel
+import net.lingala.zip4j.model.enums.CompressionMethod
+import net.lingala.zip4j.model.enums.EncryptionMethod
+import java.io.BufferedOutputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.nio.charset.Charset
+
+private val logger = LoggingUtil.getThreemaLogger("FileHandlingZipOutputStream")
+
+class FileHandlingZipOutputStream(outputStream: OutputStream, password: CharArray?, charset: Charset?) :
+    ZipOutputStream(outputStream, password, charset) {
+
+    constructor(outputStream: OutputStream) : this(outputStream, null, null)
+
+    constructor(outputStream: OutputStream, password: CharArray) : this(
+        outputStream,
+        password,
+        null
+    )
+
+    companion object {
+        /**
+         * Get a [FileHandlingZipOutputStream] that writes to a provided [OutputStream].
+         * Note that the outputStream will be wrapped by a [BufferedOutputStream].
+         * @param outputStream The [OutputStream] data should be written to
+         * @param password Desired password or null if no encryption is desired
+         * @throws IOException If the stream could not be created
+         */
+        @Throws(IOException::class)
+        @JvmStatic
+        fun initializeZipOutputStream(outputStream: OutputStream, password: String?): FileHandlingZipOutputStream {
+            val bufferedOutputStream = BufferedOutputStream(outputStream)
+            return if (password != null) {
+                FileHandlingZipOutputStream(bufferedOutputStream, password.toCharArray())
+            } else {
+                FileHandlingZipOutputStream(bufferedOutputStream)
+            }
+        }
+
+        @Throws(IOException::class)
+        @JvmStatic
+        fun initializeZipOutputStream(zipFile: File, password: String?): FileHandlingZipOutputStream {
+            val fileOutputStream = FileOutputStream(zipFile)
+            val bufferedOutputStream = BufferedOutputStream(fileOutputStream)
+            return initializeZipOutputStream(bufferedOutputStream, password)
+        }
+    }
+
+    /**
+     * Write the contents of [inputStream] to this [FileHandlingZipOutputStream] and close [inputStream]
+     * afterwards.
+     * @param inputStream
+     * @param filenameInZip
+     * @param compress whether to compress the data (don't use for already compressed data like images)
+     * @throws IOException
+     */
+    @Throws(IOException::class)
+    fun addFileFromInputStream(inputStream: InputStream?, filenameInZip: String, compress: Boolean) {
+        if (inputStream == null) {
+            return
+        }
+
+        inputStream.use { dataInputStream ->
+            putNextEntry(createZipParameter(filenameInZip, compress))
+            val buffer = ByteArray(16384)
+            var bytesRead: Int
+            while (dataInputStream.read(buffer).also { bytesRead = it } > 0) {
+                write(buffer, 0, bytesRead)
+            }
+            closeEntry()
+        }
+    }
+
+    @Throws(ThreemaException::class)
+    fun addFile(filenameInZip: String, compress: Boolean, consumer:  ThrowingConsumer<OutputStream>) {
+        putNextEntry(createZipParameter(filenameInZip, compress))
+        try {
+            consumer.accept(object : OutputStream() {
+                @Throws(IOException::class)
+                override fun close() {
+                    logger.debug("Ignore closing of output stream")
+                }
+
+                @Throws(IOException::class)
+                override fun flush() {
+                    this@FileHandlingZipOutputStream.flush()
+                }
+
+                @Throws(IOException::class)
+                override fun write(b: Int) {
+                    this@FileHandlingZipOutputStream.write(b)
+                }
+
+                @Throws(IOException::class)
+                override fun write(b: ByteArray?) {
+                    this@FileHandlingZipOutputStream.write(b)
+                }
+
+                @Throws(IOException::class)
+                override fun write(b: ByteArray?, off: Int, len: Int) {
+                    this@FileHandlingZipOutputStream.write(b, off, len)
+                }
+            })
+        } catch (e: Exception) {
+            throw ThreemaException("Exception while adding file", e)
+        }
+        closeEntry()
+    }
+
+    private fun createZipParameter(filenameInZip: String, compress: Boolean): ZipParameters {
+        return ZipParameters().apply {
+            compressionMethod = CompressionMethod.DEFLATE
+            compressionLevel = if (compress) {
+                CompressionLevel.NORMAL
+            } else {
+                CompressionLevel.NO_COMPRESSION
+            }
+            isEncryptFiles = true
+            encryptionMethod = EncryptionMethod.AES
+            aesKeyStrength = AesKeyStrength.KEY_STRENGTH_256
+            fileNameInZip = filenameInZip
+        }
+    }
+}

+ 0 - 7
app/src/main/java/ch/threema/app/utils/IntentDataUtil.java

@@ -43,7 +43,6 @@ import ch.threema.app.BuildConfig;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ComposeMessageActivity;
 import ch.threema.app.activities.HomeActivity;
-import ch.threema.app.backuprestore.BackupRestoreDataService;
 import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.mediaattacher.MediaFilterQuery;
@@ -101,8 +100,6 @@ public class IntentDataUtil {
 	public static final String INTENT_DATA_WEB_CLIENT_SESSION_MODEL_ID = "session_model_id";
 	public static final String INTENT_DATA_PAYLOAD = "payload";
 
-	private static final String INTENT_DATA_BACKUP_FILE = "backup_file";
-
 	private static final String INTENT_HIDE_AFTER_UNLOCK = "hide_after_unlock";
 	private static final String INTENT_DATA_BALLOT_ID = "ballot_id";
 	private static final String INTENT_DATA_BALLOT_CHOICE_ID = "ballot_choide_id";
@@ -110,10 +107,6 @@ public class IntentDataUtil {
 	public static final int PENDING_INTENT_FLAG_IMMUTABLE = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? FLAG_IMMUTABLE : 0;
 	public static final int PENDING_INTENT_FLAG_MUTABLE = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? FLAG_MUTABLE : 0;
 
-	public static void append(BackupRestoreDataService.BackupData backupData, Intent intent) {
-		intent.putExtra(INTENT_DATA_BACKUP_FILE, backupData.getFile().getPath());
-	}
-
 	public static void append(byte[] payload, Intent intent) {
 		intent.putExtra(INTENT_DATA_PAYLOAD, payload);
 	}

+ 96 - 0
app/src/main/java/ch/threema/app/utils/RingtoneChecker.kt

@@ -0,0 +1,96 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.utils
+
+import android.content.ContentResolver
+import android.database.Cursor
+import android.provider.MediaStore
+import androidx.core.net.toUri
+import ch.threema.app.services.ServicesConstants
+import java.io.File
+
+class RingtoneChecker(
+    private val contentResolver: ContentResolver,
+) {
+    fun isValidRingtoneUri(ringtoneUri: String?): Boolean {
+        when {
+            ringtoneUri.isNullOrEmpty() -> {
+                // silent ringtone
+                return true
+            }
+
+            ringtoneUri == ServicesConstants.PREFERENCES_NULL -> {
+                return false
+            }
+
+            ringtoneUri == DEFAULT_RINGTONE_URI -> {
+                return true
+            }
+
+            else -> {
+                val uri = ringtoneUri.toUri()
+                try {
+                    contentResolver.query(uri, PROJECTION, null, null, null).use { cursor ->
+                        if (cursor?.moveToFirst() == true) {
+                            if (cursor.pointsToValidFile() && cursor.isAcceptableTypeForRingtone()) {
+                                return true
+                            }
+                        }
+                    }
+                } catch (e: Exception) {
+                    // failed to check the ringtone's validity, consider it invalid
+                }
+                return false
+            }
+        }
+    }
+
+    private fun Cursor.pointsToValidFile(): Boolean {
+        val path = getString(getProjectionIndex(MediaStore.MediaColumns.DATA))
+        return path != null && File(path).exists()
+    }
+
+    private fun Cursor.isAcceptableTypeForRingtone(): Boolean {
+        // It seems that RingtoneManager (which is used to let the user pick a ringtone)
+        // sometimes doesn't respect the type filter given to it, and therefore
+        // not only returns ringtones, but also alarm and notification sounds.
+        // This isn't a big issue, as these can be played just the same, so we allow them here.
+        val isRingtone = getInt(getProjectionIndex(MediaStore.Audio.Media.IS_RINGTONE)) == 1
+        val isAlarm = getInt(getProjectionIndex(MediaStore.Audio.Media.IS_ALARM)) == 1
+        val isNotification = getInt(getProjectionIndex(MediaStore.Audio.Media.IS_NOTIFICATION)) == 1
+        return isRingtone || isAlarm || isNotification
+    }
+
+    companion object {
+        private const val DEFAULT_RINGTONE_URI = "content://settings/system/ringtone"
+
+        private val PROJECTION = arrayOf(
+            MediaStore.MediaColumns.DATA,
+            MediaStore.Audio.Media.IS_RINGTONE,
+            MediaStore.Audio.Media.IS_ALARM,
+            MediaStore.Audio.Media.IS_NOTIFICATION,
+        )
+
+        private fun getProjectionIndex(value: String): Int =
+            PROJECTION.indexOf(value)
+    }
+}

+ 27 - 0
app/src/main/java/ch/threema/app/utils/ThrowingConsumer.kt

@@ -0,0 +1,27 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.utils
+
+fun interface ThrowingConsumer<T> {
+    @Throws(Exception::class)
+    fun accept(value: T)
+}

+ 0 - 113
app/src/main/java/ch/threema/app/utils/ZipUtil.java

@@ -1,113 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2018-2024 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.utils;
-
-import android.content.ContentResolver;
-import android.net.Uri;
-
-import net.lingala.zip4j.io.outputstream.ZipOutputStream;
-import net.lingala.zip4j.model.ZipParameters;
-import net.lingala.zip4j.model.enums.AesKeyStrength;
-import net.lingala.zip4j.model.enums.CompressionLevel;
-import net.lingala.zip4j.model.enums.CompressionMethod;
-import net.lingala.zip4j.model.enums.EncryptionMethod;
-
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-
-public class ZipUtil {
-
-	/**
-	 * Get a ZipOutputStream that writes to an OutputStream pointed to by Uri
-	 * @param contetResolver ContentResolver to resolve the supplied Uri
-	 * @param zipFileUri Uri of the file the OutputStream writes to
-	 * @param password Desired password or null if no encryption is desired
-	 * @return ZipOutputStream
-	 * @throws IOException
-	 */
-	public static ZipOutputStream initializeZipOutputStream(ContentResolver contetResolver, Uri zipFileUri, String password) throws IOException {
-		OutputStream outputStream = contetResolver.openOutputStream(zipFileUri);
-		if (password != null) {
-			return new ZipOutputStream(outputStream, password.toCharArray());
-		}
-		return new ZipOutputStream(outputStream);
-	}
-
-	/**
-	 * Get a ZipOutputStream that writes to an OutputStream specified by File
-	 * @param zipFile Output file
-	 * @param password Desired password or null if no encryption is desired
-	 * @return ZipOutputStream
-	 * @throws IOException
-	 */
-	public static ZipOutputStream initializeZipOutputStream(File zipFile, String password) throws IOException {
-		FileOutputStream outputStream = new FileOutputStream(zipFile);
-		if (password != null) {
-			return new ZipOutputStream(outputStream, password.toCharArray());
-		}
-		return new ZipOutputStream(outputStream);
-	}
-
-	/**
-	 * Add contents of InputStream to specified ZipOutputStream and closes the provided InputStream
-	 * @param zipOutputStream
-	 * @param inputStream
-	 * @param filenameInZip
-	 * @param compress whether to compress the data (don't use for already compressed data like images)
-	 * @throws IOException
-	 */
-	public static void addZipStream(ZipOutputStream zipOutputStream, InputStream inputStream, String filenameInZip, boolean compress) throws IOException {
-		if (inputStream != null) {
-			try {
-				zipOutputStream.putNextEntry(createZipParameter(filenameInZip, compress));
-
-				byte[] buf = new byte[16384];
-				int nread;
-				while ((nread = inputStream.read(buf)) > 0) {
-					zipOutputStream.write(buf, 0, nread);
-				}
-				zipOutputStream.closeEntry();
-			} finally {
-				try {
-					inputStream.close();
-				} catch (IOException e) {
-					//
-				}
-			}
-		}
-	}
-
-	private static ZipParameters createZipParameter(String filenameInZip, boolean compress) {
-		ZipParameters parameters = new ZipParameters();
-		parameters.setCompressionMethod(CompressionMethod.DEFLATE);
-		parameters.setCompressionLevel(compress ? CompressionLevel.NORMAL : CompressionLevel.NO_COMPRESSION);
-		parameters.setEncryptFiles(true);
-		parameters.setEncryptionMethod(EncryptionMethod.AES);
-		parameters.setAesKeyStrength(AesKeyStrength.KEY_STRENGTH_256);
-		parameters.setFileNameInZip(filenameInZip);
-		return parameters;
-	}
-
-}

+ 6 - 6
app/src/main/java/ch/threema/app/voip/activities/CallActivity.java

@@ -535,8 +535,8 @@ public class CallActivity extends ThreemaActivity implements
 											int[] location = new int[2];
 											commonViews.audioSelectorButton.getLocationInWindow(location);
 											location[0] += (commonViews.audioSelectorButton.getWidth() / 2);
-											audioSelectorTooltip = new TooltipPopup(CallActivity.this, R.string.preferences__tooltip_audio_selector_hint,CallActivity.this);
-											audioSelectorTooltip.show(CallActivity.this, commonViews.audioSelectorButton, getString(R.string.tooltip_voip_enable_speakerphone), TooltipPopup.ALIGN_ABOVE_ANCHOR_ARROW_RIGHT, location, 5000);
+											audioSelectorTooltip = new TooltipPopup(CallActivity.this, R.string.preferences__tooltip_audio_selector_hint, CallActivity.this);
+											audioSelectorTooltip.show(CallActivity.this, commonViews.audioSelectorButton, null, getString(R.string.tooltip_voip_enable_speakerphone), TooltipPopup.Alignment.ABOVE_ANCHOR_ARROW_RIGHT, location, 5000);
 											audioSelectorTooltipShown = true;
 										}
 									}
@@ -556,8 +556,8 @@ public class CallActivity extends ThreemaActivity implements
 												commonViews.toggleOutgoingVideoButton.getLocationInWindow(location);
 												location[0] += (commonViews.toggleOutgoingVideoButton.getWidth() / 2);
 												location[1]	+= commonViews.toggleOutgoingVideoButton.getHeight();
-												toggleVideoTooltip = new TooltipPopup(CallActivity.this, 0, CallActivity.this);
-												toggleVideoTooltip.show(CallActivity.this, commonViews.toggleOutgoingVideoButton, getString(R.string.tooltip_voip_other_party_video_on), TooltipPopup.ALIGN_BELOW_ANCHOR_ARROW_RIGHT, location, 6000);
+												toggleVideoTooltip = new TooltipPopup(CallActivity.this, 0, CallActivity.this, 0, false);
+												toggleVideoTooltip.show(CallActivity.this, commonViews.toggleOutgoingVideoButton, null, getString(R.string.tooltip_voip_other_party_video_on), TooltipPopup.Alignment.BELOW_ANCHOR_ARROW_RIGHT, location, 6000);
 												toggleVideoTooltipShown = true;
 											}
 										}
@@ -1416,8 +1416,8 @@ public class CallActivity extends ThreemaActivity implements
 						v.getLocationInWindow(location);
 						location[0] += (v.getWidth() / 2);
 						location[1]	+= v.getHeight();
-						TooltipPopup tooltipPopup = new TooltipPopup(CallActivity.this, 0, CallActivity.this);
-						tooltipPopup.show(CallActivity.this, v, getString(R.string.tooltip_voip_other_party_video_disabled), TooltipPopup.ALIGN_BELOW_ANCHOR_ARROW_RIGHT, location, 3000);
+						TooltipPopup tooltipPopup = new TooltipPopup(CallActivity.this, 0, CallActivity.this, 0, false);
+						tooltipPopup.show(CallActivity.this, v, null, getString(R.string.tooltip_voip_other_party_video_disabled), TooltipPopup.Alignment.BELOW_ANCHOR_ARROW_RIGHT, location, 3000);
 					}
 					return;
 				}

+ 6 - 6
app/src/main/java/ch/threema/app/voip/activities/GroupCallActivity.kt

@@ -252,12 +252,12 @@ class GroupCallActivity : ThreemaActivity(), GenericAlertDialog.DialogClickListe
 							R.string.preferences__tooltip_gc_camera,
 							this
 						).show(
-							this,
-							views.buttonToggleCamera,
-							getString(R.string.tooltip_voip_turn_on_camera),
-							TooltipPopup.ALIGN_BELOW_ANCHOR_ARROW_RIGHT,
-							location,
-							2500
+							activity = this,
+							anchor = views.buttonToggleCamera,
+							text = getString(R.string.tooltip_voip_turn_on_camera),
+							alignment = TooltipPopup.Alignment.BELOW_ANCHOR_ARROW_RIGHT,
+							originLocation = location,
+							timeoutMs = 2500,
 						)
 						viewModel.toggleCameraTooltipShown = true
 					}

+ 17 - 23
app/src/main/java/ch/threema/app/workers/WorkSyncWorker.kt

@@ -27,6 +27,7 @@ import android.widget.Toast
 import androidx.annotation.StringRes
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.app.NotificationCompat
+import androidx.core.content.edit
 import androidx.preference.PreferenceManager
 import androidx.work.Constraints
 import androidx.work.Data
@@ -364,18 +365,15 @@ class WorkSyncWorker(private val context: Context, workerParameters: WorkerParam
     private fun resetRestrictions() {
         /* note that PreferenceService may not be available at this time */
         logger.debug("Reset Restrictions")
-        val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
-        if (sharedPreferences != null) {
-            val editor = sharedPreferences.edit()
-            if (editor != null) {
-                applyBooleanRestriction(editor, R.string.restriction__block_unknown, R.string.preferences__block_unknown) { it }
-                applyBooleanRestriction(editor, R.string.restriction__disable_screenshots, R.string.preferences__hide_screenshots) { it }
-                applyBooleanRestriction(editor, R.string.restriction__disable_save_to_gallery, R.string.preferences__save_media) { !it }
-                applyBooleanRestriction(editor, R.string.restriction__disable_message_preview, R.string.preferences__notification_preview) { !it }
+        (PreferenceManager.getDefaultSharedPreferences(context) ?: return)
+            .edit {
+                applyBooleanRestriction(R.string.restriction__block_unknown, R.string.preferences__block_unknown) { it }
+                applyBooleanRestriction(R.string.restriction__disable_screenshots, R.string.preferences__hide_screenshots) { it }
+                applyBooleanRestriction(R.string.restriction__disable_save_to_gallery, R.string.preferences__save_media) { !it }
+                applyBooleanRestriction(R.string.restriction__disable_message_preview, R.string.preferences__notification_preview) { !it }
                 applyBooleanRestrictionMapToInt(
-                    editor,
                     R.string.restriction__disable_send_profile_picture,
-                    R.string.preferences__profile_pic_release
+                    R.string.preferences__profile_pic_release,
                 ) {
                     if (it) {
                         PreferenceService.PROFILEPIC_RELEASE_NOBODY
@@ -383,14 +381,12 @@ class WorkSyncWorker(private val context: Context, workerParameters: WorkerParam
                         PreferenceService.PROFILEPIC_RELEASE_EVERYONE
                     }
                 }
-                applyBooleanRestriction(editor, R.string.restriction__disable_calls, R.string.preferences__voip_enable) { !it }
-                applyBooleanRestriction(editor, R.string.restriction__disable_video_calls, R.string.preferences__voip_video_enable) { !it }
-                applyBooleanRestriction(editor, R.string.restriction__disable_group_calls, R.string.preferences__group_calls_enable) { !it }
-                applyBooleanRestriction(editor, R.string.restriction__hide_inactive_ids, R.string.preferences__show_inactive_contacts) { !it }
-                editor.apply()
-                applyNicknameRestriction()
+                applyBooleanRestriction(R.string.restriction__disable_calls, R.string.preferences__voip_enable) { !it }
+                applyBooleanRestriction(R.string.restriction__disable_video_calls, R.string.preferences__voip_video_enable) { !it }
+                applyBooleanRestriction(R.string.restriction__disable_group_calls, R.string.preferences__group_calls_enable) { !it }
+                applyBooleanRestriction(R.string.restriction__hide_inactive_ids, R.string.preferences__show_inactive_contacts) { !it }
             }
-        }
+        applyNicknameRestriction()
     }
 
     override fun getForegroundInfoAsync(): ListenableFuture<ForegroundInfo> {
@@ -409,26 +405,24 @@ class WorkSyncWorker(private val context: Context, workerParameters: WorkerParam
         return Futures.immediateFuture(ForegroundInfo(ThreemaApplication.WORK_SYNC_NOTIFICATION_ID, notification))
     }
 
-    private fun applyBooleanRestriction(
-        editor: Editor,
+    private fun Editor.applyBooleanRestriction(
         @StringRes restrictionKeyRes: Int,
         @StringRes settingKeyRes: Int,
         mapper: (Boolean) -> Boolean
     ) {
         AppRestrictionUtil.getBooleanRestriction(context.getString(restrictionKeyRes))?.let {
-            editor.putBoolean(context.getString(settingKeyRes), mapper(it))
+            putBoolean(context.getString(settingKeyRes), mapper(it))
         }
     }
 
     @Suppress("SameParameterValue")
-    private fun applyBooleanRestrictionMapToInt(
-        editor: Editor,
+    private fun Editor.applyBooleanRestrictionMapToInt(
         @StringRes restrictionKeyRes: Int,
         @StringRes settingKeyRes: Int,
         mapper: (Boolean) -> Int
     ) {
         AppRestrictionUtil.getBooleanRestriction(context.getString(restrictionKeyRes))?.let {
-            editor.putInt(context.getString(settingKeyRes), mapper(it))
+            putInt(context.getString(settingKeyRes), mapper(it))
         }
     }
 

+ 42 - 0
app/src/main/java/ch/threema/data/repositories/EmojiReactionsRepository.kt

@@ -25,6 +25,7 @@ import android.database.sqlite.SQLiteException
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.emojis.EmojiUtil
 import ch.threema.app.managers.CoreServiceManager
+import ch.threema.app.utils.ThrowingConsumer
 import ch.threema.base.ThreemaException
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.data.ModelTypeCache
@@ -84,6 +85,37 @@ class EmojiReactionsRepository(
         }
     }
 
+    /**
+     * This deletes all reactions from the database.
+     */
+    fun deleteAllReactions() {
+        emojiReactionDao.deleteAll()
+    }
+
+    fun getContactReactionsCount(): Long {
+        return emojiReactionDao.getContactReactionsCount()
+    }
+
+    fun getGroupReactionsCount(): Long {
+        return emojiReactionDao.getGroupReactionsCount()
+    }
+
+    /**
+     * This method is only intended for backup creation.
+     * Iteration is ordered by id of the referenced messages.
+     */
+    fun iterateAllContactReactionsForBackup(consumer: ThrowingConsumer<EmojiReactionsDao.BackupContactReaction>) {
+        emojiReactionDao.iterateAllContactBackupReactions(consumer)
+    }
+
+    /**
+     * This method is only intended for backup creation.
+     * Iteration is ordered by id of the referenced messages.
+     */
+    fun iterateAllGroupReactionsForBackup(consumer: ThrowingConsumer<EmojiReactionsDao.BackupGroupReaction>) {
+        emojiReactionDao.iterateAllGroupBackupReactions(consumer)
+    }
+
     /**
      * Add reactions from the old ack/dec system to an existing list of emoji reactions
      */
@@ -183,6 +215,16 @@ class EmojiReactionsRepository(
         }
     }
 
+    @Throws(Exception::class)
+    fun restoreContactReactions(block: EmojiReactionsDao.TransactionalReactionInsertScope) {
+        emojiReactionDao.insertContactReactionsInTransaction(block)
+    }
+
+    @Throws(Exception::class)
+    fun restoreGroupReactions(block: EmojiReactionsDao.TransactionalReactionInsertScope) {
+        emojiReactionDao.insertGroupReactionsInTransaction(block)
+    }
+
     /**
      * Removes a [DbEmojiReaction] from the db.
      * Call this before saving the edited message

+ 50 - 0
app/src/main/java/ch/threema/data/storage/EmojiReactionsDao.kt

@@ -22,6 +22,7 @@
 package ch.threema.data.storage
 
 import android.database.sqlite.SQLiteException
+import ch.threema.app.utils.ThrowingConsumer
 import ch.threema.storage.models.AbstractMessageModel
 
 interface EmojiReactionsDao {
@@ -50,4 +51,53 @@ interface EmojiReactionsDao {
      * Find all reactions referred to by the specified message id
      */
     fun findAllByMessage(messageModel: AbstractMessageModel): List<DbEmojiReaction>
+
+    /**
+     * Delete all reactions from the database.
+     */
+    fun deleteAll()
+
+    fun getContactReactionsCount(): Long
+
+    fun getGroupReactionsCount(): Long
+
+    /**
+     * Iteration is ordered by the id of the referenced messages.
+     */
+    fun iterateAllContactBackupReactions(consumer: ThrowingConsumer<BackupContactReaction>)
+
+    /**
+     * Iteration is ordered by the id of the referenced messages.
+     */
+    fun iterateAllGroupBackupReactions(consumer: ThrowingConsumer<BackupGroupReaction>)
+
+    fun insertContactReactionsInTransaction(block: TransactionalReactionInsertScope)
+
+    fun insertGroupReactionsInTransaction(block: TransactionalReactionInsertScope)
+
+    data class BackupContactReaction(
+        val contactIdentity: String,
+        val apiMessageId: String,
+        val senderIdentity: String,
+        val emojiSequence: String,
+        val reactedAt: Long
+    )
+
+    data class BackupGroupReaction(
+        val apiGroupId: String,
+        val groupCreatorIdentity: String,
+        val apiMessageId: String,
+        val senderIdentity: String,
+        val emojiSequence: String,
+        val reactedAt: Long
+    )
+
+    fun interface ReactionInsertHandle {
+        fun insert(entry: DbEmojiReaction)
+    }
+
+    fun interface TransactionalReactionInsertScope {
+        @Throws(Exception::class)
+        fun runInserts(handle: ReactionInsertHandle)
+    }
 }

+ 181 - 8
app/src/main/java/ch/threema/data/storage/EmojiReactionsDaoImpl.kt

@@ -24,12 +24,14 @@ package ch.threema.data.storage
 import android.content.ContentValues
 import android.database.Cursor
 import androidx.sqlite.db.SupportSQLiteOpenHelper
+import ch.threema.app.utils.ThrowingConsumer
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.data.repositories.EmojiReactionEntryCreateException
 import ch.threema.storage.factories.ContactEmojiReactionModelFactory
 import ch.threema.storage.factories.GroupEmojiReactionModelFactory
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.GroupMessageModel
+import ch.threema.storage.models.GroupModel
 import ch.threema.storage.models.MessageModel
 import net.zetetic.database.sqlcipher.SQLiteDatabase
 
@@ -40,17 +42,10 @@ class EmojiReactionsDaoImpl(
 ) : EmojiReactionsDao {
 
     override fun create(entry: DbEmojiReaction, messageModel: AbstractMessageModel) {
-        val contentValues = ContentValues()
-
-        contentValues.put(DbEmojiReaction.COLUMN_MESSAGE_ID, entry.messageId)
-        contentValues.put(DbEmojiReaction.COLUMN_SENDER_IDENTITY, entry.senderIdentity)
-        contentValues.put(DbEmojiReaction.COLUMN_EMOJI_SEQUENCE, entry.emojiSequence)
-        contentValues.put(DbEmojiReaction.COLUMN_REACTED_AT, entry.reactedAt.time)
-
         val table = getReactionTableForMessage(messageModel) ?: throw EmojiReactionEntryCreateException(
             IllegalArgumentException("Cannot create reaction entry for message of class ${messageModel.javaClass.name}")
         )
-        sqlite.writableDatabase.insert(table, SQLiteDatabase.CONFLICT_ROLLBACK, contentValues)
+        sqlite.writableDatabase.insert(table, SQLiteDatabase.CONFLICT_ROLLBACK, entry.getContentValues())
     }
 
     override fun remove(entry: DbEmojiReaction, messageModel: AbstractMessageModel) {
@@ -90,6 +85,143 @@ class EmojiReactionsDaoImpl(
         return cursor.use { getResult(it) }
     }
 
+    override fun deleteAll() {
+        val db = sqlite.writableDatabase
+        db.beginTransaction()
+        try {
+            db.execSQL("DELETE FROM ${ContactEmojiReactionModelFactory.TABLE}")
+            db.execSQL("DELETE FROM ${GroupEmojiReactionModelFactory.TABLE}")
+            db.setTransactionSuccessful()
+        } finally {
+            db.endTransaction()
+        }
+    }
+
+    override fun getContactReactionsCount(): Long {
+        return getReactionCount(ContactEmojiReactionModelFactory.TABLE)
+    }
+
+    override fun getGroupReactionsCount(): Long {
+        return getReactionCount(GroupEmojiReactionModelFactory.TABLE)
+    }
+
+    private fun getReactionCount(table: String): Long {
+        val query = "SELECT COUNT(*) FROM `$table`"
+        return sqlite.readableDatabase
+            .query(query)
+            .use {
+                if (it.moveToFirst()) {
+                    it.getLong(0)
+                } else {
+                    0
+                }
+            }
+    }
+
+    override fun iterateAllContactBackupReactions(consumer: ThrowingConsumer<EmojiReactionsDao.BackupContactReaction>) {
+        val resultColumnContactIdentity = "contactIdentity"
+        val resultColumnApiMessageId = "apiId"
+        val resultColumnSenderIdentity = "senderIdentity"
+        val resultColumnEmojiSequence = "emojiSequence"
+        val resultColumnReactedAt = "reactedAt"
+
+        val query = """
+            SELECT
+                m.${MessageModel.COLUMN_IDENTITY} as $resultColumnContactIdentity,
+                m.${MessageModel.COLUMN_API_MESSAGE_ID} as $resultColumnApiMessageId,
+                r.${DbEmojiReaction.COLUMN_SENDER_IDENTITY} as $resultColumnSenderIdentity,
+                r.${DbEmojiReaction.COLUMN_EMOJI_SEQUENCE} as $resultColumnEmojiSequence,
+                r.${DbEmojiReaction.COLUMN_REACTED_AT} as $resultColumnReactedAt
+            FROM ${ContactEmojiReactionModelFactory.TABLE} r JOIN ${MessageModel.TABLE} m
+            ON r.${DbEmojiReaction.COLUMN_MESSAGE_ID} = m.${MessageModel.COLUMN_ID}
+            ORDER BY r.${DbEmojiReaction.COLUMN_MESSAGE_ID}
+        """.trimIndent()
+
+        sqlite.readableDatabase.query(query).use { cursor ->
+            val columnIndexContactIdentity = cursor.getColumnIndexOrThrow(resultColumnContactIdentity)
+            val columnIndexApiMessageId = cursor.getColumnIndexOrThrow(resultColumnApiMessageId)
+            val columnIndexSenderIdentity = cursor.getColumnIndexOrThrow(resultColumnSenderIdentity)
+            val columnIndexEmojiSequence = cursor.getColumnIndexOrThrow(resultColumnEmojiSequence)
+            val columnIndexReactedAt = cursor.getColumnIndexOrThrow(resultColumnReactedAt)
+
+            while (cursor.moveToNext()) {
+                tryHandlingReactionEntry {
+                    consumer.accept(
+                        EmojiReactionsDao.BackupContactReaction(
+                            contactIdentity = cursor.getString(columnIndexContactIdentity),
+                            apiMessageId = cursor.getString(columnIndexApiMessageId),
+                            senderIdentity = cursor.getString(columnIndexSenderIdentity),
+                            emojiSequence = cursor.getString(columnIndexEmojiSequence),
+                            reactedAt = cursor.getLong(columnIndexReactedAt)
+                        )
+                    )
+                }
+            }
+        }
+    }
+
+    /**
+     * Results are ordered by the message id
+     */
+    override fun iterateAllGroupBackupReactions(consumer: ThrowingConsumer<EmojiReactionsDao.BackupGroupReaction>) {
+        val resultColumnGroupId = "groupId"
+        val resultColumnGroupCreatorIdentity = "groupCreatorIdentity"
+        val resultColumnApiMessageId = "apiId"
+        val resultColumnSenderIdentity = "senderIdentity"
+        val resultColumnEmojiSequence = "emojiSequence"
+        val resultColumnReactedAt = "reactedAt"
+
+        val query = """
+            SELECT
+                g.${GroupModel.COLUMN_API_GROUP_ID} as $resultColumnGroupId,
+                g.${GroupModel.COLUMN_CREATOR_IDENTITY} as $resultColumnGroupCreatorIdentity,
+                m.${GroupMessageModel.COLUMN_API_MESSAGE_ID} as $resultColumnApiMessageId,
+                r.${DbEmojiReaction.COLUMN_SENDER_IDENTITY} as $resultColumnSenderIdentity,
+                r.${DbEmojiReaction.COLUMN_EMOJI_SEQUENCE} as $resultColumnEmojiSequence,
+                r.${DbEmojiReaction.COLUMN_REACTED_AT} as $resultColumnReactedAt
+            FROM
+                ${GroupEmojiReactionModelFactory.TABLE} r,
+                ${GroupMessageModel.TABLE} m,
+                ${GroupModel.TABLE} g
+            WHERE
+                r.${DbEmojiReaction.COLUMN_MESSAGE_ID} = m.${MessageModel.COLUMN_ID}
+                AND m.${GroupMessageModel.COLUMN_GROUP_ID} = g.${GroupModel.COLUMN_ID}
+            ORDER BY r.${DbEmojiReaction.COLUMN_MESSAGE_ID}
+        """.trimIndent()
+
+        sqlite.readableDatabase.query(query).use { cursor ->
+            val columnIndexGroupId = cursor.getColumnIndexOrThrow(resultColumnGroupId)
+            val columnIndexGroupCreatorIdentity = cursor.getColumnIndexOrThrow(resultColumnGroupCreatorIdentity)
+            val columnIndexApiMessageId = cursor.getColumnIndexOrThrow(resultColumnApiMessageId)
+            val columnIndexSenderIdentity = cursor.getColumnIndexOrThrow(resultColumnSenderIdentity)
+            val columnIndexEmojiSequence = cursor.getColumnIndexOrThrow(resultColumnEmojiSequence)
+            val columnIndexReactedAt = cursor.getColumnIndexOrThrow(resultColumnReactedAt)
+
+            while (cursor.moveToNext()) {
+                tryHandlingReactionEntry {
+                    consumer.accept(
+                        EmojiReactionsDao.BackupGroupReaction(
+                            apiGroupId = cursor.getString(columnIndexGroupId),
+                            groupCreatorIdentity = cursor.getString(columnIndexGroupCreatorIdentity),
+                            apiMessageId = cursor.getString(columnIndexApiMessageId),
+                            senderIdentity = cursor.getString(columnIndexSenderIdentity),
+                            emojiSequence = cursor.getString(columnIndexEmojiSequence),
+                            reactedAt = cursor.getLong(columnIndexReactedAt)
+                        )
+                    )
+                }
+            }
+        }
+    }
+
+    private fun tryHandlingReactionEntry(block: () -> Unit) {
+        try {
+            block()
+        } catch (e: Exception) {
+            logger.error("Skip invalid reaction", e)
+        }
+    }
+
     private fun getReactionTableForMessage(messageModel: AbstractMessageModel): String? {
         return when (messageModel) {
             is GroupMessageModel -> GroupEmojiReactionModelFactory.TABLE
@@ -138,4 +270,45 @@ class EmojiReactionsDaoImpl(
 
         return result
     }
+
+    override fun insertContactReactionsInTransaction(block: EmojiReactionsDao.TransactionalReactionInsertScope) {
+        insertReactionsInTransaction(
+            ContactEmojiReactionModelFactory.TABLE,
+            block
+        )
+    }
+
+    override fun insertGroupReactionsInTransaction(block: EmojiReactionsDao.TransactionalReactionInsertScope) {
+        insertReactionsInTransaction(
+            GroupEmojiReactionModelFactory.TABLE,
+            block
+        )
+    }
+
+    private fun insertReactionsInTransaction(table: String, block: EmojiReactionsDao.TransactionalReactionInsertScope) {
+        val database = sqlite.writableDatabase
+        database.beginTransaction()
+        try {
+            block.runInserts { entry ->
+                val success = database.insert(
+                    table,
+                    SQLiteDatabase.CONFLICT_IGNORE,
+                    entry.getContentValues()
+                ) >= 0
+                if (logger.isDebugEnabled) {
+                    logger.debug("Insert reaction {}, success={}", entry, success)
+                }
+            }
+            database.setTransactionSuccessful()
+        } finally {
+            database.endTransaction()
+        }
+    }
+
+    private fun DbEmojiReaction.getContentValues() = ContentValues().apply {
+        put(DbEmojiReaction.COLUMN_MESSAGE_ID, messageId)
+        put(DbEmojiReaction.COLUMN_SENDER_IDENTITY, senderIdentity)
+        put(DbEmojiReaction.COLUMN_EMOJI_SEQUENCE, emojiSequence)
+        put(DbEmojiReaction.COLUMN_REACTED_AT, reactedAt.time)
+    }
 }

+ 4 - 14
app/src/main/java/ch/threema/logging/backend/DebugLogFileBackend.java

@@ -26,7 +26,6 @@ import android.os.HandlerThread;
 import android.os.Looper;
 import android.util.Log;
 
-import net.lingala.zip4j.io.outputstream.ZipOutputStream;
 import net.lingala.zip4j.model.ZipParameters;
 import net.lingala.zip4j.model.enums.CompressionLevel;
 import net.lingala.zip4j.model.enums.CompressionMethod;
@@ -44,7 +43,7 @@ import androidx.annotation.Nullable;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.services.FileService;
-import ch.threema.app.utils.ZipUtil;
+import ch.threema.app.utils.FileHandlingZipOutputStream;
 import ch.threema.app.utils.executor.HandlerExecutor;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.logging.LogLevel;
@@ -52,11 +51,8 @@ import java8.util.concurrent.CompletableFuture;
 
 /**
  * A logging backend that logs to the debug log file.
- *
  * This backend is only enabled if the user enabled the debug log.
- *
  * The log file is deleted when calling `setEnabled(false)`.
- *
  * A zipped log file can be requested with `getZipFile()`.
  */
 public class DebugLogFileBackend implements LogBackend {
@@ -117,9 +113,7 @@ public class DebugLogFileBackend implements LogBackend {
 
 	/**
 	 * Enable or disable logging to the debug log file.
-	 *
 	 * By default, it is disabled.
-	 *
 	 * When disabling the logging, then the file is deleted if it already exists.
 	 */
 	public synchronized static void setEnabled(boolean enabled) {
@@ -152,9 +146,8 @@ public class DebugLogFileBackend implements LogBackend {
 	}
 
 	/**
-	 * Return a `File` instance pointing to the debug log file.
-	 *
-	 * Returns `null` if the log file directory could not be created.
+	 * @return a {@link File} instance pointing to the debug log file or `null` if the log file
+     *         directory could not be created.
 	 */
 	@Nullable
 	private static File getLogFile() {
@@ -173,9 +166,7 @@ public class DebugLogFileBackend implements LogBackend {
 
 	/**
 	 * If the logger is enabled, write the log asynchronously to the log file.
-	 *
 	 * I/O is dispatched to the handler thread.
-	 *
 	 * A CompletableFuture is returned, which resolves once processing is finished.
 	 * The returned value is TRUE if the log was written successfully,
 	 * FALSE if writing the log failed, and null if the logger was not enabled.
@@ -262,7 +253,6 @@ public class DebugLogFileBackend implements LogBackend {
 
 	/**
 	 * If the logger is enabled, write the log to the log file.
-	 *
 	 * Note: I/O is done asynchronously, so the log may not yet be fully written
 	 * to storage when this method returns!
 	 *
@@ -311,7 +301,7 @@ public class DebugLogFileBackend implements LogBackend {
 		// Create and return ZIP
 		try (
 			final FileInputStream inputStream = new FileInputStream(logFile);
-			final ZipOutputStream zipOutputStream = ZipUtil.initializeZipOutputStream(tempDebugLogArchive, null)
+			final FileHandlingZipOutputStream zipOutputStream = FileHandlingZipOutputStream.initializeZipOutputStream(tempDebugLogArchive, null)
 		) {
 			final ZipParameters parameters = createZipParameters(logFile.getName());
 			zipOutputStream.putNextEntry(parameters);

+ 1 - 2
app/src/main/java/ch/threema/storage/factories/GroupMessageModelFactory.java

@@ -271,8 +271,7 @@ public class GroupMessageModelFactory extends AbstractMessageModelFactory {
 						+ " AND " + GroupMessageModel.COLUMN_OUTBOX + "=0"
 						+ " AND " + GroupMessageModel.COLUMN_IS_SAVED + "=1"
 						+ " AND " + GroupMessageModel.COLUMN_IS_READ + "=0"
-						+ " AND " + GroupMessageModel.COLUMN_IS_STATUS_MESSAGE + "=0"
-						+ " AND " + GroupMessageModel.COLUMN_DELETED_AT + " IS NULL",
+						+ " AND " + GroupMessageModel.COLUMN_IS_STATUS_MESSAGE + "=0",
 				new String[]{
 						String.valueOf(groupId)
 				}

+ 1 - 2
app/src/main/java/ch/threema/storage/factories/MessageModelFactory.java

@@ -239,8 +239,7 @@ public class MessageModelFactory extends AbstractMessageModelFactory {
                 + " AND " + MessageModel.COLUMN_OUTBOX + "=0"
                 + " AND " + MessageModel.COLUMN_IS_SAVED + "=1"
                 + " AND " + MessageModel.COLUMN_IS_READ + "=0"
-                + " AND " + MessageModel.COLUMN_IS_STATUS_MESSAGE + "=0"
-                + " AND " + MessageModel.COLUMN_DELETED_AT + " IS NULL",
+                + " AND " + MessageModel.COLUMN_IS_STATUS_MESSAGE + "=0",
             new String[]{
                 identity
             }

+ 3 - 0
app/src/main/java/ch/threema/storage/models/GroupMessageModel.java

@@ -31,6 +31,9 @@ public class GroupMessageModel extends AbstractMessageModel {
 	public static final String COLUMN_GROUP_MESSAGE_STATES = "groupMessageStates";
 
 	private int groupId;
+
+    // TODO(ANDR-3325): This is only used for group ack/dec and can therefore be removed
+    //  when the database is migrated.
 	private Map<String, Object> groupMessageStates;
 
 	public GroupMessageModel() {

+ 2 - 1
app/src/main/res/layout/activity_emojireactions_overview.xml

@@ -67,7 +67,8 @@
                     android:paddingLeft="8dp"
                     android:clickable="false"
                     android:importantForAccessibility="no"
-                    app:srcCompat="@drawable/ic_info_outline" />
+                    app:srcCompat="@drawable/ic_info_outline"
+                    app:tint="?attr/colorOnBackground" />
 
                 <ch.threema.app.emojis.EmojiTextView
                     android:id="@+id/infobox_text"

+ 25 - 6
app/src/main/res/layout/popup_tooltip.xml

@@ -4,6 +4,7 @@
   -->
 <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="wrap_content"
     android:layout_height="wrap_content"
     android:layout_marginRight="@dimen/tooltip_layout_margin_right"
@@ -40,15 +41,33 @@
                 android:layout_marginRight="8dp"
                 app:srcCompat="@drawable/ic_badge_work_24dp" />
 
-            <ch.threema.app.emojis.EmojiTextView
-                android:id="@+id/label"
-                style="@style/Threema.TextAppearance.BodyMedium"
+            <LinearLayout
+                android:orientation="vertical"
                 android:layout_width="0dp"
                 android:layout_height="wrap_content"
                 android:layout_gravity="left|center_vertical"
-                android:layout_weight="1"
-                android:textColor="?colorOnTertiaryContainer"
-                android:textSize="@dimen/tooltip_text_size" />
+                android:layout_weight="1">
+
+                <ch.threema.app.emojis.EmojiTextView
+                    android:id="@+id/title"
+                    style="@style/Threema.TextAppearance.BodyMedium"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:textColor="?colorOnTertiaryContainer"
+                    android:textSize="@dimen/tooltip_title_size"
+                    android:textStyle="bold"
+                    tools:text="Tooltip Title" />
+
+                <ch.threema.app.emojis.EmojiTextView
+                    android:id="@+id/label"
+                    style="@style/Threema.TextAppearance.BodyMedium"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:textColor="?colorOnTertiaryContainer"
+                    android:textSize="@dimen/tooltip_text_size"
+                    tools:text="Tooltip Text"/>
+
+            </LinearLayout>
 
             <com.google.android.material.button.MaterialButton
                 style="@style/Threema.MaterialButton.Minimal"

+ 0 - 3
app/src/main/res/values-be-rBY/strings.xml

@@ -311,7 +311,6 @@
     <string name="state_dialog_edited">Адрэд.</string>
     <string name="state_dialog_deleted">Выдалена</string>
     <string name="state_dialog_status">Статус</string>
-    <string name="title_tab_recent">Апошні</string>
     <string name="no_recent_conversations">Размовы не знойдзены</string>
     <string name="save_changes">Захаваць</string>
     <string name="group_created_confirm">Суполка паспяхова створана</string>
@@ -473,7 +472,6 @@
     <string name="back">Назад</string>
     <string name="wearable_reply">Адказаць</string>
     <string name="wearable_reply_label">Адказаць %s</string>
-    <string name="message_acknowledged">Згодзен</string>
     <string name="push_disable_text">Калі працягнуць, замест службы push-апавяшчэнняў сістэмы будзе выкарыстоўвацца функцыя Threema Push.  Гэта дапамагае скараціць «лічбавы след», але патрабуе настройкі тэлефона такім чынам, каб праграма магла працаваць у фонавым рэжыме.  Калі адпаведныя параметры не ўдаецца знайсці ў настройках прылады, паспрабуйце звярнуцца ў службу падтрымкі вытворцы вашай прылады за дапамогай з настройкай.</string>
     <string name="ballot_intermediate_results_show">Паказаць прамежкавыя вынікі</string>
     <string name="converting_video">Апрацоўка відэа</string>
@@ -590,7 +588,6 @@
     <string name="permission_storage_required">Каб адпраўляць медыя, дазвольце Threema доступ да сховішча.</string>
     <string name="permission_location_required">Каб адправіць месцазнаходжанне, дазвольце Threema атрымаць доступ да вашай пазіцыі.</string>
     <string name="permission_contacts_required">Каб адпраўляць кантакты, дазвольце Threema чытанне кантактаў.</string>
-    <string name="message_declined">Не згодны</string>
     <string name="notifications_settings">Налады апавяшчэнняў</string>
     <string name="notifications_default">Прадвызначаны налады</string>
     <string name="notifications_until">Да% s</string>

+ 5 - 3
app/src/main/res/values-ca/strings.xml

@@ -304,7 +304,6 @@ les dades de la vostra còpia de seguretat.</string>
     <string name="state_dialog_posted">Enviat</string>
     <string name="state_dialog_modified">Actualitzat</string>
     <string name="state_dialog_status">Estat</string>
-    <string name="title_tab_recent">Recent</string>
     <string name="no_recent_conversations">No s\'han trobat xats</string>
     <string name="save_changes">Desar</string>
     <string name="group_created_confirm">Grup creat correctament</string>
@@ -460,7 +459,6 @@ de contacte</string>
     <string name="back">Tornar</string>
     <string name="wearable_reply">Respondre</string>
     <string name="wearable_reply_label">Respondre a %s</string>
-    <string name="message_acknowledged">«Acord» enviat</string>
     <string name="push_disable_text">Si continueu, es desactivaran els missatges push i Threema comprovarà si hi ha missatges nous cada 15 minuts.</string>
     <string name="ballot_intermediate_results_show">Mostrar resultats intermedis</string>
     <string name="converting_video">Processant el vídeo</string>
@@ -568,7 +566,8 @@ de contacte</string>
     <string name="permission_storage_required">Per desar o enviar multimèdia, doneu a Threema permís per accedir a l\'emmagatzematge.</string>
     <string name="permission_location_required">Per enviar una localització, doneu a Threema permís per accedir a la vostra posició.</string>
     <string name="permission_contacts_required">Per enviar contactes, doneu a Threema permís per llegir els contactes.</string>
-    <string name="message_declined">«En desacord» enviat</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
     <string name="notifications_settings">Configuració de les notificacions</string>
     <string name="notifications_default">Configuració del sistema</string>
     <string name="notifications_until">Fins %s</string>
@@ -1312,6 +1311,9 @@ Si esteu canviant a un dispositiu nou, desinstal·leu o desactiveu %s al disposi
     <string name="organization_type">Organització</string>
     <string name="messages_cannot_be_recovered">No podreu recuperar els missatges.</string>
     <!-- accessibility -->
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
     <plurals name="contacts_counter_label">
         <item quantity="one">%d contacte</item>
         <item quantity="other">%d contactes</item>

+ 10 - 2
app/src/main/res/values-cs/strings.xml

@@ -592,8 +592,10 @@ možné je obnovit.</string>
     <string name="permission_storage_required">Chcete‑li uložit či odeslat média, povolte aplikaci Threema oprávnění přístupu k úložišti.</string>
     <string name="permission_location_required">Chcete‑li odeslat polohu, povolte aplikaci Threema oprávnění přístupu k poloze vašeho zařízení.</string>
     <string name="permission_contacts_required">Chcete‑li odeslat kontakty, povolte aplikaci Threema oprávnění přístupu ke kontaktům.</string>
-    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">„Palec nahoru“ odeslán</string>
-    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">„Palec dolů“ odeslán</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <string name="message_acknowledged">„Palec nahoru“ odeslán</string>
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
+    <string name="message_declined">„Palec dolů“ odeslán</string>
     <string name="notifications_settings">Nastavení oznámení</string>
     <string name="notifications_default">Výchozí nastavení</string>
     <string name="notifications_until">Do %s</string>
@@ -1604,6 +1606,12 @@ přátelům vás automaticky najít, pokud vás mají v adresáři svého telef
     <string name="emoji_reactions_cannot_remove_body">Kontakt %1$s používá verzi aplikace, která prozatím nepodporuje odstraňování emoji reakcí nebo odesílání více než jedné emoji reakce. Stále můžete nahradit 👍 za 👎 a naopak.</string>
     <string name="emoji_reactions_cannot_remove_group_body">Žádný ze členů skupiny nepoužívá verzi aplikace, která podporuje odstraňování emoji reakcí nebo odesílání více než jedné emoji reakce. Stále však můžete nahradit 👍 za 👎 a naopak.</string>
     <string name="emoji_reactions_cannot_remove_v1_body">Verze vaší aplikace prozatím nepodporuje odstraňování emoji reakcí nebo odesílání více než jedné emoji reakce.</string>
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <string name="emoji_reactions_message_details_hint">Tato zpráva obsahuje emoji reakce. Chcete-li je zobrazit všechny, dlouze klepněte v konverzaci na jakoukoliv emoji bublinu.</string>
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_title">Emoji reakce</string>
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_text">Tato zpráva obsahuje emoji reakce. Chcete-li je zobrazit všechny, dlouze klepněte na jakoukoliv emoji bublinu v konverzaci.</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d kontakt</item>
         <item quantity="few">%d kontakty</item>

+ 8 - 2
app/src/main/res/values-de/strings.xml

@@ -631,8 +631,10 @@ sicheren Ort gesichert oder ausgedruckt haben.</string>
     <string name="permission_storage_required">Aktivieren Sie die Speicher-Berechtigung, um Medien zu sichern oder zu senden.</string>
     <string name="permission_location_required">Aktivieren Sie die Standort-Berechtigung, um Ihre Position zu senden.</string>
     <string name="permission_contacts_required">Aktivieren Sie die Kontakte-Berechtigung, um einen Kontakt zu senden.</string>
-    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">«Daumen hoch» gesendet</string>
-    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">«Daumen runter» gesendet</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <string name="message_acknowledged">«Daumen hoch» gesendet</string>
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
+    <string name="message_declined">«Daumen runter» gesendet</string>
     <string name="notifications_settings">Benachrichtigungseinstellungen</string>
     <string name="notifications_default">Standardeinstellung</string>
     <string name="notifications_until">Bis %s</string>
@@ -1657,6 +1659,10 @@ sicheren Ort gesichert oder ausgedruckt haben.</string>
     <string name="emoji_reactions_cannot_remove_v1_body">Ihre App-Version unterstützt das Entfernen von Emoji-Reaktionen oder das Senden mehrerer Emoji-Reaktionen noch nicht.</string>
     <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
     <string name="emoji_reactions_message_details_hint">Diese Nachricht hat Reaktionen. Halten Sie im Chat ein Reaktions-Emoji gedrückt, um alle zu sehen.</string>
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_title">Emoji-Reaktion</string>
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_text">Diese Nachricht hat Emoji-Reaktionen. Tippen Sie die Emojis an und halten Sie sie gedrückt, um alle zu sehen.</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d Kontakt</item>
         <item quantity="other">%d Kontakte</item>

+ 10 - 2
app/src/main/res/values-es/strings.xml

@@ -478,7 +478,6 @@ Introduzca una pregunta para su sondeo.</string>
     <string name="back">Volver</string>
     <string name="wearable_reply">Contestar</string>
     <string name="wearable_reply_label">Contestar a %s</string>
-    <string name="message_acknowledged">Mensaje confirmado</string>
     <string name="push_disable_text">Si continúa, se utilizará \"Threema Push\" en lugar del servicio de inserción del sistema. Esto ayuda a reducir su huella digital, pero requiere que su teléfono esté configurado de manera que permita que la aplicación se ejecute en segundo plano. Es posible que quiera ponerse en contacto con el servicio de asistencia del fabricante de su dispositivo para obtener ayuda con la configuración si no encuentra las opciones correspondientes en los ajustes del dispositivo.</string>
     <string name="ballot_intermediate_results_show">Mostrar resultados provisionales</string>
     <string name="converting_video">Procesando vídeo</string>
@@ -598,7 +597,10 @@ almacenado.</string>
     <string name="permission_storage_required">Para enviar archivos multimedia, permita a Threema acceder a su almacenamiento.</string>
     <string name="permission_location_required">Para enviar una ubicación, permita a Threema acceder a su posición.</string>
     <string name="permission_contacts_required">Para enviar contactos, permita a Threema acceder a su agenda de contactos.</string>
-    <string name="message_declined">«Rechazo» enviado</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <string name="message_acknowledged">«Me gusta» enviado</string>
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
+    <string name="message_declined">«Me gusta» enviado</string>
     <string name="notifications_settings">Configuración de las notificaciones</string>
     <string name="notifications_default">Configuración por defecto</string>
     <string name="notifications_until">Hasta %s</string>
@@ -1613,6 +1615,12 @@ almacenado.</string>
     <string name="emoji_reactions_cannot_remove_body">%1$s usa una versión de la aplicación que aún no admite la eliminación de reacciones de emojis ni el envío de más de una reacción de emojis. Aún puedes reemplazar 👍 por 👎 o viceversa.</string>
     <string name="emoji_reactions_cannot_remove_group_body">Ninguno de los miembros del grupo usa una versión de la aplicación que permita eliminar las reacciones de emojis o enviar más de una reacción de emojis. Puedes reemplazar 👍 por 👎 o viceversa.</string>
     <string name="emoji_reactions_cannot_remove_v1_body">La versión de tu aplicación aún no admite la eliminación de reacciones de emojis ni el envío de más de una reacción de emojis.</string>
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <string name="emoji_reactions_message_details_hint">Este mensaje tiene reacciones con emojis. En el chat, mantenga pulsada cualquier burbuja de emojis para verlas todas.</string>
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_title">Reacción con emoji</string>
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_text">Este mensaje tiene reacciones con emojis. En el chat, mantenga pulsada cualquier burbuja de emojis para verlas todas.</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d contacto</item>
         <item quantity="other">%d contactos</item>

+ 12 - 4
app/src/main/res/values-fr/strings.xml

@@ -594,8 +594,10 @@ Veuillez saisir une question pour votre enquête.</string>
     <string name="permission_storage_required">Pour envoyer des médias, autorisez Threema à accéder à l\'espace de stockage.</string>
     <string name="permission_location_required">Pour envoyer un emplacement, autorisez Threema à accéder à votre position.</string>
     <string name="permission_contacts_required">Pour envoyer des contacts, autorisez Threema à accéder aux contacts.</string>
-    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">Envoi d’un « pouce en l’air »</string>
-    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">Envoi d’un « pouce vers le bas »</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <string name="message_acknowledged">Envoi d’un « pouce en l’air »</string>
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
+    <string name="message_declined">Envoi d’un « pouce vers le bas »</string>
     <string name="notifications_settings">Réglages des notifications</string>
     <string name="notifications_default">Réglages par défaut</string>
     <string name="notifications_until">Jusqu\'à %s</string>
@@ -640,7 +642,7 @@ Veuillez saisir une question pour votre enquête.</string>
     <string name="new_wizard_info_sync_contacts">Si vous activez cette option, Threema chiffrera de façon unidirectionnelle (hachage) les adresses e-mail et les numéros de téléphone avant de les envoyer au serveur pour chercher des contacts correspondants. Nous ne stockons pas les données du carnet d\'adresses.</string>
     <string name="new_wizard_info_sync_contacts_dialog">La synchronisation des contacts peut vous aider à retrouver automatiquement vos amis. Si vous acceptez, les numéros de téléphone et les adresses e-mail de votre carnet d\'adresses seront chiffrés avant d\'être envoyés à notre serveur pour rechercher des contacts correspondants. Absolument aucune donnée ne sera stockée ou partagée.\n\nVoulez-vous activer la synchronisation des contacts ?</string>
     <string name="new_wizard_info_link">En nous donnant votre numéro de téléphone et votre adresse e-mail, nous pouvons aider vos amis à vous trouver automatiquement sur Threema. Ces informations seront stockées de façon sécurisée et anonyme. Si vous ignorez cette étape, vous utiliserez Threema de façon complètement anonyme.</string>
-    <string name="new_wizard_info_link_phone_only">En donnant votre numéro de téléphone, Theema peut aider vos amis à vous trouver automatiquement si vous vous trouvez dans leur carnet d’adresses. Le numéro sera stocké de manière chiffrée (hashée) sur notre serveur. Vous pouvez simplement passer cette étape si vous souhaitez utiliser Threema de manière complètement anonyme.</string>
+    <string name="new_wizard_info_link_phone_only">En donnant votre numéro de téléphone, Threema peut aider vos amis à vous trouver automatiquement si vous vous trouvez dans leur carnet d’adresses. Le numéro sera stocké de manière chiffrée (hashée) sur notre serveur. Vous pouvez simplement passer cette étape si vous souhaitez utiliser Threema de manière complètement anonyme.</string>
     <string name="new_wizard_info_nickname">Le surnom est utilisé dans les notifications push sur certains appareils, ou comme moyen supplémentaire de vous identifier auprès des utilisateurs dont vous n\'êtes pas encore dans le carnet d\'adresse. Nous vous recommandons de ne donner que votre prénom ou un pseudo. Si vous ne définissez pas de surnom, nous utiliserons par défaut votre ID Threema.</string>
     <string name="not_linked">non lié</string>
     <string name="linked">lié</string>
@@ -851,7 +853,7 @@ Veuillez saisir une question pour votre enquête.</string>
     <string name="switched_off">Désactivée</string>
     <string name="switched_on">Activée</string>
     <string name="title_tab_work_users">Utilisateurs Threema Work</string>
-    <string name="no_matching_work_contacts">Aucun contact Theema Work vérifié par un administrateur n\'a été trouvé</string>
+    <string name="no_matching_work_contacts">Aucun contact Threema Work vérifié par un administrateur n\'a été trouvé</string>
     <string name="all">Tous</string>
     <string name="webclient_session_stop_all">Tout fermer</string>
     <string name="passphrase_service_name">Service de phrase secrète</string>
@@ -1604,6 +1606,12 @@ Veuillez saisir une question pour votre enquête.</string>
     <string name="emoji_reactions_cannot_remove_body">%1$s utilise une version de l’application qui ne prend pas encore en charge la suppression des réactions emoji ou l’envoi de plus d’une réaction emoji. Vous pouvez toujours remplacer 👍 par 👎 ou vice versa.</string>
     <string name="emoji_reactions_cannot_remove_group_body">Aucun membre du groupe n’utilise une version de l’application qui permet de supprimer des réactions emoji ou d’envoyer plus d’une réaction emoji. Vous pouvez remplacer 👍 par 👎 ou vice-versa.</string>
     <string name="emoji_reactions_cannot_remove_v1_body">La version de votre application ne permet pas encore de supprimer les réactions emoji ou d’envoyer plus d’une réaction emoji.</string>
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <string name="emoji_reactions_message_details_hint">Ce message a reçu des réactions emoji. Dans le chat, appuyez sur une bulle d’emoji et maintenez-la enfoncée pour les afficher toutes.</string>
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_title">Réaction d\'émoji</string>
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_text">Ce message a reçu des réactions d\'émojis. Dans le chat, appuyez sur une bulle d’émojis et maintenez-la enfoncée pour les afficher toutes.</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d contact</item>
         <item quantity="other">%d contacts</item>

+ 10 - 2
app/src/main/res/values-gsw/strings.xml

@@ -589,8 +589,10 @@
     <string name="permission_storage_required">Aktiviered Sie d’Speicher-Berächtigung, zum Medie z’sichere oder z’schicke.</string>
     <string name="permission_location_required">Aktiviered Sie d’Standort-Berächtigung, zum Ihri Position z’schicke.</string>
     <string name="permission_contacts_required">Aktiviered Sie d’Kontakt-Berächtigung, zum en Kontakt z’schicke.</string>
-    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">«Duume ufe» gschickt</string>
-    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">«Duume abe» gschickt</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <string name="message_acknowledged">«Duume ufe» gschickt</string>
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
+    <string name="message_declined">«Duume abe» gschickt</string>
     <string name="notifications_settings">Benachrichtigungsiistellige</string>
     <string name="notifications_default">Standardiistellig</string>
     <string name="notifications_until">Bis %s</string>
@@ -1599,6 +1601,12 @@
     <string name="emoji_reactions_cannot_remove_body">%1$s bruucht e App-Version, wo s’Lösche vo Emoji-Reaktione oder s’Sände vo mehrere Emoji-Reaktione nonig unterstützt. 👍 und 👎 lönd sich wiiterhin ustuusche.</string>
     <string name="emoji_reactions_cannot_remove_group_body">Keis Gruppemitglied hät e Version vode App, wo s’Lösche vo Emoji-Reaktione oder s’Sände vo mehrere Emoji-Reaktione unterstützt. 👍 und 👎 lönd sich aber wiiterhin ustuusche.</string>
     <string name="emoji_reactions_cannot_remove_v1_body">Ihri App-Version unterstützt s’Lösche vo Emoji-Reaktione oder s’Sände vo mehrere Emoji-Reaktione nonig.</string>
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <string name="emoji_reactions_message_details_hint">Die Nachricht hät Reaktione. Tipped Sie im Chat lang uf es Reaktions-Emoji, zum all Reaktione gseh.</string>
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_title">Emoji-Reaktion</string>
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_text">Die Nachricht hät Reaktione. Tipped Sie im Chat lang uf es Reaktions-Emoji, zum all Reaktione gseh.</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d Kontakt</item>
         <item quantity="other">%d Kontäkt</item>

+ 5 - 3
app/src/main/res/values-hu/strings.xml

@@ -312,7 +312,6 @@
     <string name="state_dialog_posted">Elküldve</string>
     <string name="state_dialog_modified">Frissítve</string>
     <string name="state_dialog_status">Állapot</string>
-    <string name="title_tab_recent">Legutóbbi</string>
     <string name="no_recent_conversations">Nem találtunk csevegést</string>
     <string name="save_changes">Mentés</string>
     <string name="group_created_confirm">Csoport sikeresen létrehozva</string>
@@ -470,7 +469,6 @@
     <string name="back">Vissza</string>
     <string name="wearable_reply">Válasz</string>
     <string name="wearable_reply_label">Válasz %s-nak</string>
-    <string name="message_acknowledged">Üzenet megerősítve</string>
     <string name="push_disable_text">Ha folytatja, a push üzenetek letiltásra kerülnek, és
 		a Threema 15 percenként ellenőrzi az új üzeneteket.</string>
     <string name="ballot_intermediate_results_show">Közbenső eredmények megjelenítése</string>
@@ -585,7 +583,8 @@
     <string name="permission_storage_required">Aktiválja a mentési engedélyt a média mentéséhez vagy küldéséhez.</string>
     <string name="permission_location_required">Aktiválja a helymeghatározási engedélyt a tartózkodási helye elküldéséhez.</string>
     <string name="permission_contacts_required">Aktiválja a kapcsolatok engedélyt egy névjegy elküldéséhez.</string>
-    <string name="message_declined">Üzenet elutasítva</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
     <string name="notifications_settings">Értesítési beállítások</string>
     <string name="notifications_default">Alapértelmezett beállítás</string>
     <string name="notifications_until">%s-ig</string>
@@ -1322,6 +1321,9 @@ Ha új eszközre vált, kérjük, távolítsa el vagy deaktiválja a %s-t a rég
     <string name="organization_type">Szervezet</string>
     <string name="messages_cannot_be_recovered">Az üzeneteket nem lehet visszaállítani.</string>
     <!-- accessibility -->
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
     <plurals name="contacts_counter_label">
         <item quantity="one">%d névjegyek</item>
         <item quantity="other">%d névjegyek</item>

+ 5 - 3
app/src/main/res/values-it/strings.xml

@@ -328,7 +328,6 @@ automaticamente in caso di inattività dopo un intervallo predefinito (solo cara
     <string name="state_dialog_edited">Modificato</string>
     <string name="state_dialog_deleted">Cancellato</string>
     <string name="state_dialog_status">Stato</string>
-    <string name="title_tab_recent">Ultimo</string>
     <string name="no_recent_conversations">Nessuna chat trovata</string>
     <string name="save_changes">Salvare modifiche</string>
     <string name="group_created_confirm">Creazione gruppo effettuata con successo</string>
@@ -496,7 +495,6 @@ automaticamente in caso di inattività dopo un intervallo predefinito (solo cara
     <string name="back">Indietro</string>
     <string name="wearable_reply">Rispondi</string>
     <string name="wearable_reply_label">Rispondi a %s</string>
-    <string name="message_acknowledged">Messaggio confermato</string>
     <string name="push_disable_text">Se continui verrà utilizzato «Threema Push» anziché il servizio push del sistema. Ciò contribuisce a ridurre l\'impronta digitale, ma richiede che il dispositivo sia configurato in modo tale che l\'app possa funzionare in background. Contatta il servizio clienti del tuo dispositivo se hai bisogno di aiuto per la configurazione se non trovi le relative opzioni nelle impostazioni del dispositivo.</string>
     <string name="ballot_intermediate_results_show">Mostra risultati intermedi</string>
     <string name="converting_video">Elaborazione video</string>
@@ -613,7 +611,8 @@ automaticamente in caso di inattività dopo un intervallo predefinito (solo cara
     <string name="permission_storage_required">Per poter inviare media, devi autorizzare Threema all\'accesso alla memoria.</string>
     <string name="permission_location_required">Per poter inviare una posizione, devi permettere a Threema di accedere alla tua posizione.</string>
     <string name="permission_contacts_required">Per poter inviare contatti, devi permettere a Threema di leggere i contatti.</string>
-    <string name="message_declined">«Rifiuto» inviato</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
     <string name="notifications_settings">Impostazioni notifiche</string>
     <string name="notifications_default">Impostazioni predefinite</string>
     <string name="notifications_until">Fino a %s</string>
@@ -1593,6 +1592,9 @@ automaticamente in caso di inattività dopo un intervallo predefinito (solo cara
     <string name="cd_message_details_container">dettagli del messaggio</string>
     <string name="cd_enable_formatting">Abilita la formattazione</string>
     <string name="cd_message">messaggio</string>
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
     <plurals name="contacts_counter_label">
         <item quantity="one">%d contatto</item>
         <item quantity="other">%d contatti</item>

+ 2 - 0
app/src/main/res/values-ja/colorpicker_strings.xml

@@ -1,6 +1,8 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources xmlns:tools="http://schemas.android.com/tools" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
     <!-- Default title for color picker dialog [CHAR LIMIT=30] -->
+    <string name="color_picker_default_title">色を選択</string>
     <!-- Content description for a color square. -->
     <!-- Content description for a selected color square. -->
+    <string name="color_swatch_description_selected" tools:ignore="PluralsCandidate">色<xliff:g id="color_index" example="14">%1$d</xliff:g>選択済</string>
 </resources>

+ 7 - 5
app/src/main/res/values-ja/strings.xml

@@ -317,7 +317,6 @@ https://shop.threema.ch/retrieve_keys]]></string>
     <string name="state_dialog_edited">編集済</string>
     <string name="state_dialog_deleted">削除済</string>
     <string name="state_dialog_status">状態</string>
-    <string name="title_tab_recent">最近</string>
     <string name="no_recent_conversations">トークが見つかりません</string>
     <string name="save_changes">保存</string>
     <string name="group_created_confirm">グループが作成されました</string>
@@ -479,7 +478,6 @@ http://www.7-zip.org または https://itunes.apple.com/us/app/the-unarchiver/id
     <string name="back">戻る</string>
     <string name="wearable_reply">返信</string>
     <string name="wearable_reply_label">%s に返信</string>
-    <string name="message_acknowledged">«賛成» 送信</string>
     <string name="push_disable_text">続行すると、システムのプッシュサービスの代わりに、「Threemaプッシュ」が使用されます。これはデジタルフットプリントを減らすのに役に立ちますが、アプリをバックグラウンドで実行できるように端末を設定する必要があります。端末の設定で対応するオプションが見つからない場合、端末の製造元のサポートと連絡を取り、設定のサポートを受けてください。</string>
     <string name="ballot_intermediate_results_show">途中経過を表示</string>
     <string name="converting_video">動画を処理中</string>
@@ -596,7 +594,8 @@ https://myid.threema.ch/revoke に入力することで ID を削除すること
     <string name="permission_storage_required">メディアを保存したり送信したりするには、Threemaにストレージへのアクセス許可をしてください。</string>
     <string name="permission_location_required">位置情報を送信するには、Threemaに自分の位置情報へのアクセスを許可をしてください。</string>
     <string name="permission_contacts_required">連絡先を送信するには、Threemaに自分の連絡先へのアクセスを許可する必要があります。</string>
-    <string name="message_declined">«反対» 送信</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
     <string name="notifications_settings">通知設定</string>
     <string name="notifications_default">デフォルト設定</string>
     <string name="notifications_until">%s まで</string>
@@ -1410,8 +1409,8 @@ https://myid.threema.ch/revoke に入力することで ID を削除すること
     <string name="read_phone_state_short_message">通話の最中であるかどうかの確認のために、Threemaに電話へのアクセスを許可してください。</string>
     <string name="prefs_title_read_phone_state">電話へのアクセス許可</string>
     <string name="prefs_title_hibernation">使用されていないアプリの設定</string>
-    <string name="prefs_summary_hibernation_api_32">長期の不使用後に、Theemaがシステムによって中断されるのを防ぐために、システム設定の「権限を削除して空き容量を増やす」を無効にしてください。</string>
-    <string name="prefs_summary_hibernation_api">長期の不使用後に、Theemaがシステムによって中断されるのを防ぐために、システム設定の「使用していないアプリを一時停止する」を無効にしてください。</string>
+    <string name="prefs_summary_hibernation_api_32">長期の不使用後に、Threemaがシステムによって中断されるのを防ぐために、システム設定の「権限を削除して空き容量を増やす」を無効にしてください。</string>
+    <string name="prefs_summary_hibernation_api">長期の不使用後に、Threemaがシステムによって中断されるのを防ぐために、システム設定の「使用していないアプリを一時停止する」を無効にしてください。</string>
     <string name="unable_to_fetch_configuration">設定サーバーからデータを取得できません。後でもう一度お試しください。</string>
     <string name="rogue_device_warning"><![CDATA[異なるデバイスからの接続が、同じThreema IDで検出されました。最近、別の端末であなたのThreema IDを使用しましたか?&lt;br&gt;&lt;br&gt;もし使用したのであれば、このメッセージは無視してください。&lt;br&gt;&lt;br&gt;もし使用していないのであれば、あなたのプライベートキーが不正利用されている可能性があります。新しいIDを作成する前に、<a href="https://threema.ch/en/faq/another_connection">私たちの提案に従って</a>、あなたの端末とデータを保護してください。]]></string>
     <string name="fetch2_failure">プロビジョニングサーバーとの同期に失敗しました。</string>
@@ -1536,6 +1535,9 @@ https://myid.threema.ch/revoke に入力することで ID を削除すること
     <string name="unsupported_image_type">サポート外の画像形式: %s</string>
     <string name="add_contact_failed">連絡先の追加が失敗しました</string>
     <!-- accessibility -->
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
     <plurals name="contacts_counter_label">
         <item quantity="other">%d 連絡先</item>
     </plurals>

+ 10 - 2
app/src/main/res/values-nl-rNL/strings.xml

@@ -594,8 +594,10 @@ Voer een vraag in voor uw poll.</string>
     <string name="permission_storage_required">Geeft Threema toegang tot uw opslag om media te kunnen versturen.</string>
     <string name="permission_location_required">Geef Threema toegang tot uw locatie om een locatie te kunnen versturen.</string>
     <string name="permission_contacts_required">Geef Threema leestoegang tot uw contactpersonen om contactpersonen te kunnen versturen.</string>
-    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">\"Duimen omhoog\" verstuurd</string>
-    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">\"Duimen omlaag\" verzonden</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <string name="message_acknowledged">\"Duimen omhoog\" verstuurd</string>
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
+    <string name="message_declined">\"Duimen omlaag\" verzonden</string>
     <string name="notifications_settings">Meldingsinstellingen</string>
     <string name="notifications_default">Standaardinstellingen</string>
     <string name="notifications_until">Tot %s</string>
@@ -1605,6 +1607,12 @@ Weet u zeker dat u Threema anoniem wil gebruiken?</string>
     <string name="emoji_reactions_cannot_remove_body">%1$s gebruikt een app-versie die het verwijderen van emoji-reacties of het verzenden van meer dan één emoji-reactie nog niet ondersteunt. Je kunt 👍 nog steeds vervangen door 👎 of andersom.</string>
     <string name="emoji_reactions_cannot_remove_group_body">Geen van de groepsleden gebruikt een versie van de app die het verwijderen van emoji-reacties of het verzenden van meer dan één emoji-reactie ondersteunt. Je kunt 👍 vervangen door 👎 of andersom.</string>
     <string name="emoji_reactions_cannot_remove_v1_body">Je app-versie ondersteunt nog niet het verwijderen van emoji-reacties of het verzenden van meer dan één emoji-reactie.</string>
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <string name="emoji_reactions_message_details_hint">Dit bericht heeft emoji-reacties. Tik in de chat op een emoji-ballon en houd deze vast om ze allemaal te bekijken.</string>
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_title">Emoji-reactie</string>
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_text">Dit bericht heeft emoji-reacties. Tik in de chat op een emoji-ballon en houd deze vast om ze allemaal te bekijken.</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d contactpersoon</item>
         <item quantity="other">%d contactpersonen</item>

+ 5 - 3
app/src/main/res/values-no/strings.xml

@@ -303,7 +303,6 @@ når den første meldingen mottas.</string>
     <string name="state_dialog_posted">Sendt</string>
     <string name="state_dialog_modified">Oppdatert</string>
     <string name="state_dialog_status">Status</string>
-    <string name="title_tab_recent">Nylig</string>
     <string name="no_recent_conversations">Ingen samtaler funnet</string>
     <string name="save_changes">Lagre</string>
     <string name="group_created_confirm">Gruppen ble opprettet</string>
@@ -463,7 +462,6 @@ http://www.7-zip.org eller https://itunes.apple.com/us/app/the-unarchiver/id4254
     <string name="back">Tilbake</string>
     <string name="wearable_reply">Svar</string>
     <string name="wearable_reply_label">Svar til %s</string>
-    <string name="message_acknowledged">\"Enig\" sendt</string>
     <string name="push_disable_text">Om du fortsetter, så vil \"Threema Push\" bli brukt istedenfor push-tjenesten til systemet. Dette hjelper til å redusere ditt digitale fotavtrykk, men krever at telefonen er satt opp til å tillate å kjøre i bakgrunnen. Du må kanskje kontakte brukerstøtten til produsenten av din telefon for hjelp til konfigurasjon om du ikke finner denne innstillingen.</string>
     <string name="ballot_intermediate_results_show">Vis midlertidige resultater</string>
     <string name="converting_video">Behandler video</string>
@@ -579,7 +577,8 @@ http://www.7-zip.org eller https://itunes.apple.com/us/app/the-unarchiver/id4254
     <string name="permission_storage_required">For å lagre eller sende media, gi Threema tilgang til lagring på enheten.</string>
     <string name="permission_location_required">For å sende en posisjon til andre, gi Threema tilgang til din posisjon.</string>
     <string name="permission_contacts_required">For å sende en kontakt, gi Threema tilgang til å se kontakter.</string>
-    <string name="message_declined">\"Uenig\" sendt</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
     <string name="notifications_settings">Varsler</string>
     <string name="notifications_default">Standard innstillinger</string>
     <string name="notifications_until">Frem til %s</string>
@@ -1521,6 +1520,9 @@ Om du ikke har brukt din Threema ID på en annen enhet eller brukt en eldre vers
     <string name="unsupported_image_type">Ustøttet bildeformat: %s</string>
     <string name="application_setup_steps_failed">Det er krav om en aktiv internettforbindelse for å sjekke tilstanden til kontaktene dine. Sørg for at du er tilkoblet internett, og prøv igjen.</string>
     <!-- accessibility -->
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
     <plurals name="contacts_counter_label">
         <item quantity="one">%d kontakt</item>
         <item quantity="other">%d kontakter</item>

+ 10 - 2
app/src/main/res/values-pl/strings.xml

@@ -595,8 +595,10 @@ Wprowadź pytanie do swojej ankiety.</string>
     <string name="permission_storage_required">Aby wysłać media, zezwól Threema na dostęp do pamięci.</string>
     <string name="permission_location_required">Aby wysłać swoją lokalizację, zezwól Threema na dostęp do swojego położenia.</string>
     <string name="permission_contacts_required">Aby wysłać kontakty, zezwól Threema na odczytywanie danych kontaktowych.</string>
-    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">Wysłano „kciuk w górę”</string>
-    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">Wysłano „kciuk w dół”</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <string name="message_acknowledged">Wysłano „kciuk w górę”</string>
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
+    <string name="message_declined">Wysłano „kciuk w dół”</string>
     <string name="notifications_settings">Ustawienia powiadomień</string>
     <string name="notifications_default">Ustawienia domyślne</string>
     <string name="notifications_until">Do %s</string>
@@ -1620,6 +1622,12 @@ anonimowo?</string>
     <string name="emoji_reactions_cannot_remove_body">%1$s używa wersji aplikacji, która na razie nie obsługuje usuwania reakcji w formie emoji ani wysyłania więcej niż jednej takiej reakcji. Nadal możesz zastąpić 👍 emoji 👎 lub na odwrót.</string>
     <string name="emoji_reactions_cannot_remove_group_body">Żaden z członków grupy nie używa wersji aplikacji, która obsługuje usuwanie reakcji w formie emoji lub wysyłanie więcej niż jednej takiej reakcji. Możesz zastąpić 👍 emoji 👎 lub na odwrót.</string>
     <string name="emoji_reactions_cannot_remove_v1_body">Używana przez Ciebie wersja aplikacji nie obsługuje na razie usuwania reakcji w formie emoji ani wysyłania więcej niż jednej takiej reakcji.</string>
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <string name="emoji_reactions_message_details_hint">Ta wiadomość zawiera reakcje w formie emoji. Aby wyświetlić wszystkie, na czacie stuknij i przytrzymaj dowolny dymek z emoji.</string>
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_title">Reakcja w formie emoji</string>
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_text">Ta wiadomość zawiera reakcje w formie emoji. Aby wyświetlić wszystkie, na czacie stuknij i przytrzymaj dowolny dymek z emoji.</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d kontakt</item>
         <item quantity="few">%d kontakty</item>

+ 11 - 3
app/src/main/res/values-pt-rBR/strings.xml

@@ -224,7 +224,7 @@
     <string name="file_too_large">O arquivo excede o tamanho máximo de %1$d MB</string>
     <string name="deleting_thread">Excluindo conversa</string>
     <string name="enter_serial_body"><![CDATA[Digite a sua chave da licença de compra. Você pode <a href="https://shop.threema.ch/">adquirir</a> uma licença ou <a href="https://shop.threema.ch/retrieve_keys">recuperar</a> as suas chaves caso já tenha adquirido uma licença anteriormente.]]></string>
-    <string name="enter_serial_title">Desbloquear o Threma</string>
+    <string name="enter_serial_title">Desbloquear o Threema</string>
     <string name="serial_required_want_exit">A chave de licença é inválida. Gostaria de tentar novamente ou sair do Threema?</string>
     <string name="checking_serial">Verificando licença</string>
     <string name="update_available">Atualização disponível</string>
@@ -594,8 +594,10 @@ Por favor, insira uma pergunta para a sua enquete.</string>
     <string name="permission_storage_required">Para enviar mídia, permita que o Threema acesse o armazenamento.</string>
     <string name="permission_location_required">Para enviar uma localização, permita que o Threema acesse sua posição.</string>
     <string name="permission_contacts_required">Para enviar contatos, permita que o Threema leia os contatos.</string>
-    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">“Polegar para cima” enviado</string>
-    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">“polegar para baixo” enviado</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <string name="message_acknowledged">“Polegar para cima” enviado</string>
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
+    <string name="message_declined">“polegar para baixo” enviado</string>
     <string name="notifications_settings">Configurações de notificação</string>
     <string name="notifications_default">Configurações padrão</string>
     <string name="notifications_until">Até %s</string>
@@ -1605,6 +1607,12 @@ Por favor, insira uma pergunta para a sua enquete.</string>
     <string name="emoji_reactions_cannot_remove_body">%1$s está usando uma versão do aplicativo que ainda não suporta a remoção de reações de emoji ou o envio de mais de uma reação de emoji. Você ainda pode substituir 👍 por 👎 ou vice-versa.</string>
     <string name="emoji_reactions_cannot_remove_group_body">Nenhum dos membros do grupo está usando uma versão do aplicativo que suporta a remoção de reações de emoji ou o envio de mais de uma reação de emoji. Você pode substituir 👍 por 👎 ou vice-versa.</string>
     <string name="emoji_reactions_cannot_remove_v1_body">Sua versão do aplicativo ainda não suporta a remoção de reações de emoji ou o envio de mais de uma reação de emoji.</string>
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <string name="emoji_reactions_message_details_hint">Esta mensagem tem reações de emoji. No chat, toque e segure qualquer balão de emoji para ver todos eles.</string>
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_title">Reação de emoji</string>
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_text">Esta mensagem tem reações de emoji. No chat, toque e segure qualquer balão de emoji para ver todos eles.</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d contato</item>
         <item quantity="other">%d contatos</item>

+ 5 - 3
app/src/main/res/values-rm/strings.xml

@@ -304,7 +304,6 @@ Endatescha in pled-clav per tes backup da datas.</string>
     <string name="state_dialog_edited">Elavurà</string>
     <string name="state_dialog_deleted">Stizzà</string>
     <string name="state_dialog_status">Status</string>
-    <string name="title_tab_recent">Ultims</string>
     <string name="no_recent_conversations">Chattà nagins chats</string>
     <string name="save_changes">Memorisar</string>
     <string name="group_created_confirm">Creà cun success la gruppa</string>
@@ -464,7 +463,6 @@ Endatescha in pled-clav per tes backup da datas.</string>
     <string name="back">Enavos</string>
     <string name="wearable_reply">Respunder</string>
     <string name="wearable_reply_label">Respunder a %s</string>
-    <string name="message_acknowledged">Confermar il messadi</string>
     <string name="push_disable_text">Sche ti cuntinueschas, vegnan messadis da push deactivads e Threema vegn a controllar mintga 15 minutas, sch\'i dat novs messadis.</string>
     <string name="ballot_intermediate_results_show">Mussar resultats intermediars</string>
     <string name="converting_video">Il video vegn elavurà</string>
@@ -581,7 +579,8 @@ Endatescha in pled-clav per tes backup da datas.</string>
     <string name="permission_storage_required">Activescha l\'autorisaziun da l\'arcun per trametter medias.</string>
     <string name="permission_location_required">Activescha l\'autorisaziun dal lieu per trametter tia posiziun.</string>
     <string name="permission_contacts_required">Activescha l\'autorisaziun dals contacts per trametter in contact.</string>
-    <string name="message_declined">Refusà il messadi</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
     <string name="notifications_settings">Configuraziuns dals avis</string>
     <string name="notifications_default">Configuraziun da standard</string>
     <string name="notifications_until">Fin %s</string>
@@ -1497,6 +1496,9 @@ Sche ti midas sin in nov apparat, deinstallescha u deactivescha p.pl. %s sin l\'
     <string name="directory_request_failed">Betg reussì da consultar il register. P.pl. empruvar anc ina giada.</string>
     <string name="add_shortcut_exists">Igl exista gia ina scursanida per quest object.</string>
     <!-- accessibility -->
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
     <plurals name="contacts_counter_label">
         <item quantity="one">%d contact</item>
         <item quantity="other">%d contacts</item>

+ 10 - 2
app/src/main/res/values-ru/strings.xml

@@ -591,8 +591,10 @@
     <string name="permission_storage_required">Чтобы отправлять медиа, разрешите Threema доступ к хранилищу.</string>
     <string name="permission_location_required">Чтобы отправлять местоположение, разрешите Threema доступ к службе геопозиционирования.</string>
     <string name="permission_contacts_required">Чтобы отправлять контакты, разрешите Threema чтение контактов.</string>
-    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">«Одобрение» отправлено</string>
-    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">«Неодобрение» отправлено</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <string name="message_acknowledged">«Одобрение» отправлено</string>
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
+    <string name="message_declined">«Неодобрение» отправлено</string>
     <string name="notifications_settings">Настройки уведомлений</string>
     <string name="notifications_default">Настройки по умолчанию</string>
     <string name="notifications_until">До %s</string>
@@ -1603,6 +1605,12 @@
     <string name="emoji_reactions_cannot_remove_body">%1$s использует версию приложения, которая не поддерживает удаление эмоциональных реакций или отправку нескольких. Вы можете поменять 👍 на 👎 или наоборот.</string>
     <string name="emoji_reactions_cannot_remove_group_body">Ни один из участников группы не использует версию приложения, которая поддерживает удаление эмоциональных реакций или отправку нескольких. Вы можете поменять 👍 на 👎 или наоборот.</string>
     <string name="emoji_reactions_cannot_remove_v1_body">Ваша версия приложения не поддерживает удаление эмоциональных реакций или отправку нескольких.</string>
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <string name="emoji_reactions_message_details_hint">В этом сообщении есть эмоциональные реакции. Долгим нажатием на любую эмоцию в чате можно просмотреть их все.</string>
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_title">Реакции эмоцией</string>
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_text">В этом сообщении есть эмоциональные реакции. Долгим нажатием на любую эмоцию в чате можно просмотреть их все.</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d контакт</item>
         <item quantity="few">%d контакта</item>

+ 5 - 3
app/src/main/res/values-sk/strings.xml

@@ -314,7 +314,6 @@ alebo naskenovať QR kód pomocou iného zariadenia.</string>
     <string name="state_dialog_edited">Upravené</string>
     <string name="state_dialog_deleted">Vymazané</string>
     <string name="state_dialog_status">Stav</string>
-    <string name="title_tab_recent">Nedávne</string>
     <string name="no_recent_conversations">Žiadne konverzácie</string>
     <string name="save_changes">Uložiť</string>
     <string name="group_created_confirm">Skupina úspešne vytvorená</string>
@@ -477,7 +476,6 @@ Naplánujte udalosti, vytvorte prieskum, alebo sa niečo spýtajte svojich priat
     <string name="back">Späť</string>
     <string name="wearable_reply">Odpovedať</string>
     <string name="wearable_reply_label">Odpovedať %s</string>
-    <string name="message_acknowledged">\"Súhlas\" odoslaný</string>
     <string name="push_disable_text">Ak budete pokračovať, namiesto systémovej služby push sa použije „Threema Push“. Pomáha to znížiť vašu digitálnu stopu, ale vyžaduje, aby bol váš telefón nakonfigurovaný tak, aby umožňoval beh aplikácie na pozadí. Ak v nastaveniach zariadenia nenájdete zodpovedajúce možnosti, možno budete musieť kontaktovať podporu výrobcu zariadenia a požiadať o pomoc s konfiguráciou.</string>
     <string name="ballot_intermediate_results_show">Zobrazovať priebežné výsledky</string>
     <string name="converting_video">Video sa spracováva</string>
@@ -596,7 +594,8 @@ Už ich nebude možné obnoviť.</string>
     <string name="permission_storage_required">Ak chcete ukladať či odosielať médiá, povolte aplikácii Threema oprávnenie pristupu k úložisku.</string>
     <string name="permission_location_required">Ak chcete odosielať polohu, povolte aplikácii Threema oprávnenie prístupu k polohe vašeho zariadenia.</string>
     <string name="permission_contacts_required">Ak chcete odosielať kontakty, povolte aplikácii Threema oprávnenie čítať kontakty.</string>
-    <string name="message_declined">\"Nesúhlas\" odoslaný</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
     <string name="notifications_settings">Nastavenia upozornení</string>
     <string name="notifications_default">Predvolené nastavenia</string>
     <string name="notifications_until">Až do %s</string>
@@ -1593,6 +1592,9 @@ Vykonajte prosím zálohú vašich údajov vhodnou metódou.</string>
     <string name="cd_message_details_container">detaily správy</string>
     <string name="cd_enable_formatting">Povoliť formátovanie</string>
     <string name="cd_message">správa</string>
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
     <plurals name="contacts_counter_label">
         <item quantity="one">%d kontakt</item>
         <item quantity="few">%d kontakty</item>

+ 10 - 2
app/src/main/res/values-tr/strings.xml

@@ -590,8 +590,10 @@ tekrar denemeden önce girdiğiniz numaranın doğru olduğundan ve mobil ağa b
     <string name="permission_storage_required">Medyayı kaydetmek veya göndermek için, Threema\'nın depolama alanına erişmesine izin verin.</string>
     <string name="permission_location_required">Lokasyon gönderebilmek için, Threema\'nın konumunuza erişmesine izin verin.</string>
     <string name="permission_contacts_required">Kontaklarınızı gönderebilmek için, Threema\'nın kişilerinizi okumasına izin verin.</string>
-    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">\"Beğendi\" gönderildi</string>
-    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">\"Beğenmedi\" gönderildi</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <string name="message_acknowledged">\"Beğendi\" gönderildi</string>
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
+    <string name="message_declined">\"Beğenmedi\" gönderildi</string>
     <string name="notifications_settings">Bildirim ayarları</string>
     <string name="notifications_default">Varsayılan ayarlar</string>
     <string name="notifications_until">%s tarihine kadar</string>
@@ -1600,6 +1602,12 @@ sunucularımıza güvenli bir şekilde iletildi. Kişisel anahtar hiçbir zaman
     <string name="emoji_reactions_cannot_remove_body">%1$s, henüz simge tepkilerinin kaldırılmasını veya birden fazla simge tepkisi gönderilmesini desteklemeyen bir uygulama sürümü kullanıyor. 👍 yine 👎 ile değiştirebilirsiniz veya tam tersi de mümkündür.</string>
     <string name="emoji_reactions_cannot_remove_group_body">Grup üyelerinden hiçbiri uygulamanın simge tepkilerini kaldırmayı veya birden fazla simge tepkisi göndermeyi destekleyen bir sürümünü kullanmıyor. 👍 👎 ile değiştirebilirsiniz veya tam tersi de mümkündür.</string>
     <string name="emoji_reactions_cannot_remove_v1_body">Uygulama sürümün henüz simge tepkilerinin kaldırılmasını veya birden fazla simge tepkisi gönderilmesini desteklemiyor.</string>
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <string name="emoji_reactions_message_details_hint">Bu mesajda simge tepkileri var. Sohbette, tümünü görüntülemek için herhangi bir simge balonuna dokunup basılı tutun.</string>
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_title">Simge Tepkisi</string>
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_text">Bu mesajda simge tepkileri var. Sohbette, tümünü görüntülemek için herhangi bir simge balonuna dokunup basılı tutun.</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d kişi</item>
         <item quantity="other">%d kişi</item>

+ 10 - 2
app/src/main/res/values-uk/strings.xml

@@ -618,8 +618,10 @@
     <string name="permission_storage_required">Щоб зберігати та надсилати медіафайли, надайте Threema доступ до сховища.</string>
     <string name="permission_location_required">Щоб надіслати розташування, надайте Threema доступ до геоданих.</string>
     <string name="permission_contacts_required">Щоб надіслати контакти, надайте Threema доступ до них.</string>
-    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">\"Подобається\" надіслано</string>
-    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">\"Не подобається\" надіслано</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <string name="message_acknowledged">\"Подобається\" надіслано</string>
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
+    <string name="message_declined">\"Не подобається\" надіслано</string>
     <string name="notifications_settings">Налаштування сповіщень</string>
     <string name="notifications_default">Налаштування за умовчанням</string>
     <string name="notifications_until">До %s</string>
@@ -1647,6 +1649,12 @@
     <string name="emoji_reactions_cannot_remove_body">%1$s використовує версію додатка, яка наразі не підтримує вилучення емодзі-реакцій або надсилання кількох реакцій одночасно. Проте ви можете замінити 👍 на 👎 чи навпаки.</string>
     <string name="emoji_reactions_cannot_remove_group_body">Жоден із учасників групи не використовує версію додатка, яка підтримує вилучення емодзі-реакцій або надсилання кількох реакцій одночасно. Проте ви можете замінити 👍 на 👎 або навпаки.</string>
     <string name="emoji_reactions_cannot_remove_v1_body">Ваша версія додатка наразі не підтримує вилучення емодзі-реакцій або надсилання кількох реакцій одночасно.</string>
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <string name="emoji_reactions_message_details_hint">У цього повідомлення є емодзі-реакції. Щоб переглянути їх, натисніть і утримуйте будь-яку бульбашку емодзі в чаті.</string>
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_title">Емодзі-реакція</string>
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_text">У цього повідомлення є емодзі-реакції. Щоб переглянути їх, натисніть і утримуйте будь-яку бульбашку емодзі в чаті.</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d контакт</item>
         <item quantity="few">%d контакти</item>

+ 10 - 2
app/src/main/res/values-zh-rCN/strings.xml

@@ -611,8 +611,10 @@ Threema 支持的所有表情符号。</string>
     <string name="permission_storage_required">如要保存或发送媒体,请允许 Threema 访问存储权限。</string>
     <string name="permission_location_required">如要发送位置,请允许 Threema 访问您的位置的权限。</string>
     <string name="permission_contacts_required">如要发送联系人,请允许 Threema 读取联系人的权限。</string>
-    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">已发送 “赞”</string>
-    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">已发送 “踩”</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <string name="message_acknowledged">已发送 “赞”</string>
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
+    <string name="message_declined">已发送 “踩”</string>
     <string name="notifications_settings">通知设置</string>
     <string name="notifications_default">默认设置</string>
     <string name="notifications_until">直到%s</string>
@@ -1636,6 +1638,12 @@ Threema ID。您将不会出现在朋友的联系人列表中。您确定要
     <string name="emoji_reactions_cannot_remove_body">虽然%1$s使用的应用版本尚不支持删除心情表态或发送多个心情表态,但您仍然可以选择 👍 或 👎 。</string>
     <string name="emoji_reactions_cannot_remove_group_body">虽然所有群组成员使用的应用版本都不支持删除心情表态或发送多个心情表态,但您仍然可以选择 👍 或 👎 。</string>
     <string name="emoji_reactions_cannot_remove_v1_body">您当前使用的应用版本尚不支持删除心情表态或发送多个心情表态。</string>
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <string name="emoji_reactions_message_details_hint">有人为此消息添加了心情表态。在会话內,长按任意表情符号气泡即可查看所有心情表态。</string>
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_title">心情表态</string>
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_text">有人为此消息添加了心情表态。在会话內,长按任意表情符号气泡即可查看所有心情表态。</string>
     <plurals name="contacts_counter_label">
         <item quantity="other">%d个联系人</item>
     </plurals>

+ 10 - 2
app/src/main/res/values-zh-rTW/strings.xml

@@ -611,8 +611,10 @@ Threema 支援的所有表情符號。</string>
     <string name="permission_storage_required">如要儲存或傳送媒體,請允許 Threema 取用儲存權限。</string>
     <string name="permission_location_required">如要傳送位置,請允許 Threema 取用您的位置的權限。</string>
     <string name="permission_contacts_required">如要傳送聯絡人,請允許 Threema 讀取聯絡人的權限。</string>
-    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">已發送「讚好」</string>
-    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">已發送「不喜歡」</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <string name="message_acknowledged">已發送「讚好」</string>
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
+    <string name="message_declined">已發送「不喜歡」</string>
     <string name="notifications_settings">通知設定</string>
     <string name="notifications_default">預設設定</string>
     <string name="notifications_until">直至%s</string>
@@ -1630,6 +1632,12 @@ Threema 支援的所有表情符號。</string>
     <string name="emoji_reactions_cannot_remove_body">雖然%1$s使用的應用程式版本尚未支援刪除表情回應或傳送多個表情回應,但您仍然可以選擇 👍 或 👎 。</string>
     <string name="emoji_reactions_cannot_remove_group_body">雖然所有群組成員使用的應用程式版本都未支援刪除表情回應或傳送多個表情回應,但您仍然可以選擇 👍 或 👎 。</string>
     <string name="emoji_reactions_cannot_remove_v1_body">您目前使用的應用程式版本尚未支援移除表情回應或傳送多個表情回應。</string>
+    <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
+    <string name="emoji_reactions_message_details_hint">有人對此訊息新增了表情回應。在對話內,長按任意表情符號氣泡即可檢視所有表情回應。</string>
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_title">表情回應</string>
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_text">有人對此訊息新增了表情回應。在對話內,長按任意表情符號氣泡即可檢視所有表情回應。</string>
     <plurals name="contacts_counter_label">
         <item quantity="other">%d位聯絡人</item>
     </plurals>

+ 1 - 0
app/src/main/res/values/dimens.xml

@@ -219,6 +219,7 @@
 	<dimen name="tooltip_popup_arrow_inset">10dp</dimen>
 	<dimen name="tooltip_layout_margin_left">2dp</dimen>
 	<dimen name="tooltip_layout_margin_right">8dp</dimen>
+	<dimen name="tooltip_title_size">15sp</dimen>
 	<dimen name="tooltip_text_size">15sp</dimen>
 
 	<dimen name="compose_title_avatar_size">40dp</dimen>

+ 5 - 0
app/src/main/res/values/preferences_strings.xml

@@ -163,6 +163,8 @@
 	<string name="preferences__working_days_enable" translatable="false">pref_working_days_enable</string>
 	<string name="preferences__generate_test_quotes" translatable="false">pref_key_generate_test_quotes</string>
 	<string name="preferences__tooltip_work_hint_shown" translatable="false">pref_tooltip_work_hint_shown</string>
+	<string name="preferences__tooltip_emoji_reactions_shown" translatable="false">pref_tooltip_emoji_reactions_shown</string>
+	<string name="preferences__tooltip_emoji_reactions_shown_counter" translatable="false">pref_tooltip_emoji_reactions_shown_counter</string>
 	<string name="preferences__camera_flash_mode" translatable="false">pref_camera_flash_mode</string>
 	<string name="preferences__camera_lens_facing" translatable="false">pref_camera_lens_facing</string>
 	<string name="preferences__pip_position" translatable="false">pref_pip_position</string>
@@ -221,4 +223,7 @@
     <string name="preferences__notification_channels_version" translatable="false">pref_notification_channels_version</string>
     <string name="preferences__last_shortcut_update_date" translatable="false">pref_last_shortcut_update_date</string>
     <string name="preferences__last_notification_request_timestamp" translatable="false">pref_last_notification_request_timestamp</string>
+    <string name="preferences__dev_reset_reaction_tooltip_shown" translatable="false">pref_dev_reset_reaction_tooltip_shown</string>
+    <string name="preferences__dev_create_messages_with_reactions" translatable="false">pref_dev_create_create_messages_with_reactions</string>
+    <string name="preferences__dev_create_nonces" translatable="false">pref_dev_create_nonces</string>
 </resources>

+ 8 - 2
app/src/main/res/values/strings.xml

@@ -618,8 +618,10 @@
     <string name="permission_storage_required">To save or send media, allow Threema permission to access storage.</string>
     <string name="permission_location_required">To send a location, allow Threema permission to access your position.</string>
     <string name="permission_contacts_required">To send contacts, allow Threema permission to read contacts.</string>
-    <string name="message_acknowledged" comment="&quot;Thumbs up&quot; should be translated by the name of the emoji 👍">“Thumbs up” sent</string>
-    <string name="message_declined" comment="&quot;Thumbs down&quot; should be translated by the name of the emoji 👎">“Thumbs down” sent</string>
+    <!-- "Thumbs up" should be translated by the name of the emoji 👍 -->
+    <string name="message_acknowledged">“Thumbs up” sent</string>
+    <!-- "Thumbs down" should be translated by the name of the emoji 👎 -->
+    <string name="message_declined">“Thumbs down” sent</string>
     <string name="notifications_settings">Notification settings</string>
     <string name="notifications_default">Default settings</string>
     <string name="notifications_until">Until %s</string>
@@ -1651,6 +1653,10 @@
     <string name="emoji_reactions_cannot_remove_v1_body">Your app version does not yet support removing emoji reactions or sending more than one emoji reaction.</string>
     <!-- Hint shown on message details screen, to let the user know how they can view a message's emoji reactions -->
     <string name="emoji_reactions_message_details_hint">This message has emoji reactions. In the chat, tap and hold any emoji bubble to view them all.</string>
+    <!-- Title shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_title">Emoji Reaction</string>
+    <!-- Message shown in popup above emoji reaction buttons on a message, to hint to the user that they can long-press to view the details -->
+    <string name="emoji_reactions_popup_hint_text">This message has emoji reactions. In the chat, tap and hold any emoji bubble to view them all.</string>
     <plurals name="contacts_counter_label">
         <item quantity="one">%d contact</item>
         <item quantity="few">%d contacts</item>

+ 16 - 0
app/src/main/res/xml/preference_developers.xml

@@ -14,6 +14,14 @@
             android:widgetLayout="@layout/preference_switch_layout" />
     </PreferenceCategory>
 
+    <PreferenceCategory
+        android:title="Hints">
+        <Preference
+            android:key="@string/preferences__dev_reset_reaction_tooltip_shown"
+            android:title="Reset reaction tooltip"
+            android:summary="Reset the reaction tooltip shown state." />
+    </PreferenceCategory>
+
     <PreferenceCategory
         android:key="pref_key_conversation"
         android:title="Conversations">
@@ -27,6 +35,14 @@
     <PreferenceCategory
         android:key="pref_key_generate"
         android:title="Generate Data">
+        <Preference
+            android:key="@string/preferences__dev_create_messages_with_reactions"
+            android:title="Create messages with reactions"
+            android:summary="Create messages galore with reactions/ack/dec in contact and group chats (local only, nothing will be sent)" />
+        <Preference
+            android:key="@string/preferences__dev_create_nonces"
+            android:title="Create nonces"
+            android:summary="A fair amount of random nonces will be created and stored" />
         <Preference
             android:key="@string/preferences__generate_voip_messages"
             android:title="Generate VoIP Messages" />

+ 1 - 1
app/src/onprem/res/values-fr/strings.xml

@@ -15,7 +15,7 @@
     <string name="new_wizard_welcome">Bienvenue sur Threema OnPrem !</string>
     <string name="new_wizard_info_fingerprint">En déplaçant votre doigt, vous créez des données aléatoires (appelées entropie), qui sont utilisées pour générer une paire de clés associées à votre nouvel ID Threema unique.
 La paire de clés comprend une <b>clé publie</b> distribuée à vos contacts et une <b>clé privée</b> stockée de manière sécurisée sur votre téléphone. Vos contacts chiffreront les messages qui vous sont destinés avec votre clé publique. Seul le propriétaire de la clé privée peut déchiffrer ces messages.</string>
-    <string name="new_wizard_info_link">En donnant votre numéro de téléphone et votre adresse e-mail, Theema OnPrem peut aider vos amis à vous trouver automatiquement si vous vous trouvez dans leur carnet d\'adresses. Les adresses e-mail seront stockées de manière chiffrée sur notre serveur. Vous pouvez passer cette étape si vous souhaitez utiliser Threema OnPrem de manière anonyme.</string>
+    <string name="new_wizard_info_link">En donnant votre numéro de téléphone et votre adresse e-mail, Threema OnPrem peut aider vos amis à vous trouver automatiquement si vous vous trouvez dans leur carnet d\'adresses. Les adresses e-mail seront stockées de manière chiffrée sur notre serveur. Vous pouvez passer cette étape si vous souhaitez utiliser Threema OnPrem de manière anonyme.</string>
     <string name="new_wizard_info_sync_contacts">Si vous activez cette option, Threema OnPrem chiffrera de façon unidirectionnelle (hachage) les adresses e-mail et les numéros de téléphone
 avant de les envoyer au serveur pour chercher des contacts correspondants. Les données sont conservées uniquement en mémoire et ne sont jamais stockées sur le serveur.</string>
     <string name="threema_contact">Contact Threema OnPrem</string>

+ 1 - 1
app/src/onprem/res/values-pt-rBR/strings.xml

@@ -14,7 +14,7 @@
     <string name="new_wizard_setup_threema">Configurar Threema OnPrem</string>
     <string name="new_wizard_welcome">Bem-vindo ao Threema OnPrem!</string>
     <string name="new_wizard_info_fingerprint">Ao mover o seu dedo, você cria dados aleatórios (chamados de entropia) usados para gerar
-		o par de chaves associado à sua nova ID exclusiva do Threma. O par de chaves é composto por uma <b>chave pública</b> que é distribuída aos
+		o par de chaves associado à sua nova ID exclusiva do Threema. O par de chaves é composto por uma <b>chave pública</b> que é distribuída aos
 		seus contatos e uma <b>chave particular</b>que é armazenada com segurança no seu telefone. Seus contatos irão criptografar as mensagens enviadas para você com a
 		sua chave pública. Apenas o dono da chave particular e mais ninguém poderá decodificar estas mensagens.</string>
     <string name="new_wizard_info_link">Ao nos dar o seu número de telefone e endereço de e-mail, o Threema OnPrem pode ajudar os seus amigos a encontrarem você automaticamente se tiverem os seus dados na agenda do telefone. Essas informações serão armazenadas de forma criptografada no nosso servidor. Você pode pular essa etapa se preferir usar o Threema OnPrem de forma anônima.</string>

+ 1 - 1
app/src/store_google_work/res/values-fr/strings.xml

@@ -15,7 +15,7 @@
     <string name="new_wizard_welcome">Bienvenue sur Threema Work !</string>
     <string name="new_wizard_info_fingerprint">En déplaçant votre doigt, vous créez des données aléatoires (appelées entropie), qui sont utilisées pour générer une paire de clés associées à votre nouvel ID Threema unique.
 La paire de clés comprend une <b>clé publie</b> distribuée à vos contacts et une <b>clé privée</b> stockée de manière sécurisée sur votre téléphone. Vos contacts chiffreront les messages qui vous sont destinés avec votre clé publique. Seul le propriétaire de la clé privée peut déchiffrer ces messages.</string>
-    <string name="new_wizard_info_link">En donnant votre numéro de téléphone et votre adresse e-mail, Theema Work peut aider vos amis à vous trouver automatiquement si vous vous trouvez dans leur carnet d\'adresses. Les adresses e-mail seront stockées de manière chiffrée sur notre serveur. Vous pouvez passer cette étape si vous souhaitez utiliser Threema Work de manière anonyme.</string>
+    <string name="new_wizard_info_link">En donnant votre numéro de téléphone et votre adresse e-mail, Threema Work peut aider vos amis à vous trouver automatiquement si vous vous trouvez dans leur carnet d\'adresses. Les adresses e-mail seront stockées de manière chiffrée sur notre serveur. Vous pouvez passer cette étape si vous souhaitez utiliser Threema Work de manière anonyme.</string>
     <string name="threema_contact">Contact Threema Work</string>
     <string name="menu_about">À propos de Threema Work</string>
     <string name="directory_search">Rechercher dans le répertoire</string>

+ 1 - 1
app/src/store_google_work/res/values-pt-rBR/strings.xml

@@ -14,7 +14,7 @@
     <string name="new_wizard_setup_threema">Configurar Threema Work</string>
     <string name="new_wizard_welcome">Bem-vindo ao Threema Work!</string>
     <string name="new_wizard_info_fingerprint">Ao mover o seu dedo, você cria dados aleatórios (chamados de entropia) usados para gerar
-		o par de chaves associado à sua nova ID exclusiva do Threma. O par de chaves é composto por uma <b>chave pública</b> que é distribuída aos
+		o par de chaves associado à sua nova ID exclusiva do Threema. O par de chaves é composto por uma <b>chave pública</b> que é distribuída aos
 		seus contatos e uma <b>chave particular</b>que é armazenada com segurança no seu telefone. Seus contatos irão criptografar as mensagens enviadas para você com a
 		sua chave pública. Apenas o dono da chave particular e mais ninguém poderá decodificar estas mensagens.</string>
     <string name="new_wizard_info_link">Ao nos dar o seu número de telefone e endereço de e-mail, o Threema Work pode ajudar os seus amigos a encontrarem você automaticamente se tiverem os seus dados na agenda do telefone. Essas informações serão armazenadas de forma criptografada no nosso servidor. Você pode pular essa etapa se preferir usar o Threema Work de forma anônima.</string>

+ 67 - 0
app/src/test/java/ch/threema/app/messagereceiver/MessageReceiverExtensionsTest.kt

@@ -0,0 +1,67 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.messagereceiver
+
+import ch.threema.storage.models.ContactModel
+import org.mockito.Mockito.`when`
+import org.mockito.Mockito.mock
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class MessageReceiverExtensionsTest {
+    @Test
+    fun `direct chat with gateway contact is considered a gateway chat`() {
+        val messageReceiver = mock(ContactMessageReceiver::class.java)
+        val contactModel = ContactModel("*TESTING", MOCK_PUBLIC_KEY)
+        `when`(messageReceiver.contact).thenReturn(contactModel)
+
+        assertTrue(messageReceiver.isGatewayChat())
+    }
+
+    @Test
+    fun `direct chat with non-gateway contact is not considered a gateway chat`() {
+        val messageReceiver = mock(ContactMessageReceiver::class.java)
+        val contactModel = ContactModel("TESTUSER", MOCK_PUBLIC_KEY)
+        `when`(messageReceiver.contact).thenReturn(contactModel)
+
+        assertFalse(messageReceiver.isGatewayChat())
+    }
+
+    @Test
+    fun `group chat is not considered a gateway chat`() {
+        val messageReceiver = mock(GroupMessageReceiver::class.java)
+
+        assertFalse(messageReceiver.isGatewayChat())
+    }
+
+    @Test
+    fun `distribution list chat is not considered a gateway chat`() {
+        val messageReceiver = mock(DistributionListMessageReceiver::class.java)
+
+        assertFalse(messageReceiver.isGatewayChat())
+    }
+
+    companion object {
+        private val MOCK_PUBLIC_KEY = ByteArray(0)
+    }
+}

+ 122 - 0
app/src/test/java/ch/threema/app/utils/CounterTest.kt

@@ -0,0 +1,122 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.utils
+
+import org.junit.Test
+import org.junit.Assert.assertThrows
+import kotlin.test.assertEquals
+
+class CounterTest {
+    @Test
+    fun `initial counter value must be zero`() {
+        val counter = Counter()
+        counter.assertCountAndStringRepresentation(0L)
+    }
+
+    @Test
+    fun `count must increment the counter by one for each call`() {
+        val counter = Counter()
+        counter.assertCountAndStringRepresentation(0)
+
+        repeat(1000) { round ->
+            counter.count()
+            counter.assertCountAndStringRepresentation(round + 1L)
+        }
+    }
+
+    @Test
+    fun `step sizes smaller or equal zero must throw an exception`() {
+        assertThrows(IllegalArgumentException::class.java) { Counter(0) }
+        assertThrows(IllegalArgumentException::class.java) { Counter(-1) }
+        assertThrows(IllegalArgumentException::class.java) { Counter(Long.MIN_VALUE) }
+    }
+
+    @Test
+    fun `default step size must be one`() {
+        val counter = Counter()
+        counter.assertCountAndSteps(0, 0)
+
+        repeat(1000) { round ->
+            counter.count()
+            val expected = round + 1L
+            counter.assertCountAndSteps(expected, expected)
+        }
+    }
+
+    @Test
+    fun `steps must be counted based on the step size`() {
+        val counter = Counter(10)
+        counter.assertCountAndSteps(0, 0)
+
+        repeat(10) { counter.count() }
+        counter.assertCountAndSteps(10, 1)
+
+        repeat(1000) { counter.count() }
+        counter.assertCountAndSteps(1010, 101)
+    }
+
+    @Test
+    fun `threshold must be respected when querying steps`() {
+        val counter = Counter(10)
+        counter.assertCountAndSteps(0, 0)
+
+        repeat(10) { counter.count() }
+        assertEquals(0, counter.getAndResetSteps(5))
+        counter.assertCountAndSteps(10, 1)
+
+        repeat(30) { counter.count() }
+        assertEquals(0, counter.getAndResetSteps(5))
+        counter.assertCountAndSteps(40, 4)
+
+        repeat(10) { counter.count() }
+        assertEquals(5, counter.getAndResetSteps(5))
+        // steps must be reset after they are queried
+        counter.assertCountAndSteps(50, 0)
+
+        repeat(60) { counter.count() }
+        assertEquals(6, counter.getAndResetSteps(5))
+        counter.assertCountAndSteps(110, 0)
+    }
+
+    @Test
+    fun `partial steps must not be affected when step threshold is met`() {
+        val counter = Counter(10)
+        counter.assertCountAndSteps(0, 0)
+
+        repeat(59) { counter.count() }
+        assertEquals(5, counter.getAndResetSteps(5))
+        counter.assertCountAndSteps(59, 0)
+
+        counter.count()
+        counter.assertCountAndSteps(60, 1)
+    }
+
+    private fun Counter.assertCountAndStringRepresentation(expectedCount: Long) {
+        assertEquals(expectedCount, count)
+        assertEquals("$expectedCount", toString())
+    }
+
+    private fun Counter.assertCountAndSteps(expectedCount: Long, expectedSteps: Long) {
+        assertCountAndStringRepresentation(expectedCount)
+        assertEquals(expectedSteps, steps)
+    }
+}

+ 2 - 2
app/src/test/java/ch/threema/architecture/LayerDependenciesTest.java

@@ -38,8 +38,8 @@ import ch.threema.app.messagereceiver.DistributionListMessageReceiver;
 import ch.threema.app.messagereceiver.GroupMessageReceiver;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.services.FileService;
+import ch.threema.app.utils.FileHandlingZipOutputStream;
 import ch.threema.app.utils.ListReader;
-import ch.threema.app.utils.ZipUtil;
 import ch.threema.app.utils.executor.HandlerExecutor;
 import ch.threema.logging.LoggerManager;
 import ch.threema.logging.backend.DebugLogFileBackend;
@@ -135,7 +135,7 @@ public class LayerDependenciesTest {
         .ignoreDependency(LoggerManager.class, BuildFlavor.Companion.getClass())
         .ignoreDependency(DebugLogFileBackend.class, FileService.class)
         .ignoreDependency(DebugLogFileBackend.class, HandlerExecutor.class)
-        .ignoreDependency(DebugLogFileBackend.class, ZipUtil.class)
+        .ignoreDependency(DebugLogFileBackend.class, FileHandlingZipOutputStream.class)
         .ignoreDependency(DebugLogFileBackend.class, ThreemaApplication.class); // TODO(ANDR-1439): Refactor
 
     @ArchTest

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است