瀏覽代碼

Version 5.4

Threema 1 年之前
父節點
當前提交
8b2e7dec39
共有 100 個文件被更改,包括 4141 次插入1691 次删除
  1. 47 41
      app/build.gradle
  2. 41 0
      app/src/androidTest/java/ch/threema/app/TestCoreServiceManager.kt
  3. 419 0
      app/src/androidTest/java/ch/threema/app/contacts/AddOrUpdateContactBackgroundTaskTest.kt
  4. 3 4
      app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupLeaveTest.kt
  5. 0 9
      app/src/androidTest/java/ch/threema/app/processors/IncomingMessageProcessorTest.kt
  6. 24 9
      app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt
  7. 32 0
      app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt
  8. 2 1
      app/src/androidTest/java/ch/threema/app/testutils/TestHelpers.java
  9. 339 0
      app/src/androidTest/java/ch/threema/data/ContactModelRepositoryTest.kt
  10. 36 0
      app/src/androidTest/java/ch/threema/data/TestDatabaseService.kt
  11. 3 2
      app/src/libre/play/release-notes/de/default.txt
  12. 3 2
      app/src/libre/play/release-notes/en-US/default.txt
  13. 10 3
      app/src/main/AndroidManifest.xml
  14. 36 0
      app/src/main/java/ch/threema/app/BuildFlavor.java
  15. 97 84
      app/src/main/java/ch/threema/app/ThreemaApplication.java
  16. 3 3
      app/src/main/java/ch/threema/app/activities/AboutActivity.java
  17. 85 148
      app/src/main/java/ch/threema/app/activities/AddContactActivity.java
  18. 8 7
      app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.java
  19. 175 148
      app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java
  20. 67 0
      app/src/main/java/ch/threema/app/activities/ContactDetailViewModel.kt
  21. 2 1
      app/src/main/java/ch/threema/app/activities/GroupAdd2Activity.java
  22. 49 8
      app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java
  23. 6 13
      app/src/main/java/ch/threema/app/activities/HomeActivity.java
  24. 1 9
      app/src/main/java/ch/threema/app/activities/IdentityListActivity.java
  25. 2 2
      app/src/main/java/ch/threema/app/activities/ImagePaintActivity.java
  26. 26 26
      app/src/main/java/ch/threema/app/activities/MapActivity.java
  27. 2 2
      app/src/main/java/ch/threema/app/activities/SendMediaActivity.java
  28. 16 67
      app/src/main/java/ch/threema/app/activities/TextChatBubbleActivity.java
  29. 123 71
      app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java
  30. 79 65
      app/src/main/java/ch/threema/app/adapters/ContactDetailAdapter.java
  31. 1 1
      app/src/main/java/ch/threema/app/adapters/ContactListAdapter.java
  32. 15 2
      app/src/main/java/ch/threema/app/adapters/MessageListViewHolder.kt
  33. 8 3
      app/src/main/java/ch/threema/app/adapters/decorators/BallotChatAdapterDecorator.java
  34. 11 2
      app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java
  35. 36 0
      app/src/main/java/ch/threema/app/adapters/decorators/DeletedChatAdapterDecorator.kt
  36. 14 3
      app/src/main/java/ch/threema/app/archive/ArchiveAdapter.java
  37. 3 3
      app/src/main/java/ch/threema/app/asynctasks/AddContactAsyncTask.java
  38. 303 0
      app/src/main/java/ch/threema/app/asynctasks/AddOrUpdateContactBackgroundTask.kt
  39. 2 2
      app/src/main/java/ch/threema/app/asynctasks/DeleteIdentityAsyncTask.java
  40. 12 4
      app/src/main/java/ch/threema/app/asynctasks/EmptyOrDeleteConversationsAsyncTask.java
  41. 13 7
      app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java
  42. 25 13
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java
  43. 3 1
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreSettings.java
  44. 2 1
      app/src/main/java/ch/threema/app/backuprestore/csv/Tags.java
  45. 4 0
      app/src/main/java/ch/threema/app/connection/CspD2mDualConnectionSupplier.kt
  46. 27 50
      app/src/main/java/ch/threema/app/dialogs/ContactEditDialog.java
  47. 10 0
      app/src/main/java/ch/threema/app/dialogs/GenericAlertDialog.java
  48. 16 0
      app/src/main/java/ch/threema/app/dialogs/MessageDetailDialog.java
  49. 2 15
      app/src/main/java/ch/threema/app/dialogs/ResendGroupMessageDialog.kt
  50. 8 2
      app/src/main/java/ch/threema/app/emojis/EmojiPicker.java
  51. 134 119
      app/src/main/java/ch/threema/app/emojis/search/EmojiSearchIndex.kt
  52. 551 147
      app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java
  53. 20 31
      app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java
  54. 22 24
      app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java
  55. 1 1
      app/src/main/java/ch/threema/app/fragments/WorkUserMemberListFragment.java
  56. 4 4
      app/src/main/java/ch/threema/app/glide/ContactAvatarFetcher.kt
  57. 45 11
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchAdapter.java
  58. 5 11
      app/src/main/java/ch/threema/app/listeners/ContactListener.java
  59. 6 9
      app/src/main/java/ch/threema/app/listeners/EditMessageListener.kt
  60. 5 13
      app/src/main/java/ch/threema/app/listeners/MessageDeletedForAllListener.kt
  61. 1 1
      app/src/main/java/ch/threema/app/locationpicker/LocationAutocompleteActivity.java
  62. 40 36
      app/src/main/java/ch/threema/app/locationpicker/LocationPickerActivity.java
  63. 2 2
      app/src/main/java/ch/threema/app/locationpicker/LocationPickerConfirmDialog.java
  64. 1 1
      app/src/main/java/ch/threema/app/locationpicker/NearbyPoiUtil.java
  65. 1 1
      app/src/main/java/ch/threema/app/locationpicker/Poi.java
  66. 1 1
      app/src/main/java/ch/threema/app/locationpicker/PoiQuery.java
  67. 1 1
      app/src/main/java/ch/threema/app/locationpicker/PoiRepository.java
  68. 77 0
      app/src/main/java/ch/threema/app/managers/CoreServiceManager.kt
  69. 97 0
      app/src/main/java/ch/threema/app/managers/CoreServiceManagerImpl.kt
  70. 4 0
      app/src/main/java/ch/threema/app/managers/ListenerManager.java
  71. 79 109
      app/src/main/java/ch/threema/app/managers/ServiceManager.java
  72. 17 9
      app/src/main/java/ch/threema/app/mediaattacher/MediaAttachActivity.java
  73. 15 4
      app/src/main/java/ch/threema/app/messagereceiver/ContactMessageReceiver.java
  74. 34 0
      app/src/main/java/ch/threema/app/messagereceiver/GroupMessageReceiver.java
  75. 1 1
      app/src/main/java/ch/threema/app/multidevice/LinkedDevicesActivity.kt
  76. 3 2
      app/src/main/java/ch/threema/app/multidevice/LinkedDevicesViewModel.kt
  77. 6 2
      app/src/main/java/ch/threema/app/multidevice/MultiDeviceManager.kt
  78. 15 8
      app/src/main/java/ch/threema/app/multidevice/MultiDeviceManagerImpl.kt
  79. 5 6
      app/src/main/java/ch/threema/app/multidevice/linking/DeviceJoinDataCollector.kt
  80. 16 0
      app/src/main/java/ch/threema/app/processors/IncomingMessageProcessorImpl.kt
  81. 1 1
      app/src/main/java/ch/threema/app/processors/contactcontrol/IncomingDeleteProfilePictureTask.kt
  82. 1 1
      app/src/main/java/ch/threema/app/processors/contactcontrol/IncomingSetProfilePictureTask.kt
  83. 59 0
      app/src/main/java/ch/threema/app/processors/conversation/IncomingContactDeleteMessageTask.kt
  84. 59 0
      app/src/main/java/ch/threema/app/processors/conversation/IncomingContactEditMessageTask.kt
  85. 57 0
      app/src/main/java/ch/threema/app/processors/conversation/IncomingGroupDeleteMessageTask.kt
  86. 57 0
      app/src/main/java/ch/threema/app/processors/conversation/IncomingGroupEditMessageTask.kt
  87. 7 1
      app/src/main/java/ch/threema/app/processors/groupcontrol/IncomingGroupLeaveTask.kt
  88. 6 0
      app/src/main/java/ch/threema/app/processors/groupcontrol/IncomingGroupSetupTask.kt
  89. 0 1
      app/src/main/java/ch/threema/app/processors/statusupdates/IncomingDeliveryReceiptTask.kt
  90. 3 3
      app/src/main/java/ch/threema/app/routines/SynchronizeContactsRoutine.java
  91. 92 102
      app/src/main/java/ch/threema/app/routines/UpdateBusinessAvatarRoutine.java
  92. 6 0
      app/src/main/java/ch/threema/app/services/ApiService.java
  93. 5 0
      app/src/main/java/ch/threema/app/services/ApiServiceImpl.java
  94. 1 1
      app/src/main/java/ch/threema/app/services/AvatarCacheServiceImpl.java
  95. 14 9
      app/src/main/java/ch/threema/app/services/ContactService.java
  96. 148 95
      app/src/main/java/ch/threema/app/services/ContactServiceImpl.java
  97. 18 0
      app/src/main/java/ch/threema/app/services/ConversationService.java
  98. 39 10
      app/src/main/java/ch/threema/app/services/ConversationServiceImpl.java
  99. 25 20
      app/src/main/java/ch/threema/app/services/FileService.java
  100. 83 59
      app/src/main/java/ch/threema/app/services/FileServiceImpl.java

+ 47 - 41
app/build.gradle

@@ -18,14 +18,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.3.2"
+def app_version = "5.4"
 
 // 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 = 959
+def defaultVersionCode = 973
 
 /**
  * Return the git hash, if git is installed.
@@ -85,7 +85,7 @@ def keystores = [
     release: findKeystore("threema"),
     hms_release: findKeystore("threema_hms"),
     onprem_release: findKeystore("onprem"),
-    blue_release: findKeystore("red"),
+    blue_release: findKeystore("threema_blue"),
 ]
 
 android {
@@ -121,7 +121,6 @@ android {
         buildConfigField "boolean", "CHAT_SERVER_GROUPS", "true"
         buildConfigField "boolean", "DISABLE_CERT_PINNING", "false"
         buildConfigField "boolean", "VIDEO_CALLS_ENABLED", "true"
-        buildConfigField "boolean", "GROUP_CALLS_ENABLED", "true"
         buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x45, (byte) 0x0b, (byte) 0x97, (byte) 0x57, (byte) 0x35, (byte) 0x27, (byte) 0x9f, (byte) 0xde, (byte) 0xcb, (byte) 0x33, (byte) 0x13, (byte) 0x64, (byte) 0x8f, (byte) 0x5f, (byte) 0xc6, (byte) 0xee, (byte) 0x9f, (byte) 0xf4, (byte) 0x36, (byte) 0x0e, (byte) 0xa9, (byte) 0x2a, (byte) 0x8c, (byte) 0x17, (byte) 0x51, (byte) 0xc6, (byte) 0x61, (byte) 0xe4, (byte) 0xc0, (byte) 0xd8, (byte) 0xc9, (byte) 0x09 }"
         buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0xda, (byte) 0x7c, (byte) 0x73, (byte) 0x79, (byte) 0x8f, (byte) 0x97, (byte) 0xd5, (byte) 0x87, (byte) 0xc3, (byte) 0xa2, (byte) 0x5e, (byte) 0xbe, (byte) 0x0a, (byte) 0x91, (byte) 0x41, (byte) 0x7f, (byte) 0x76, (byte) 0xdb, (byte) 0xcc, (byte) 0xcd, (byte) 0xda, (byte) 0x29, (byte) 0x30, (byte) 0xe6, (byte) 0xa9, (byte) 0x09, (byte) 0x0a, (byte) 0xf6, (byte) 0x2e, (byte) 0xba, (byte) 0x6f, (byte) 0x15 }"
         buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
@@ -146,8 +145,9 @@ android {
         buildConfigField "String", "DEFAULT_APP_THEME", "\"2\""
 
         buildConfigField "String[]", "ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", "null"
-        buildConfigField "boolean", "SEND_CONSUMED_DELIVERY_RECEIPTS", "false"
         buildConfigField "boolean", "MD_ENABLED", "false"
+        buildConfigField "boolean", "EDIT_MESSAGES_ENABLED", "false"
+        buildConfigField "boolean", "DELETE_MESSAGES_ENABLED", "false"
 
         // config fields for action URLs / deep links
         buildConfigField "String", "uriScheme", "\"threema\""
@@ -266,6 +266,8 @@ android {
             buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.test.threema.ch/\""
             buildConfigField "String", "APP_RATING_URL", "\"https://test.threema.ch/app-rating/android/{rating}\""
             buildConfigField "boolean", "MD_ENABLED", "true"
+            buildConfigField "boolean", "EDIT_MESSAGES_ENABLED", "true"
+            buildConfigField "boolean", "DELETE_MESSAGES_ENABLED", "true"
         }
         sandbox_work {
             versionName "${app_version}k${beta_suffix}"
@@ -298,6 +300,9 @@ android {
             buildConfigField "String", "actionUrl", "\"work.test.threema.ch\""
 
             buildConfigField "boolean", "MD_ENABLED", "true"
+            buildConfigField "boolean", "EDIT_MESSAGES_ENABLED", "true"
+            buildConfigField "boolean", "DELETE_MESSAGES_ENABLED", "true"
+
 
             manifestPlaceholders = [
                 uriScheme       : "threemawork",
@@ -405,7 +410,8 @@ android {
             buildConfigField "String", "APP_RATING_URL", "\"https://test.threema.ch/app-rating/android-work/{rating}\""
             buildConfigField "String", "LOG_TAG", "\"3mablue\""
 
-            buildConfigField "boolean", "SEND_CONSUMED_DELIVERY_RECEIPTS", "true"
+            buildConfigField "boolean", "EDIT_MESSAGES_ENABLED", "true"
+            buildConfigField "boolean", "DELETE_MESSAGES_ENABLED", "true"
 
             // config fields for action URLs / deep links
             buildConfigField "String", "uriScheme", "\"threemablue\""
@@ -581,10 +587,11 @@ android {
         debug {
             debuggable true
             jniDebuggable false
-            testCoverageEnabled false
             ndk {
                 debugSymbolLevel 'FULL'
             }
+            enableUnitTestCoverage false
+            enableAndroidTestCoverage false
 
             if (keystores['debug'] != null) {
                 signingConfig signingConfigs.debug
@@ -649,7 +656,7 @@ android {
     packagingOptions {
         jniLibs {
             // replacement for extractNativeLibs in AndroidManifest
-            useLegacyPackaging = false
+            useLegacyPackaging = true
         }
         resources {
             excludes += [
@@ -763,7 +770,7 @@ dependencies {
 
     implementation project(':domain')
 
-    implementation 'net.zetetic:sqlcipher-android:4.5.5@aar'
+    implementation 'net.zetetic:sqlcipher-android:4.5.7@aar'
 
     implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
     implementation 'net.sf.opencsv:opencsv:2.3'
@@ -784,42 +791,42 @@ dependencies {
     implementation 'androidx.recyclerview:recyclerview:1.3.2'
     implementation 'androidx.palette:palette-ktx:1.0.0'
     implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
-    implementation 'androidx.core:core-ktx:1.12.0'
-    implementation 'androidx.appcompat:appcompat:1.6.1'
+    implementation 'androidx.core:core-ktx:1.13.1'
+    implementation 'androidx.appcompat:appcompat:1.7.0'
     implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
     implementation 'androidx.biometric:biometric:1.1.0'
     implementation 'androidx.work:work-runtime-ktx:2.9.0'
-    implementation 'androidx.fragment:fragment-ktx:1.6.2'
-    implementation 'androidx.activity:activity-ktx:1.8.2'
+    implementation 'androidx.fragment:fragment-ktx:1.8.0'
+    implementation 'androidx.activity:activity-ktx:1.9.0'
     implementation 'androidx.sqlite:sqlite:2.2.2'
-    implementation "androidx.concurrent:concurrent-futures:1.1.0"
-    implementation "androidx.camera:camera-camera2:1.3.2"
-    implementation "androidx.camera:camera-lifecycle:1.3.2"
-    implementation "androidx.camera:camera-view:1.3.2"
-    implementation 'androidx.camera:camera-video:1.3.2'
+    implementation "androidx.concurrent:concurrent-futures:1.2.0"
+    implementation "androidx.camera:camera-camera2:1.3.4"
+    implementation "androidx.camera:camera-lifecycle:1.3.4"
+    implementation "androidx.camera:camera-view:1.3.4"
+    implementation 'androidx.camera:camera-video:1.3.4'
     implementation "androidx.media:media:1.7.0"
     implementation 'androidx.media3:media3-exoplayer:1.3.1'
     implementation 'androidx.media3:media3-ui:1.3.1'
     implementation "androidx.media3:media3-session:1.3.1"
-    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0"
-    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.7.0"
-    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.7.0"
-    implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0"
-    implementation "androidx.lifecycle:lifecycle-service:2.7.0"
-    implementation "androidx.lifecycle:lifecycle-process:2.7.0"
-    implementation "androidx.lifecycle:lifecycle-common-java8:2.7.0"
+    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.2"
+    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.8.2"
+    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.2"
+    implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.2"
+    implementation "androidx.lifecycle:lifecycle-service:2.8.2"
+    implementation "androidx.lifecycle:lifecycle-process:2.8.2"
+    implementation "androidx.lifecycle:lifecycle-common-java8:2.8.2"
     implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
-    implementation "androidx.paging:paging-runtime-ktx:3.2.1"
+    implementation "androidx.paging:paging-runtime-ktx:3.3.0"
     implementation "androidx.sharetarget:sharetarget:1.2.0"
     implementation 'androidx.room:room-runtime:2.6.1'
-    implementation 'androidx.window:window:1.2.0'
+    implementation 'androidx.window:window:1.3.0'
     kapt 'androidx.room:room-compiler:2.6.1'
 
     implementation 'org.bouncycastle:bcprov-jdk15to18:1.78.1'
 
     implementation 'com.google.android.material:material:1.10.0' // last version before switch to tonal system: https://github.com/material-components/material-components-android/releases/tag/1.11.0
     implementation 'com.google.zxing:core:3.3.3' // zxing 3.4 crashes on API < 24
-    implementation 'com.googlecode.libphonenumber:libphonenumber:8.13.23' // make sure to update this in domain's build.gradle as well
+    implementation 'com.googlecode.libphonenumber:libphonenumber:8.13.39' // make sure to update this in domain's build.gradle as well
 
     // webclient dependencies
     implementation 'org.msgpack:msgpack-core:0.8.24!!'
@@ -846,27 +853,26 @@ dependencies {
     annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
 
     // Kotlin
-    implementation 'androidx.core:core-ktx:1.10.1'
+    implementation 'androidx.core:core-ktx:1.13.1'
     implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
     implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
     implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1"
     testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
     androidTestImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
 
-    // use leak canary in dev builds
+    // use leak canary in debug builds
     if (!project.hasProperty("noLeakCanary")) {
-        def leakCanaryDependency = 'com.squareup.leakcanary:leakcanary-android:2.13'
-        blueImplementation(leakCanaryDependency)
-        greenImplementation(leakCanaryDependency)
-        sandbox_workImplementation(leakCanaryDependency)
-        // Uncomment the following line to use leak canary in *any* debug build
-        // debugImplementation(leakCanaryDependency)
+        debugImplementation('com.squareup.leakcanary:leakcanary-android:2.13')
     }
 
     // test dependencies
     testImplementation "junit:junit:$junit_version"
     testImplementation(testFixtures(project(":domain")))
 
+    // custom test helpers, shared between unit test and android tests
+    testImplementation(project(":test-helpers"))
+    androidTestImplementation(project(":test-helpers"))
+
     // use powermock instead of mockito. it support mocking static classes.
     def mockitoVersion = '2.0.9'
     testImplementation "org.powermock:powermock-api-mockito2:${mockitoVersion}"
@@ -880,7 +886,7 @@ dependencies {
     testImplementation 'com.tngtech.archunit:archunit-junit4:0.18.0'
 
     androidTestImplementation(testFixtures(project(":domain")))
-    androidTestImplementation 'androidx.test:rules:1.5.0'
+    androidTestImplementation 'androidx.test:rules:1.6.0'
     androidTestImplementation 'tools.fastlane:screengrab:2.1.1', {
         exclude group: 'androidx.annotation', module: 'annotation'
     }
@@ -890,7 +896,7 @@ dependencies {
     androidTestImplementation 'androidx.test:runner:1.4.0', {
         exclude group: 'androidx.annotation', module: 'annotation'
     }
-    androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
+    androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0'
     androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0', {
         exclude group: 'androidx.annotation', module: 'annotation'
         exclude group: 'androidx.appcompat', module: 'appcompat'
@@ -903,8 +909,8 @@ dependencies {
     androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0', {
         exclude group: 'androidx.annotation', module: 'annotation'
     }
-    androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
-    androidTestImplementation 'androidx.test:core-ktx:1.5.0'
+    androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.0'
+    androidTestImplementation 'androidx.test:core-ktx:1.6.0'
     androidTestImplementation "org.mockito:mockito-core:4.8.1"
     androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlin_coroutines_version"
 
@@ -946,7 +952,7 @@ dependencies {
     blueImplementation(name: 'libgsaverification-client', ext: 'aar')
 
     // Maplibre (may have transitive dependencies on Google location services)
-    def maplibreDependency = 'org.maplibre.gl:android-sdk:10.3.0'
+    def maplibreDependency = 'org.maplibre.gl:android-sdk:11.0.1'
     noneImplementation maplibreDependency
     store_googleImplementation maplibreDependency
     store_google_workImplementation maplibreDependency

+ 41 - 0
app/src/androidTest/java/ch/threema/app/TestCoreServiceManager.kt

@@ -0,0 +1,41 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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
+
+import ch.threema.app.managers.CoreServiceManager
+import ch.threema.app.multidevice.MultiDeviceManagerImpl
+import ch.threema.app.stores.PreferenceStoreInterface
+import ch.threema.app.tasks.TaskArchiverImpl
+import ch.threema.app.utils.DeviceCookieManagerImpl
+import ch.threema.domain.models.AppVersion
+import ch.threema.domain.taskmanager.TaskManager
+import ch.threema.storage.DatabaseServiceNew
+
+class TestCoreServiceManager(
+    override val version: AppVersion,
+    override val databaseService: DatabaseServiceNew,
+    override val preferenceStore: PreferenceStoreInterface,
+    override val taskArchiver: TaskArchiverImpl,
+    override val deviceCookieManager: DeviceCookieManagerImpl,
+    override val taskManager: TaskManager,
+    override val multiDeviceManager: MultiDeviceManagerImpl,
+): CoreServiceManager

+ 419 - 0
app/src/androidTest/java/ch/threema/app/contacts/AddOrUpdateContactBackgroundTaskTest.kt

@@ -0,0 +1,419 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.contacts
+
+import ch.threema.app.ThreemaApplication
+import ch.threema.app.asynctasks.AddContactRestrictionPolicy
+import ch.threema.app.asynctasks.AddOrUpdateContactBackgroundTask
+import ch.threema.app.asynctasks.AlreadyVerified
+import ch.threema.app.asynctasks.ContactExists
+import ch.threema.app.asynctasks.Failed
+import ch.threema.app.asynctasks.ContactAddResult
+import ch.threema.app.asynctasks.ContactModified
+import ch.threema.app.asynctasks.Success
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.executor.BackgroundExecutor
+import ch.threema.data.TestDatabaseService
+import ch.threema.data.repositories.ContactModelRepository
+import ch.threema.data.repositories.ModelRepositories
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.protocol.SSLSocketFactoryFactory
+import ch.threema.domain.protocol.api.APIConnector
+import ch.threema.domain.protocol.api.APIConnector.FetchIdentityResult
+import ch.threema.domain.protocol.api.APIConnector.HttpConnectionException
+import ch.threema.storage.models.ContactModel
+import ch.threema.storage.models.ContactModel.AcquaintanceLevel
+import com.neilalexander.jnacl.NaCl
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertArrayEquals
+import org.junit.Before
+import java.net.HttpURLConnection
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlin.test.assertFalse
+import kotlin.test.fail
+
+class AddOrUpdateContactBackgroundTaskTest {
+
+    private val backgroundExecutor = BackgroundExecutor()
+    private lateinit var databaseService: TestDatabaseService
+    private lateinit var contactModelRepository: ContactModelRepository
+
+    @Before
+    fun before() {
+        databaseService = TestDatabaseService()
+        contactModelRepository = ModelRepositories(databaseService).contacts
+    }
+
+    @Test
+    fun testAddSuccessful() {
+        val newIdentity = "01234567"
+
+        testAddingContact(
+            { identity ->
+                FetchIdentityResult().also {
+                    it.identity = identity
+                    it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
+                    it.featureMask = 12
+                    it.type = 0
+                    it.state = IdentityState.ACTIVE
+                }
+            },
+            {
+                assertTrue(it is Success)
+                assertEquals(newIdentity, it.contactModel.identity)
+                val data = it.contactModel.data.value!!
+                assertEquals(newIdentity, data.identity)
+                assertArrayEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
+                assertEquals(12u, data.featureMask)
+                assertEquals(IdentityType.NORMAL, data.identityType)
+                assertEquals(ContactModel.State.ACTIVE, data.activityState)
+                assertEquals(VerificationLevel.UNVERIFIED, data.verificationLevel)
+            }
+        )
+    }
+
+    @Test
+    fun testAddSuccessfulVerified() {
+        val newIdentity = "01234567"
+
+        testAddingContact(
+            { identity ->
+                FetchIdentityResult().also {
+                    it.identity = identity
+                    it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
+                    it.featureMask = 127
+                    it.type = 1
+                    it.state = IdentityState.INACTIVE
+                }
+            },
+            {
+                assertTrue(it is Success)
+                assertEquals(newIdentity, it.contactModel.identity)
+                val data = it.contactModel.data.value!!
+                assertEquals(newIdentity, data.identity)
+                assertArrayEquals(ByteArray(NaCl.PUBLICKEYBYTES), data.publicKey)
+                assertEquals(127u, data.featureMask)
+                assertEquals(IdentityType.WORK, data.identityType)
+                assertEquals(ContactModel.State.INACTIVE, data.activityState)
+                assertEquals(VerificationLevel.FULLY_VERIFIED, data.verificationLevel)
+            },
+            publicKey = ByteArray(NaCl.PUBLICKEYBYTES),
+        )
+    }
+
+    @Test
+    fun testAddMyIdentity() {
+        val myIdentity = "00000000"
+        testAddingContact(
+            { identity ->
+                FetchIdentityResult().also {
+                    it.identity = identity
+                    it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
+                    it.featureMask = 127
+                    it.type = 1
+                    it.state = IdentityState.INACTIVE
+                }
+            },
+            {
+                assertTrue(it is Failed)
+            },
+            newIdentity = myIdentity,
+            myIdentity = myIdentity,
+        )
+    }
+
+    @Test
+    fun testAddPublicKeyMismatch() {
+        testAddingContact(
+            { identity ->
+                FetchIdentityResult().also {
+                    it.identity = identity
+                    it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
+                    it.featureMask = 12
+                    it.type = 0
+                    it.state = IdentityState.ACTIVE
+                }
+            },
+            {
+                assertTrue(it is Failed)
+            },
+            publicKey = ByteArray(NaCl.PUBLICKEYBYTES).also { it.fill(1) }
+        )
+    }
+
+    @Test
+    fun testAddInvalidId() {
+        testAddingContact(
+            {
+                throw HttpConnectionException(HttpURLConnection.HTTP_NOT_FOUND, Exception())
+            },
+            {
+                assertTrue(it is Failed)
+            }
+        )
+    }
+
+    @Test
+    fun testAddExistingContact() {
+        val apiConnectorResult: (identity: String) -> FetchIdentityResult = { identity ->
+            FetchIdentityResult().also {
+                it.identity = identity
+                it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
+                it.featureMask = 12
+                it.type = 0
+                it.state = IdentityState.ACTIVE
+            }
+        }
+
+        // The first time adding the contact should succeed
+        testAddingContact(
+            apiConnectorResult,
+            {
+                assertTrue(it is Success)
+            }
+        )
+
+        // The second time adding the contact should fail
+        testAddingContact(
+            apiConnectorResult,
+            {
+                assertTrue(it is ContactExists)
+            }
+        )
+    }
+
+    @Test
+    fun testVerifyTwice() {
+        val publicKey = ByteArray(NaCl.PUBLICKEYBYTES).apply { fill(2) }
+
+        val apiConnectorResult: (identity: String) -> FetchIdentityResult = { identity ->
+            FetchIdentityResult().also {
+                it.identity = identity
+                it.publicKey = publicKey
+                it.featureMask = 12
+                it.type = 0
+                it.state = IdentityState.ACTIVE
+            }
+        }
+
+        // The first time adding the contact should succeed
+        testAddingContact(
+            apiConnectorResult,
+            {
+                assertTrue(it is Success)
+            },
+            publicKey = publicKey,
+        )
+
+        // The second time adding the contact should fail
+        testAddingContact(
+            apiConnectorResult,
+            {
+                assertTrue(it is AlreadyVerified)
+            },
+            publicKey = publicKey
+        )
+    }
+
+    @Test
+    fun testAddGroupContact() {
+        val newIdentity = "01234567"
+
+        val apiConnectorResult: (identity: String) -> FetchIdentityResult = { identity ->
+            FetchIdentityResult().also {
+                it.identity = identity
+                it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
+                it.featureMask = 12
+                it.type = 0
+                it.state = IdentityState.ACTIVE
+            }
+        }
+
+        // The first time adding the contact should succeed
+        testAddingContact(
+            apiConnectorResult,
+            {
+                assertTrue(it is Success)
+            },
+            newIdentity = newIdentity
+        )
+
+        val contactModel = contactModelRepository.getByIdentity(newIdentity)!!
+
+        // Downgrade the contact to a group contact
+        contactModel.setAcquaintanceLevelFromLocal(AcquaintanceLevel.GROUP)
+
+        // Assert that the acquaintance level change worked
+        assertEquals(AcquaintanceLevel.GROUP, contactModel.data.value!!.acquaintanceLevel)
+
+        // When adding the contact again, it should be converted back to a direct contact
+        testAddingContact(
+            apiConnectorResult,
+            {
+                assertTrue(it is ContactModified)
+                assertTrue(it.acquaintanceLevelChanged)
+                assertFalse(it.verificationLevelChanged)
+                assertEquals(AcquaintanceLevel.DIRECT, contactModel.data.value!!.acquaintanceLevel)
+            },
+            newIdentity = newIdentity
+        )
+    }
+
+    @Test
+    fun testVerificationLevelUpgrade() {
+        val newIdentity = "01234567"
+
+        val apiConnectorResult: (identity: String) -> FetchIdentityResult = { identity ->
+            FetchIdentityResult().also {
+                it.identity = identity
+                it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
+                it.featureMask = 12
+                it.type = 0
+                it.state = IdentityState.ACTIVE
+            }
+        }
+
+        // The first time adding the contact should succeed
+        testAddingContact(
+            apiConnectorResult,
+            {
+                assertTrue(it is Success)
+            },
+            newIdentity = newIdentity
+        )
+
+        val contactModel = contactModelRepository.getByIdentity(newIdentity)!!
+
+        // Assert that the verification level is unverified
+        assertEquals(VerificationLevel.UNVERIFIED, contactModel.data.value!!.verificationLevel)
+
+        // When adding the contact again, it should be converted back to a direct contact
+        testAddingContact(
+            apiConnectorResult,
+            {
+                assertTrue(it is ContactModified)
+                assertTrue(it.verificationLevelChanged)
+                assertFalse(it.acquaintanceLevelChanged)
+                assertEquals(VerificationLevel.FULLY_VERIFIED, contactModel.data.value!!.verificationLevel)
+            },
+            newIdentity = newIdentity,
+            publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
+        )
+    }
+
+    @Test
+    fun testAddAndVerifyGroupContact() {
+        val newIdentity = "01234567"
+
+        val apiConnectorResult: (identity: String) -> FetchIdentityResult = { identity ->
+            FetchIdentityResult().also {
+                it.identity = identity
+                it.publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
+                it.featureMask = 12
+                it.type = 0
+                it.state = IdentityState.ACTIVE
+            }
+        }
+
+        // The first time adding the contact should succeed
+        testAddingContact(
+            apiConnectorResult,
+            {
+                assertTrue(it is Success)
+            },
+            newIdentity = newIdentity
+        )
+
+        val contactModel = contactModelRepository.getByIdentity(newIdentity)!!
+
+        // Assert that the verification level is unverified
+        assertEquals(VerificationLevel.UNVERIFIED, contactModel.data.value!!.verificationLevel)
+
+        // Downgrade the contact to acquaintance level group
+        contactModel.setAcquaintanceLevelFromLocal(AcquaintanceLevel.GROUP)
+        assertEquals(AcquaintanceLevel.GROUP, contactModel.data.value!!.acquaintanceLevel)
+
+        // When adding the contact again, it should be converted back to a direct contact
+        testAddingContact(
+            apiConnectorResult,
+            {
+                assertTrue(it is ContactModified)
+                assertTrue(it.acquaintanceLevelChanged)
+                assertTrue(it.verificationLevelChanged)
+                assertEquals(AcquaintanceLevel.DIRECT, contactModel.data.value!!.acquaintanceLevel)
+                assertEquals(VerificationLevel.FULLY_VERIFIED, contactModel.data.value!!.verificationLevel)
+            },
+            newIdentity = newIdentity,
+            publicKey = ByteArray(NaCl.PUBLICKEYBYTES)
+        )
+    }
+
+    private fun testAddingContact(
+        fetchIdentity: (identity: String) -> FetchIdentityResult,
+        runOnFinished: (result: ContactAddResult) -> Unit,
+        newIdentity: String = "01234567",
+        myIdentity: String = "00000000",
+        publicKey: ByteArray? = null,
+    ) {
+        val apiConnector = getTestApiConnector {
+            if (it != newIdentity) {
+                fail("Wrong identity is fetched: $it")
+            }
+
+            fetchIdentity(it)
+        }
+
+        val contactAdded = backgroundExecutor.executeDeferred(object : AddOrUpdateContactBackgroundTask(
+            newIdentity,
+            myIdentity,
+            apiConnector,
+            contactModelRepository,
+            AddContactRestrictionPolicy.CHECK,
+            ThreemaApplication.getAppContext(),
+            publicKey,
+        ) {
+            override fun onFinished(result: ContactAddResult) {
+                runOnFinished(result)
+            }
+        })
+
+        // Assert that the test is not stopped before running the background task completely
+        runBlocking {
+            contactAdded.await()
+        }
+    }
+
+    private fun getTestApiConnector(onIdentityFetchCalled: (identity: String) -> FetchIdentityResult): APIConnector {
+        val sslSocketFactoryFactory = SSLSocketFactoryFactory { host: String? ->
+            ConfigUtils.getSSLSocketFactory(host)
+        }
+
+        return object : APIConnector(false, null, false, sslSocketFactoryFactory) {
+            override fun fetchIdentity(identity: String) = onIdentityFetchCalled(identity)
+        }
+    }
+
+}

+ 3 - 4
app/src/androidTest/java/ch/threema/app/groupmanagement/IncomingGroupLeaveTest.kt

@@ -25,7 +25,6 @@ import androidx.test.core.app.launchActivity
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.LargeTest
 import ch.threema.app.DangerousTest
-import kotlinx.coroutines.test.runTest
 import ch.threema.app.activities.HomeActivity
 import ch.threema.app.listeners.GroupListener
 import ch.threema.app.managers.ListenerManager
@@ -39,9 +38,10 @@ import junit.framework.TestCase.assertFalse
 import junit.framework.TestCase.assertTrue
 import junit.framework.TestCase.fail
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+
+import kotlinx.coroutines.test.runTest
 import org.junit.After
 import org.junit.Assert.assertArrayEquals
-import org.junit.Ignore
 import org.junit.Test
 import org.junit.runner.RunWith
 
@@ -74,8 +74,7 @@ class IncomingGroupLeaveTest : GroupControlTest<GroupLeaveMessage>() {
      * Test that the creator of a group cannot leave the group.
      */
     @Test
-    @Ignore("TODO(ANDR-2385): ignore group leave messages from group creators")
-    fun testLeaveFromSender() = runTest {
+    fun testLeaveFromCreator() = runTest {
         assertUnsuccessfulLeave(groupA, contactA)
         assertUnsuccessfulLeave(groupB, contactB)
     }

+ 0 - 9
app/src/androidTest/java/ch/threema/app/processors/IncomingMessageProcessorTest.kt

@@ -25,7 +25,6 @@ import ch.threema.app.DangerousTest
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.domain.models.MessageId
 import ch.threema.domain.protocol.csp.ProtocolDefines
-import ch.threema.domain.protocol.csp.ProtocolDefines.DELIVERYRECEIPT_MSGCONSUMED
 import ch.threema.domain.protocol.csp.ProtocolDefines.DELIVERYRECEIPT_MSGREAD
 import ch.threema.domain.protocol.csp.ProtocolDefines.DELIVERYRECEIPT_MSGRECEIVED
 import ch.threema.domain.protocol.csp.ProtocolDefines.DELIVERYRECEIPT_MSGUSERACK
@@ -129,14 +128,6 @@ class IncomingMessageProcessorTest : MessageProcessorProvider() {
             }.enrich(), contactA
         )
 
-        // Test 'consumed'
-        assertSuccessfulMessageProcessing(
-            DeliveryReceiptMessage().also {
-                it.receiptType = DELIVERYRECEIPT_MSGCONSUMED
-                it.receiptMessageIds = arrayOf(messageId)
-            }.enrich(), contactA
-        )
-
         // Test 'read'
         assertSuccessfulMessageProcessing(
             DeliveryReceiptMessage().also {

+ 24 - 9
app/src/androidTest/java/ch/threema/app/processors/MessageProcessorProvider.kt

@@ -26,21 +26,25 @@ import android.content.Intent
 import android.os.Build
 import androidx.test.platform.app.InstrumentationRegistry
 import androidx.test.rule.GrantPermissionRule
+import ch.threema.app.TestCoreServiceManager
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.managers.ServiceManager
+import ch.threema.app.multidevice.MultiDeviceManagerImpl
 import ch.threema.app.services.FileService
 import ch.threema.app.services.LifetimeService
+import ch.threema.app.tasks.TaskArchiverImpl
 import ch.threema.app.testutils.TestHelpers
 import ch.threema.app.testutils.TestHelpers.TestContact
 import ch.threema.app.testutils.TestHelpers.TestGroup
+import ch.threema.app.utils.DeviceCookieManagerImpl
 import ch.threema.app.utils.ForwardSecurityStatusSender
 import ch.threema.base.crypto.NonceFactory
 import ch.threema.base.crypto.NonceStore
 import ch.threema.domain.fs.DHSession
+import ch.threema.domain.helpers.DecryptTaskCodec
 import ch.threema.domain.helpers.InMemoryContactStore
 import ch.threema.domain.helpers.InMemoryDHSessionStore
 import ch.threema.domain.helpers.InMemoryNonceStore
-import ch.threema.domain.helpers.DecryptTaskCodec
 import ch.threema.domain.models.Contact
 import ch.threema.domain.models.GroupId
 import ch.threema.domain.protocol.ThreemaFeature
@@ -73,8 +77,8 @@ import org.junit.Before
 import org.junit.Rule
 import org.junit.rules.Timeout
 import java.io.File
-import java.util.LinkedList
 import java.util.Queue
+import java.util.concurrent.ConcurrentLinkedQueue
 
 open class MessageProcessorProvider {
 
@@ -111,10 +115,10 @@ open class MessageProcessorProvider {
 
     protected val serviceManager: ServiceManager = ThreemaApplication.requireServiceManager()
     private val contactStore: ContactStore = InMemoryContactStore().apply {
-        addContact(myContact.contact, true)
-        addContact(contactA.contact, true)
-        addContact(contactB.contact, true)
-        addContact(contactC.contact, true)
+        addContact(myContact.contact)
+        addContact(contactA.contact)
+        addContact(contactB.contact)
+        addContact(contactC.contact)
     }
 
     private val identityMap = listOf(
@@ -179,7 +183,7 @@ open class MessageProcessorProvider {
     private val globalTaskCodec =
         DecryptTaskCodec(contactStore, identityMap, forwardSecurityMessageProcessorMap)
 
-    private val globalTaskQueue: Queue<QueueEntry<*>> = LinkedList()
+    private val globalTaskQueue: Queue<QueueEntry<*>> = ConcurrentLinkedQueue()
 
     private data class QueueEntry<R>(
         private val task: Task<R, TaskCodec>,
@@ -379,9 +383,20 @@ open class MessageProcessorProvider {
     }
 
     private fun setTaskManager(taskManager: TaskManager) {
-        val field = ServiceManager::class.java.getDeclaredField("taskManager")
+        val serviceManager = ThreemaApplication.requireServiceManager()
+        val coreServiceManager = TestCoreServiceManager(
+            ThreemaApplication.getAppVersion(),
+            serviceManager.databaseServiceNew,
+            serviceManager.preferenceStore,
+            TaskArchiverImpl(serviceManager.databaseServiceNew.taskArchiveFactory),
+            serviceManager.deviceCookieManager as DeviceCookieManagerImpl,
+            taskManager,
+            serviceManager.multiDeviceManager as MultiDeviceManagerImpl
+        )
+
+        val field = ServiceManager::class.java.getDeclaredField("coreServiceManager")
         field.isAccessible = true
-        field.set(ThreemaApplication.getServiceManager(), taskManager)
+        field.set(ThreemaApplication.getServiceManager(), coreServiceManager)
     }
 
     private fun disableLifetimeService() {

+ 32 - 0
app/src/androidTest/java/ch/threema/app/tasks/PersistableTasksTest.kt

@@ -232,6 +232,38 @@ class PersistableTasksTest {
         )
     }
 
+    @Test
+    fun testOutgoingContactEditMessageTask() {
+        assertValidEncoding(
+            OutgoingContactEditMessageTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.OutgoingContactEditMessageTask.OutgoingContactEditMessageData\",\"toIdentity\":\"01234567\",\"messageId\":0, \"text\":\"test\", \"editedAt\":0}"
+        )
+    }
+
+    @Test
+    fun testOutgoingGroupEditMessageTask() {
+        assertValidEncoding(
+            OutgoingGroupEditMessageTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.OutgoingGroupEditMessageTask.OutgoingGroupEditMessageData\",\"messageId\":0, \"text\":\"test\", \"editedAt\":0,\"recipientIdentities\":[\"01234567\",\"01234567\"]}"
+        )
+    }
+
+    @Test
+    fun testOutgoingContactDeleteMessageTask() {
+        assertValidEncoding(
+            OutgoingContactDeleteMessageTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.OutgoingContactDeleteMessageTask.OutgoingContactDeleteMessageData\",\"toIdentity\":\"01234567\",\"messageId\":0, \"deletedAt\":0}"
+        )
+    }
+
+    @Test
+    fun testOutgoingGroupDeleteMessageTask() {
+        assertValidEncoding(
+            OutgoingGroupDeleteMessageTask::class.java,
+            "{\"type\":\"ch.threema.app.tasks.OutgoingGroupDeleteMessageTask.OutgoingGroupDeleteMessageData\",\"messageId\":0,\"deletedAt\":0,\"recipientIdentities\":[\"01234567\",\"01234567\"]}"
+        )
+    }
+
     private fun <T> assertValidEncoding(expectedTaskClass: Class<T>, encodedTask: String) {
         val decodedTask = encodedTask.decodeToTask()
         assertNotNull(decodedTask)

+ 2 - 1
app/src/androidTest/java/ch/threema/app/testutils/TestHelpers.java

@@ -46,6 +46,7 @@ import ch.threema.base.utils.Utils;
 import ch.threema.domain.helpers.InMemoryIdentityStore;
 import ch.threema.domain.models.Contact;
 import ch.threema.domain.models.GroupId;
+import ch.threema.domain.models.VerificationLevel;
 import ch.threema.domain.stores.IdentityStoreInterface;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
@@ -85,7 +86,7 @@ public class TestHelpers {
 
 		@NonNull
 		public Contact getContact() {
-			return new Contact(this.identity, this.publicKey);
+			return new Contact(this.identity, this.publicKey, VerificationLevel.UNVERIFIED);
 		}
 
 		@NonNull

+ 339 - 0
app/src/androidTest/java/ch/threema/data/ContactModelRepositoryTest.kt

@@ -0,0 +1,339 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.data
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import ch.threema.data.models.ModelDeletedException
+import ch.threema.data.repositories.ContactCreateException
+import ch.threema.data.repositories.ContactModelRepository
+import ch.threema.data.repositories.ModelRepositories
+import ch.threema.domain.models.ContactSyncState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.ReadReceiptPolicy
+import ch.threema.domain.models.TypingIndicatorPolicy
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.models.WorkVerificationLevel
+import ch.threema.storage.models.ContactModel
+import ch.threema.storage.models.ContactModel.AcquaintanceLevel
+import ch.threema.storage.models.ContactModel.State
+import ch.threema.testhelpers.nonSecureRandomArray
+import ch.threema.testhelpers.randomIdentity
+import com.neilalexander.jnacl.NaCl
+import junit.framework.TestCase.assertNotNull
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.runner.RunWith
+import java.util.Date
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+class ContactModelRepositoryTest {
+    private lateinit var databaseService: TestDatabaseService
+    private lateinit var contactModelRepository: ContactModelRepository
+
+    private enum class TestTriggerSource {
+        FROM_LOCAL,
+        FROM_REMOTE,
+    }
+
+    private val initialValuesSet = setOf(
+        InitialValues(),
+        InitialValues(publicKey = ByteArray(NaCl.PUBLICKEYBYTES) { it.toByte() }),
+        InitialValues(date = Date(42)),
+        InitialValues(identityType = IdentityType.WORK),
+        InitialValues(acquaintanceLevel = AcquaintanceLevel.GROUP),
+        InitialValues(activityState = State.INACTIVE),
+        InitialValues(featureMask = 64.toULong()),
+    )
+
+    @Before
+    fun before() {
+        this.databaseService = TestDatabaseService()
+        this.contactModelRepository = ModelRepositories(databaseService).contacts
+    }
+
+    @Test
+    fun createFromLocal() {
+        initialValuesSet.forEach { testCreateFromLocalOrRemote(it, TestTriggerSource.FROM_LOCAL) }
+    }
+
+    @Test
+    fun createFromRemote() {
+        initialValuesSet.forEach { testCreateFromLocalOrRemote(it, TestTriggerSource.FROM_REMOTE) }
+    }
+
+    @Test
+    fun createFromLocalTwice() {
+        initialValuesSet.forEach {
+            testCreateFromLocalOrRemoteTwice(it, TestTriggerSource.FROM_LOCAL)
+        }
+    }
+
+    @Test
+    fun createFromRemoteTwice() {
+        initialValuesSet.forEach {
+            testCreateFromLocalOrRemoteTwice(it, TestTriggerSource.FROM_REMOTE)
+        }
+    }
+
+    @Test
+    fun createFromSync() {
+        // TODO(ANDR-2835): Create contact from sync
+    }
+
+    @Test
+    fun getByIdentityNotFound() {
+        val model = contactModelRepository.getByIdentity("ABCDEFGH")
+        assertNull(model)
+    }
+
+    @Test
+    fun getByIdentityExisting() {
+        val identity = randomIdentity()
+        val publicKey = nonSecureRandomArray(32)
+
+        // Create contact using "old model"
+        databaseService.contactModelFactory.createOrUpdate(ContactModel(identity, publicKey))
+
+        // Fetch contact using "new model"
+        val model = contactModelRepository.getByIdentity(identity)
+        assertNotNull(model!!)
+        assertTrue { model.identity == identity }
+        assertTrue { model.data.value?.identity == identity }
+        assertContentEquals(publicKey, model.data.value?.publicKey)
+    }
+
+    @Test
+    fun deleteByIdentityNonExisting() {
+        // If model does not exist, no exception is thrown
+        contactModelRepository.deleteByIdentity("ABCDEFGH")
+        contactModelRepository.deleteByIdentity("ABCDEFGH")
+    }
+
+    @Test
+    fun deleteByIdentityExistingNotCached() {
+        // Create contact using "old model"
+        val identity = randomIdentity()
+        databaseService.contactModelFactory.createOrUpdate(ContactModel(identity, nonSecureRandomArray(32)))
+
+        // Delete through repository
+        contactModelRepository.deleteByIdentity(identity)
+
+        // Ensure that contact is gone
+        val model = contactModelRepository.getByIdentity(identity)
+        assertNull(model)
+    }
+
+    @Test
+    fun deleteByIdentityExistingCached() {
+        // Create contact using "old model"
+        val identity = randomIdentity()
+        databaseService.contactModelFactory.createOrUpdate(ContactModel(identity, nonSecureRandomArray(32)))
+
+        // Fetch model to ensure it's cached
+        val modelBeforeDeletion = contactModelRepository.getByIdentity(identity)
+        assertNotNull(modelBeforeDeletion)
+
+        // Delete through repository
+        contactModelRepository.deleteByIdentity(identity)
+
+        // Ensure that contact is gone
+        val modelAfterDeletion = contactModelRepository.getByIdentity(identity)
+        assertNull(modelAfterDeletion)
+    }
+
+    @Test
+    fun deleteExisting() {
+        // Create contact using "old model"
+        val identity = randomIdentity()
+        databaseService.contactModelFactory.createOrUpdate(ContactModel(identity, nonSecureRandomArray(32)))
+
+        // Fetch model
+        val model = contactModelRepository.getByIdentity(identity)
+        assertNotNull(model!!)
+
+        // Data is present, mutating model is possible
+        assertNotNull(model.data.value)
+        model.setNicknameFromSync("testnick")
+
+        // Delete through repository
+        contactModelRepository.delete(model)
+
+        // Data is gone, mutating model throws exception
+        assertNull(model.data.value)
+        assertFailsWith(ModelDeletedException::class) {
+            model.setNicknameFromSync("testnick")
+        }
+
+        // Ensure that contact is not cached anymore
+        val modelAfterDeletion = contactModelRepository.getByIdentity(identity)
+        assertNull(modelAfterDeletion)
+    }
+
+    private fun testCreateFromLocalOrRemote(
+        initialValues: InitialValues,
+        triggerSource: TestTriggerSource,
+    ) {
+        assertNull(contactModelRepository.getByIdentity(initialValues.identity))
+
+        val newModel = runBlocking {
+            when (triggerSource) {
+                TestTriggerSource.FROM_LOCAL -> contactModelRepository.createFromLocal(
+                    initialValues.identity,
+                    initialValues.publicKey,
+                    initialValues.date,
+                    initialValues.identityType,
+                    initialValues.acquaintanceLevel,
+                    initialValues.activityState,
+                    initialValues.featureMask,
+                )
+
+                TestTriggerSource.FROM_REMOTE -> contactModelRepository.createFromRemote(
+                    initialValues.identity,
+                    initialValues.publicKey,
+                    initialValues.date,
+                    initialValues.identityType,
+                    initialValues.acquaintanceLevel,
+                    initialValues.activityState,
+                    initialValues.featureMask,
+                )
+
+            }
+        }
+
+        // TODO(ANDR-3003): Test that transaction has been executed
+
+        val queriedModel = contactModelRepository.getByIdentity(initialValues.identity)
+        assertEquals(newModel, queriedModel)
+
+        assertDefaultValues(newModel, initialValues)
+
+        contactModelRepository.deleteByIdentity(initialValues.identity)
+
+        assertNull(contactModelRepository.getByIdentity(initialValues.identity))
+    }
+
+    private fun testCreateFromLocalOrRemoteTwice(
+        initialValues: InitialValues,
+        triggerSource: TestTriggerSource,
+    ) {
+        assertNull(contactModelRepository.getByIdentity(initialValues.identity))
+
+        val runCreation = when (triggerSource) {
+            TestTriggerSource.FROM_LOCAL -> suspend {
+                contactModelRepository.createFromLocal(
+                    initialValues.identity,
+                    initialValues.publicKey,
+                    initialValues.date,
+                    initialValues.identityType,
+                    initialValues.acquaintanceLevel,
+                    initialValues.activityState,
+                    initialValues.featureMask,
+                )
+            }
+
+            TestTriggerSource.FROM_REMOTE -> suspend {
+                contactModelRepository.createFromRemote(
+                    initialValues.identity,
+                    initialValues.publicKey,
+                    initialValues.date,
+                    initialValues.identityType,
+                    initialValues.acquaintanceLevel,
+                    initialValues.activityState,
+                    initialValues.featureMask,
+                )
+            }
+
+        }
+
+        // Insert it for the first time
+        val newModel = runBlocking {
+            runCreation()
+        }
+
+        // TODO(ANDR-3003): Test that transaction has been executed
+
+        val queriedModel = contactModelRepository.getByIdentity(initialValues.identity)
+        assertEquals(newModel, queriedModel)
+
+        assertDefaultValues(newModel, initialValues)
+
+        // Insert for the second time and assert that an exception is thrown
+        assertThrows(ContactCreateException::class.java) { runBlocking { runCreation() } }
+
+        contactModelRepository.deleteByIdentity(initialValues.identity)
+
+        assertNull(contactModelRepository.getByIdentity(initialValues.identity))
+    }
+
+    private data class InitialValues(
+        val identity: String = "ABCDEFGH",
+        val publicKey: ByteArray = ByteArray(NaCl.PUBLICKEYBYTES),
+        val date: Date = Date(),
+        val identityType: IdentityType = IdentityType.NORMAL,
+        val acquaintanceLevel: AcquaintanceLevel = AcquaintanceLevel.DIRECT,
+        val activityState: State = State.ACTIVE,
+        val featureMask: ULong = 4.toULong(),
+    )
+
+    private fun assertDefaultValues(
+        contactModel: ch.threema.data.models.ContactModel,
+        initialValues: InitialValues,
+    ) {
+        assertEquals(initialValues.identity, contactModel.identity)
+        val data = contactModel.data.value!!
+
+        // Assert that the given properties match
+        assertArrayEquals(initialValues.publicKey, data.publicKey)
+        assertEquals(initialValues.date.time, data.createdAt.time)
+        assertEquals(initialValues.identityType, data.identityType)
+        assertEquals(initialValues.acquaintanceLevel, data.acquaintanceLevel)
+        assertEquals(initialValues.activityState, data.activityState)
+        assertEquals(initialValues.featureMask, data.featureMask)
+
+        // Assert that the rest is set to the default values
+        assertEquals("", data.firstName)
+        assertEquals("", data.lastName)
+        assertNull(data.nickname)
+        assertEquals(VerificationLevel.UNVERIFIED, data.verificationLevel)
+        assertEquals(WorkVerificationLevel.NONE, data.workVerificationLevel)
+        assertEquals(ContactSyncState.INITIAL, data.syncState)
+        assertEquals(ReadReceiptPolicy.DEFAULT, data.readReceiptPolicy)
+        assertEquals(TypingIndicatorPolicy.DEFAULT, data.typingIndicatorPolicy)
+        assertNull(data.androidContactLookupKey)
+        assertNull(data.localAvatarExpires)
+        assertFalse(data.isRestored)
+        assertNull(data.profilePictureBlobId)
+        assertEquals(
+            ContactModel(data.identity, data.publicKey).idColorIndex.toUByte(),
+            data.colorIndex
+        )
+    }
+}

+ 36 - 0
app/src/androidTest/java/ch/threema/data/TestDatabaseService.kt

@@ -0,0 +1,36 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.data
+
+import androidx.test.core.app.ApplicationProvider
+import ch.threema.app.services.UpdateSystemServiceImpl
+import ch.threema.storage.DatabaseServiceNew
+
+/**
+ * An in-memory database used in android tests.
+ */
+class TestDatabaseService() : DatabaseServiceNew(
+    ApplicationProvider.getApplicationContext(),
+    null,
+    "test-database-key",
+    UpdateSystemServiceImpl()
+)

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

@@ -1,2 +1,3 @@
-* Behebung verschiedener Darstellungsfehler
-* GIFs werden neu mittels Glide gerendert
+* Verbesserte Unterstützung grosser Schriftgrössen
+* Verbesserte Anzeige von Benachrichtigungen mit Medienvorschau
+* Behebung verschiedener Fehler

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

@@ -1,2 +1,3 @@
-* Fixed various UI glitches
-* GIFs are now rendered using Glide
+* Improved support for large font sizes
+* Improved the display of notifications with media preview
+* Various bug fixes

+ 10 - 3
app/src/main/AndroidManifest.xml

@@ -13,9 +13,14 @@
 		android:smallScreens="true"
 		android:xlargeScreens="true"/>
 
+    <!-- Maplibre requires gl es version 3. To support more devices, we remove this requirement and
+    handle missing gl es version 3 support by disabling the location features on the affected
+    devices. -->
 	<uses-feature
-		android:glEsVersion="0x00020000"
-		android:required="true"/>
+		android:glEsVersion="0x00030000"
+		android:required="false"
+        tools:node="remove"
+        tools:selector="org.maplibre.android"/>
 
 	<!-- DANGEROUS PERMISSIONS -->
 	<!-- android.permission-group.CONTACTS -->
@@ -1052,7 +1057,9 @@
 			tools:node="merge">
 			<meta-data android:name="androidx.emoji2.text.EmojiCompatInitializer"
 				tools:node="remove" />
-		</provider>
+            <meta-data android:name="androidx.profileinstaller.ProfileInstallerInitializer"
+                tools:node="remove" />
+        </provider>
 
 		<!-- samsung multiwindow -->
 		<uses-library

+ 36 - 0
app/src/main/java/ch/threema/app/BuildFlavor.java

@@ -38,8 +38,13 @@ public class BuildFlavor {
 		NONE, GOOGLE, SERIAL, GOOGLE_WORK, HMS, HMS_WORK, ONPREM
 	}
 
+	public enum BuildEnvironment {
+		LIVE, SANDBOX, ONPREM
+	}
+
 	private static volatile boolean initialized = false;
 	private static LicenseType licenseType = null;
+	private static BuildEnvironment buildEnvironment = null;
 	private static String name = null;
 
 	/**
@@ -83,6 +88,14 @@ public class BuildFlavor {
 		return FLAVOR_LIBRE.equals(BuildConfig.FLAVOR);
 	}
 
+	/**
+	 * Return whether this build flavor uses the sandbox build environment.
+	 */
+	public static boolean isSandbox() {
+		init();
+		return buildEnvironment == BuildEnvironment.SANDBOX;
+	}
+
 	@SuppressWarnings("ConstantConditions")
 	private static synchronized void init() {
 		if (!initialized) {
@@ -117,6 +130,29 @@ public class BuildFlavor {
 					throw new IllegalStateException("Unhandled build flavor " + BuildConfig.FLAVOR);
 			}
 
+			// Build Environment
+			switch (BuildConfig.FLAVOR) {
+				case FLAVOR_GREEN:
+				case FLAVOR_BLUE:
+				case FLAVOR_SANDBOX_WORK:
+					buildEnvironment = BuildEnvironment.SANDBOX;
+					break;
+				case FLAVOR_ONPREM:
+					buildEnvironment = BuildEnvironment.ONPREM;
+					break;
+				case FLAVOR_NONE:
+				case FLAVOR_STORE_GOOGLE:
+				case FLAVOR_STORE_GOOGLE_WORK:
+				case FLAVOR_HMS:
+				case FLAVOR_HMS_WORK:
+				case FLAVOR_STORE_THREEMA:
+				case FLAVOR_LIBRE:
+					buildEnvironment = BuildEnvironment.LIVE;
+					break;
+				default:
+					throw new IllegalStateException("Unhandled build flavor " + BuildConfig.FLAVOR);
+			}
+
 			// Name
 			switch (BuildConfig.FLAVOR) {
 				case FLAVOR_STORE_GOOGLE:

+ 97 - 84
app/src/main/java/ch/threema/app/ThreemaApplication.java

@@ -106,6 +106,8 @@ import ch.threema.app.listeners.MessageListener;
 import ch.threema.app.listeners.NewSyncedContactsListener;
 import ch.threema.app.listeners.ServerMessageListener;
 import ch.threema.app.listeners.SynchronizeContactsListener;
+import ch.threema.app.managers.CoreServiceManager;
+import ch.threema.app.managers.CoreServiceManagerImpl;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.messagereceiver.ContactMessageReceiver;
@@ -165,6 +167,7 @@ import ch.threema.app.workers.WorkSyncWorker;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.Utils;
+import ch.threema.data.repositories.ModelRepositories;
 import ch.threema.domain.models.AppVersion;
 import ch.threema.domain.protocol.connection.ServerConnection;
 import ch.threema.domain.protocol.connection.ConnectionState;
@@ -438,7 +441,7 @@ public class ThreemaApplication extends Application implements DefaultLifecycleO
 
 						logger.info("master key is missing or does not match. rename database files.");
 
-						File databaseFile = getAppContext().getDatabasePath(DatabaseServiceNew.DATABASE_NAME_V4);
+						File databaseFile = getAppContext().getDatabasePath(DatabaseServiceNew.DEFAULT_DATABASE_NAME_V4);
 						if (databaseFile.exists()) {
 							File databaseBackup = new File(databaseFile.getPath() + ".backup");
 							if (!databaseFile.renameTo(databaseBackup)) {
@@ -627,8 +630,11 @@ public class ThreemaApplication extends Application implements DefaultLifecycleO
 		logger.info("*** Lifecycle: App now resumed");
 		isResumed = true;
 
-		if (serviceManager != null && serviceManager.getLifetimeService() != null) {
+		if (serviceManager != null) {
 			serviceManager.getLifetimeService().acquireConnection(ACTIVITY_CONNECTION_TAG);
+			logger.info("Connection now acquired");
+		} else {
+			logger.info("Service manager is null");
 		}
 	}
 
@@ -637,7 +643,7 @@ public class ThreemaApplication extends Application implements DefaultLifecycleO
 		logger.info("*** Lifecycle: App now paused");
 		isResumed = false;
 
-		if (serviceManager != null && serviceManager.getLifetimeService() != null) {
+		if (serviceManager != null) {
 			serviceManager.getLifetimeService().releaseConnectionLinger(ACTIVITY_CONNECTION_TAG, ACTIVITY_CONNECTION_LIFETIME);
 		}
 	}
@@ -837,6 +843,7 @@ public class ThreemaApplication extends Application implements DefaultLifecycleO
 
 			UpdateSystemService updateSystemService = new UpdateSystemServiceImpl();
 
+			// Instantiate database service
 			System.loadLibrary("sqlcipher");
 			DatabaseServiceNew databaseServiceNew = new DatabaseServiceNew(getAppContext(), databaseKey, updateSystemService);
 			databaseServiceNew.executeNull();
@@ -856,6 +863,17 @@ public class ThreemaApplication extends Application implements DefaultLifecycleO
 				dhSessionStore = new SQLDHSessionStore(context, masterKey.getKey(), updateSystemService);
 			}
 
+			// Instantiate core service manager. Note that the task manager should only be used to
+			// schedule tasks once the service manager is set.
+			final CoreServiceManager coreServiceManager = new CoreServiceManagerImpl(
+				appVersion,
+				databaseServiceNew,
+				preferenceStore
+			);
+
+			// Instantiate model repositories
+			final ModelRepositories modelRepositories = new ModelRepositories(databaseServiceNew);
+
 			logger.info("*** App launched");
 			logVersion();
 
@@ -910,12 +928,11 @@ public class ThreemaApplication extends Application implements DefaultLifecycleO
 			try {
 				// Instantiate service manager
 				serviceManager = new ServiceManager(
-					appVersion,
-					databaseServiceNew,
+					modelRepositories,
 					dhSessionStore,
 					identityStore,
-					preferenceStore,
 					masterKey,
+					coreServiceManager,
 					updateSystemService
 				);
 			} catch (ThreemaException e) {
@@ -1152,6 +1169,41 @@ public class ThreemaApplication extends Application implements DefaultLifecycleO
 		return true;
 	}
 
+	private static void showConversationNotification(AbstractMessageModel newMessage, boolean updateExisting) {
+		try {
+			ConversationService conversationService = serviceManager.getConversationService();
+			ConversationModel conversationModel = conversationService.refresh(newMessage);
+
+			if (conversationModel != null
+				&& !newMessage.isOutbox()
+				&& !newMessage.isStatusMessage()
+				&& !newMessage.isRead()) {
+
+				NotificationService notificationService = serviceManager.getNotificationService();
+				ContactService contactService = serviceManager.getContactService();
+				GroupService groupService = serviceManager.getGroupService();
+				DeadlineListService hiddenChatsListService = serviceManager.getHiddenChatsListService();
+
+				if (TestUtil.required(notificationService, contactService, groupService)) {
+					if (newMessage.getType() != MessageType.GROUP_CALL_STATUS) {
+						notificationService.showConversationNotification(ConversationNotificationUtil.convert(
+								getAppContext(),
+								newMessage,
+								contactService,
+								groupService,
+								hiddenChatsListService),
+							updateExisting);
+					}
+
+					// update widget on incoming message
+					WidgetUtil.updateWidgets(serviceManager.getContext());
+				}
+			}
+		} catch (ThreemaException e) {
+			logger.error("Exception", e);
+		}
+	}
+
 	private static void configureListeners() {
 		ListenerManager.groupListeners.add(new GroupListener() {
 			@Override
@@ -1270,21 +1322,6 @@ public class ThreemaApplication extends Application implements DefaultLifecycleO
 						null
 					);
 
-					// Show the orphaned group status when the creator left the group and it was not
-					// this user that left the group (as creator). This listener call is also
-					// triggered when the user (as creator) dissolves the group and in that case we
-					// do not want this status to be shown.
-					if (group.getCreatorIdentity().equals(identity) && !groupService.isGroupCreator(group)) {
-						// Show a group orphaned status message when the creator leaves the group
-						serviceManager.getMessageService().createGroupStatus(
-							receiver,
-							GroupStatusDataModel.GroupStatusType.ORPHANED,
-							null,
-							null,
-							null
-						);
-					}
-
 					BallotService ballotService = serviceManager.getBallotService();
 					ballotService.removeVotes(receiver, identity);
 				} catch (ThreemaException e) {
@@ -1403,15 +1440,14 @@ public class ThreemaApplication extends Application implements DefaultLifecycleO
 					if (!modifiedMessageModel.isStatusMessage()) {
 						try {
 							serviceManager.getConversationService().refresh(modifiedMessageModel);
+							if (!modifiedMessageModel.isStatusMessage() &&
+								modifiedMessageModel.getType() == MessageType.IMAGE) {
+								showConversationNotification(modifiedMessageModel, true);
+							}
 						} catch (ThreemaException e) {
 							logger.error("Exception", e);
 						}
 					}
-
-					if (!modifiedMessageModel.isStatusMessage() && modifiedMessageModel.getType() == MessageType.IMAGE) {
-						// update notification with image preview
-						showConversationNotification(modifiedMessageModel, true);
-					}
 				}
 			}
 
@@ -1450,43 +1486,10 @@ public class ThreemaApplication extends Application implements DefaultLifecycleO
 			public void onResendDismissed(@NonNull AbstractMessageModel messageModel) {
 				// Ignore
 			}
-
-			private void showConversationNotification(AbstractMessageModel newMessage, boolean updateExisting) {
-				try {
-					ConversationService conversationService = serviceManager.getConversationService();
-					ConversationModel conversationModel = conversationService.refresh(newMessage);
-
-					if (conversationModel != null
-							&& !newMessage.isOutbox()
-							&& !newMessage.isStatusMessage()
-							&& !newMessage.isRead()) {
-
-						NotificationService notificationService = serviceManager.getNotificationService();
-						ContactService contactService = serviceManager.getContactService();
-						GroupService groupService = serviceManager.getGroupService();
-						DeadlineListService hiddenChatsListService = serviceManager.getHiddenChatsListService();
-
-						if (TestUtil.required(notificationService, contactService, groupService)) {
-							if (newMessage.getType() != MessageType.GROUP_CALL_STATUS) {
-								notificationService.addConversationNotification(ConversationNotificationUtil.convert(
-										getAppContext(),
-										newMessage,
-										contactService,
-										groupService,
-										hiddenChatsListService),
-									updateExisting);
-							}
-
-							// update widget on incoming message
-							WidgetUtil.updateWidgets(serviceManager.getContext());
-						}
-					}
-				} catch (ThreemaException e) {
-					logger.error("Exception", e);
-				}
-			}
 		}, THREEMA_APPLICATION_LISTENER_TAG);
 
+		ListenerManager.editMessageListener.add(message -> showConversationNotification(message, true));
+
 		ListenerManager.groupJoinResponseListener.add((outgoingGroupJoinRequestModel, status) -> {
 			NotificationService n = serviceManager.getNotificationService();
 			if (n != null) {
@@ -1530,11 +1533,28 @@ public class ThreemaApplication extends Application implements DefaultLifecycleO
 
 		ListenerManager.contactListeners.add(new ContactListener() {
 			@Override
-			public void onModified(ContactModel modifiedContactModel) {
+			public void onModified(final @NonNull String identity) {
+				final ContactModel modifiedContactModel = serviceManager.getDatabaseServiceNew().getContactModelFactory().getByIdentity(identity);
+				if (modifiedContactModel == null) {
+					return;
+				}
+				if (modifiedContactModel.getAcquaintanceLevel() == ContactModel.AcquaintanceLevel.GROUP) {
+					this.onRemoved(modifiedContactModel.getIdentity());
+					return;
+				}
 				new Thread(() -> {
 					try {
-						serviceManager.getConversationService().refresh(modifiedContactModel);
-						ShortcutUtil.updatePinnedShortcut(serviceManager.getContactService().createReceiver(modifiedContactModel));
+						final ConversationService conversationService = serviceManager.getConversationService();
+						final ContactService contactService = serviceManager.getContactService();
+
+						// Remove contact from cache
+						contactService.removeFromCache(identity);
+
+						// Refresh conversation cache
+						conversationService.updateContactConversation(modifiedContactModel);
+						conversationService.refresh(modifiedContactModel);
+
+						ShortcutUtil.updatePinnedShortcut(contactService.createReceiver(modifiedContactModel));
 					} catch (ThreemaException e) {
 						logger.error("Exception", e);
 					}
@@ -1553,31 +1573,24 @@ public class ThreemaApplication extends Application implements DefaultLifecycleO
 			}
 
 			@Override
-			public void onNew(ContactModel createdContactModel) { }
-
-			@Override
-			public void onRemoved(ContactModel removedContactModel) {
+			public void onRemoved(@NonNull final String identity) {
 				new Thread(() -> {
 					try {
-						serviceManager.getConversationService().empty(removedContactModel);
-						serviceManager.getNotificationService().cancel(new ContactMessageReceiver(
-							removedContactModel,
-							serviceManager.getContactService(),
-							serviceManager,
-							null,
-							null,
-							null
-							)
-						);
+						// Remove stale contact model from contact service cache
+						serviceManager.getContactService().removeFromCache(identity);
+
+						// Empty and delete associated conversation
+						serviceManager.getConversationService().delete(identity);
 
-						//remove custom avatar (ANDR-353)
+						// Cancel notifications
+						serviceManager.getNotificationService().cancel(identity);
+
+						// Remove custom avatar (ANDR-353)
 						FileService f = serviceManager.getFileService();
-						if (f != null) {
-							f.removeContactAvatar(removedContactModel);
-							f.removeContactPhoto(removedContactModel);
-						}
+						f.removeContactAvatar(identity);
+						f.removeContactPhoto(identity);
 					} catch (ThreemaException e) {
-						logger.error("Exception", e);
+						logger.error("Error while handling removed contact", e);
 					}
 				}).start();
 			}

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

@@ -22,14 +22,14 @@
 package ch.threema.app.activities;
 
 import android.os.Bundle;
-import androidx.appcompat.app.ActionBar;
 import android.view.MenuItem;
 import android.widget.ImageView;
 import android.widget.Toast;
 
-import ch.threema.app.BuildConfig;
+import androidx.appcompat.app.ActionBar;
 import ch.threema.app.R;
 import ch.threema.app.utils.AnimationUtil;
+import ch.threema.app.utils.ConfigUtils;
 
 public class AboutActivity extends ThreemaToolbarActivity {
 
@@ -47,7 +47,7 @@ public class AboutActivity extends ThreemaToolbarActivity {
 		AnimationUtil.bubbleAnimate(threemaLogo, 200);
 
 		// Enable developer menu
-		if (BuildConfig.DEBUG) {
+		if (ConfigUtils.isDevBuild()) {
 			this.preferenceService.setShowDeveloperMenu(true);
 			Toast
 				.makeText(this, "You are now a craaazy developer!", Toast.LENGTH_LONG)

+ 85 - 148
app/src/main/java/ch/threema/app/activities/AddContactActivity.java

@@ -27,7 +27,6 @@ import android.annotation.TargetApi;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.net.Uri;
-import android.os.AsyncTask;
 import android.os.Build;
 import android.os.Bundle;
 import android.view.Window;
@@ -36,37 +35,45 @@ import android.widget.Toast;
 import com.google.android.material.snackbar.BaseTransientBottomBar;
 import com.google.android.material.snackbar.Snackbar;
 
+import org.slf4j.Logger;
+
 import java.io.IOException;
 import java.util.Date;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
 import androidx.fragment.app.DialogFragment;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
+import ch.threema.app.asynctasks.AddContactRestrictionPolicy;
+import ch.threema.app.asynctasks.AddOrUpdateContactBackgroundTask;
+import ch.threema.app.asynctasks.AlreadyVerified;
+import ch.threema.app.asynctasks.ContactAddResult;
+import ch.threema.app.asynctasks.ContactExists;
+import ch.threema.app.asynctasks.ContactModified;
+import ch.threema.app.asynctasks.Failed;
+import ch.threema.app.asynctasks.PolicyViolation;
+import ch.threema.app.asynctasks.Success;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.NewContactDialog;
-import ch.threema.app.exceptions.EntryAlreadyExistsException;
-import ch.threema.app.exceptions.FileSystemNotPresentException;
-import ch.threema.app.exceptions.InvalidEntryException;
-import ch.threema.app.exceptions.PolicyViolationException;
 import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.services.ContactService;
 import ch.threema.app.services.LockAppService;
 import ch.threema.app.services.QRCodeService;
-import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.IntentDataUtil;
-import ch.threema.app.utils.LogUtil;
 import ch.threema.app.utils.QRScannerUtil;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.app.utils.executor.BackgroundExecutor;
 import ch.threema.app.webclient.services.QRCodeParser;
 import ch.threema.app.webclient.services.QRCodeParserImpl;
 import ch.threema.base.utils.Base64;
-import ch.threema.localcrypto.MasterKeyLockedException;
-import ch.threema.storage.models.ContactModel;
+import ch.threema.base.utils.LoggingUtil;
+import ch.threema.data.repositories.ContactModelRepository;
+import ch.threema.domain.protocol.api.APIConnector;
 
 import static ch.threema.app.services.QRCodeServiceImpl.QR_TYPE_ID;
 import static ch.threema.domain.protocol.csp.ProtocolDefines.IDENTITY_LEN;
@@ -74,22 +81,23 @@ import static ch.threema.domain.protocol.csp.ProtocolDefines.IDENTITY_LEN;
 public class AddContactActivity extends ThreemaActivity implements GenericAlertDialog.DialogClickListener, NewContactDialog.NewContactDialogClickListener {
 	private static final String DIALOG_TAG_ADD_PROGRESS = "ap";
 	private static final String DIALOG_TAG_ADD_ERROR = "ae";
-	private static final String DIALOG_TAG_ADD_USER = "au";
 	private static final String DIALOG_TAG_ADD_BY_ID = "abi";
 	public static final String EXTRA_ADD_BY_ID = "add_by_id";
 	public static final String EXTRA_ADD_BY_QR = "add_by_qr";
 	public static final String EXTRA_QR_RESULT = "qr_result";
 
+	private static final Logger logger = LoggingUtil.getThreemaLogger("AddContactActivity");
+
 	private static final int PERMISSION_REQUEST_CAMERA = 1;
 
-	private ContactService contactService;
 	private QRCodeService qrCodeService;
 	private LockAppService lockAppService;
-	private ServiceManager serviceManager;
-	private AsyncTask<Void, Void, Exception> addContactTask;
+	private ContactModelRepository contactModelRepository;
+	private APIConnector apiConnector;
+	private final BackgroundExecutor backgroundExecutor = new BackgroundExecutor();
 
 	public void onCreate(Bundle savedInstanceState) {
-		serviceManager = ThreemaApplication.getServiceManager();
+		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 
 		if (serviceManager == null) {
 			finish();
@@ -97,11 +105,12 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 		}
 
 		try {
-			this.qrCodeService = this.serviceManager.getQRCodeService();
-			this.contactService = this.serviceManager.getContactService();
-			this.lockAppService = this.serviceManager.getLockAppService();
-		} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
-			LogUtil.exception(e, this);
+			this.qrCodeService = serviceManager.getQRCodeService();
+			this.lockAppService = serviceManager.getLockAppService();
+			this.contactModelRepository = serviceManager.getModelRepositories().getContacts();
+			this.apiConnector = serviceManager.getAPIConnector();
+		} catch (Exception e) {
+			logger.error("Could not instantiate services", e);
 			finish();
 			return;
 		}
@@ -184,67 +193,14 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 		}
 	}
 
-	@SuppressLint("StaticFieldLeak")
 	private void addContactByIdentity(final String identity) {
-		if (lockAppService.isLocked()) {
+		if (identity == null) {
+			logger.error("Identity is null");
 			finish();
 			return;
 		}
 
-		addContactTask = new AsyncTask<Void, Void, Exception>() {
-			ContactModel newContactModel;
-
-			@Override
-			protected void onPreExecute() {
-				GenericProgressDialog.newInstance(R.string.creating_contact, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_ADD_PROGRESS);
-			}
-
-			@Override
-			protected Exception doInBackground(Void... params) {
-				try {
-					newContactModel = contactService.createContactByIdentity(identity, false);
-				} catch (Exception e) {
-					return e;
-				}
-				return null;
-			}
-
-			@Override
-			protected void onPostExecute(Exception exception) {
-				if (isDestroyed()) {
-					return;
-				}
-
-				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_ADD_PROGRESS, true);
-
-				if (exception == null) {
-					newContactAdded(newContactModel);
-				} else if (exception instanceof EntryAlreadyExistsException) {
-					Toast.makeText(AddContactActivity.this, ((EntryAlreadyExistsException) exception).getTextId(), Toast.LENGTH_SHORT).show();
-					showContactDetail(identity);
-					finish();
-				} else if (exception instanceof InvalidEntryException){
-					GenericAlertDialog.newInstance(
-						ConfigUtils.isOnPremBuild() ?
-						R.string.invalid_onprem_id_title :
-						R.string.title_adduser,
-						((InvalidEntryException) exception).getTextId(), R.string.close, 0).show(getSupportFragmentManager(), DIALOG_TAG_ADD_ERROR);
-				} else if (exception instanceof PolicyViolationException) {
-					Toast.makeText(AddContactActivity.this, R.string.disabled_by_policy_short, Toast.LENGTH_SHORT).show();
-					finish();
-				}
-			}
-		};
-		addContactTask.execute();
-	}
-
-	@Override
-	protected void onDestroy() {
-		if (addContactTask != null) {
-			addContactTask.cancel(true);
-		}
-
-		super.onDestroy();
+		addContactByIdentity(identity, null);
 	}
 
 	/**
@@ -272,81 +228,64 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 			return;
 		}
 
-		ContactModel contactModel = contactService.getByPublicKey(qrResult.getPublicKey());
-
-		if (contactModel != null) {
-			// contact already exists - update it
-			boolean c = true;
-
-			int contactVerification = this.contactService.updateContactVerification(contactModel.getIdentity(), qrResult.getPublicKey());
-			int textResId;
-			switch (contactVerification) {
-				case ContactService.ContactVerificationResult_ALREADY_VERIFIED:
-					textResId = R.string.scan_duplicate;
-					break;
-				case ContactService.ContactVerificationResult_VERIFIED:
-					textResId = R.string.scan_successful;
-					break;
-				default:
-					textResId = R.string.id_mismatch;
-					c = false;
-			}
-
-			if(!c) {
-				GenericAlertDialog.newInstance(R.string.title_adduser, getString(textResId), R.string.ok, 0).show(getSupportFragmentManager(), DIALOG_TAG_ADD_USER);
-			}
-			else {
-				if (contactService.getIsHidden(contactModel.getIdentity())) {
-					contactService.setIsHidden(contactModel.getIdentity(), false);
-					newContactAdded(contactModel);
-				} else {
-					Toast.makeText(this.getApplicationContext(), textResId, Toast.LENGTH_SHORT).show();
-					showContactDetail(contactModel.getIdentity());
-					this.finish();
-				}
-			}
-		} else {
-			if (AppRestrictionUtil.isAddContactDisabled(this)) {
-				Toast.makeText(AddContactActivity.this, R.string.disabled_by_policy_short, Toast.LENGTH_SHORT).show();
-				finish();
-				return;
-			}
+		addContactByIdentity(qrResult.getIdentity(), qrResult.getPublicKey());
+	}
 
-			// add new contact
-			new AsyncTask<Void, Void, String>() {
-				ContactModel newContactModel;
+	private void addContactByIdentity(@NonNull String identity, @Nullable byte[] publicKey) {
+		if (lockAppService.isLocked()) {
+			finish();
+			return;
+		}
 
-				@Override
-				protected void onPreExecute() {
-					GenericProgressDialog.newInstance(R.string.creating_contact, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_ADD_PROGRESS);
-				}
+		backgroundExecutor.execute(new AddOrUpdateContactBackgroundTask(
+			identity,
+			getMyIdentity(),
+			apiConnector,
+			contactModelRepository,
+			AddContactRestrictionPolicy.CHECK,
+			this,
+			publicKey
+		) {
+			@Override
+			public void onBefore() {
+				GenericProgressDialog.newInstance(R.string.creating_contact, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_ADD_PROGRESS);
+			}
 
-				@Override
-				protected String doInBackground(Void... params) {
-					try {
-						newContactModel = contactService.createContactByQRResult(qrResult);
-					} catch (final InvalidEntryException e) {
-						return getString(e.getTextId());
-					} catch (final EntryAlreadyExistsException e) {
-						return getString(e.getTextId());
-					} catch (final PolicyViolationException e) {
-						return getString(R.string.disabled_by_policy_short);
-					}
-					return null;
+			@Override
+			public void onFinished(@NonNull ContactAddResult result) {
+				if (isDestroyed()) {
+					return;
 				}
 
-				@Override
-				protected void onPostExecute(String message) {
-					DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_ADD_PROGRESS, true);
+				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_ADD_PROGRESS, true);
 
-					if (TestUtil.empty(message)) {
-						newContactAdded(newContactModel);
+				if (result instanceof Success) {
+					showContactAndFinish(identity, R.string.creating_contact_successful);
+				} else if (result instanceof ContactModified) {
+					if (((ContactModified) result).getAcquaintanceLevelChanged()) {
+						showContactAndFinish(identity, R.string.creating_contact_successful);
 					} else {
-						GenericAlertDialog.newInstance(R.string.title_adduser, message, R.string.ok, 0).show(getSupportFragmentManager(), DIALOG_TAG_ADD_USER);
+						showContactAndFinish(identity, R.string.scan_successful);
 					}
+				} else if (result instanceof AlreadyVerified) {
+					showContactAndFinish(identity, R.string.scan_duplicate);
+				} else if (result instanceof ContactExists) {
+					showContactAndFinish(identity, R.string.identity_already_exists);
+				} else if (result instanceof Failed) {
+					GenericAlertDialog.newInstance(
+						ConfigUtils.isOnPremBuild() ?
+							R.string.invalid_onprem_id_title :
+							R.string.title_adduser,
+						((Failed) result).getMessage(),
+						R.string.close,
+						0
+					).show(getSupportFragmentManager(), DIALOG_TAG_ADD_ERROR);
+				} else if (result instanceof PolicyViolation) {
+					Toast.makeText(AddContactActivity.this, R.string.disabled_by_policy_short, Toast.LENGTH_SHORT).show();
+					finish();
 				}
-			}.execute();
-		}
+			}
+		});
 	}
 
 	private void showContactDetail(String id) {
@@ -356,12 +295,10 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 		finish();
 	}
 
-	private void newContactAdded(ContactModel contactModel) {
-		if(contactModel != null) {
-			Toast.makeText(this.getApplicationContext(), R.string.creating_contact_successful, Toast.LENGTH_SHORT).show();
- 			showContactDetail(contactModel.getIdentity());
-			finish();
-		}
+	private void showContactAndFinish(@NonNull String identity, @StringRes int stringRes) {
+		Toast.makeText(this.getApplicationContext(), stringRes, Toast.LENGTH_SHORT).show();
+		showContactDetail(identity);
+		finish();
 	}
 
 	private void scanQR() {

+ 8 - 7
app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.java

@@ -67,7 +67,7 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 
 	@Override
 	public void onCreate(Bundle savedInstanceState) {
-		logger.debug("onCreate");
+		logger.info("onCreate");
 
 		getWindow().setAllowEnterTransitionOverlap(true);
 		getWindow().setAllowReturnTransitionOverlap(true);
@@ -98,7 +98,7 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 			return false;
 		}
 
-		logger.debug("initActivity");
+		logger.info("initActivity");
 
 		this.getFragments();
 
@@ -140,7 +140,7 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 
 	@Override
 	public void onNewIntent(Intent intent) {
-		logger.debug("onNewIntent");
+		logger.info("onNewIntent");
 
 		super.onNewIntent(intent);
 
@@ -163,6 +163,7 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 
 	@Override
 	protected void handleOnBackPressed() {
+		logger.info("handleOnBackPressed");
 		if (ConfigUtils.isTabletLayout()) {
 			if (messageSectionFragment != null) {
 				if (messageSectionFragment.onBackPressed()) {
@@ -190,13 +191,13 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 
 	@Override
 	public void onStop() {
-		logger.debug("onStop");
+		logger.info("onStop");
 		super.onStop();
 	}
 
 	@Override
 	public void onResume() {
-		logger.debug("onResume");
+		logger.info("onResume");
 		super.onResume();
 
 		// Set the soft input mode to resize when activity resumes because it is set to adjust nothing while it is paused
@@ -206,7 +207,7 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 
 	@Override
 	public void onPause() {
-		logger.debug("onPause");
+		logger.info("onPause");
 		super.onPause();
 
 		// Set the soft input mode to adjust nothing while paused. This is needed when the keyboard is opened to edit the contact before sending.
@@ -271,7 +272,7 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 		if (messageReceiver != null) {
 			if (serviceManager != null) {
 				DeadlineListService hiddenChatsListService = serviceManager.getHiddenChatsListService();
-				if (hiddenChatsListService != null && hiddenChatsListService.has(messageReceiver.getUniqueIdString())) {
+				if (hiddenChatsListService.has(messageReceiver.getUniqueIdString())) {
 					if (preferenceService != null && ConfigUtils.hasProtection(preferenceService)) {
 						HiddenChatUtil.launchLockCheckDialog(this, null, preferenceService, requestCode);
 					} else {

+ 175 - 148
app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java

@@ -21,8 +21,6 @@
 
 package ch.threema.app.activities;
 
-import static ch.threema.app.utils.QRScannerUtil.REQUEST_CODE_QR_SCANNER;
-
 import android.Manifest;
 import android.annotation.SuppressLint;
 import android.annotation.TargetApi;
@@ -40,16 +38,6 @@ import android.view.View;
 import android.widget.TextView;
 import android.widget.Toast;
 
-import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.UiThread;
-import androidx.appcompat.app.ActionBar;
-import androidx.appcompat.view.menu.MenuBuilder;
-import androidx.fragment.app.Fragment;
-import androidx.lifecycle.LifecycleOwner;
-import androidx.recyclerview.widget.LinearLayoutManager;
-import androidx.recyclerview.widget.RecyclerView;
-
 import com.bumptech.glide.Glide;
 import com.google.android.material.appbar.AppBarLayout;
 import com.google.android.material.appbar.CollapsingToolbarLayout;
@@ -60,7 +48,19 @@ import org.slf4j.Logger;
 import java.io.File;
 import java.util.Date;
 import java.util.List;
+import java.util.Objects;
 
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.view.menu.MenuBuilder;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.adapters.ContactDetailAdapter;
@@ -77,7 +77,6 @@ import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.DeadlineListService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.IdListService;
-import ch.threema.app.services.MessageService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.QRCodeService;
 import ch.threema.app.services.QRCodeServiceImpl;
@@ -101,10 +100,14 @@ import ch.threema.app.voip.services.VoipStateService;
 import ch.threema.app.voip.util.VoipUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.data.models.ContactModelData;
+import ch.threema.data.repositories.ModelRepositories;
 import ch.threema.domain.models.VerificationLevel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 
+import static ch.threema.app.utils.QRScannerUtil.REQUEST_CODE_QR_SCANNER;
+
 public class ContactDetailActivity extends ThreemaToolbarActivity
 		implements LifecycleOwner,
 					GenericAlertDialog.DialogClickListener,
@@ -125,40 +128,47 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 
 	private static final int REQUEST_CODE_CONTACT_EDITOR = 39255;
 
-	private ContactModel contact;
-	private String identity;
+	// Services
 	private ContactService contactService;
 	private GroupService groupService;
 	private IdListService blackListIdentityService, profilePicRecipientsService;
-	private MessageService messageService;
 	private DeadlineListService hiddenChatsListService;
 	private VoipStateService voipStateService;
-	private MenuItem blockMenuItem = null, profilePicItem = null, profilePicSendItem = null, callItem = null;
+
+	// Data and state holders
+	private String identity;
+	@Deprecated
+	private ContactModel contact;
+	private @Nullable ContactDetailViewModel viewModel; // Initially null, until initialized
+	private List<GroupModel> groupList;
 	private boolean isReadonly;
+	private boolean isDisabledProfilePicReleaseSettings = false;
+
+	// Views
+	private MenuItem blockMenuItem = null, profilePicItem = null, profilePicSendItem = null, callItem = null;
 	private ResumePauseHandler resumePauseHandler;
 	private RecyclerView contactDetailRecyclerView;
 	private AvatarEditView avatarEditView;
 	private FloatingActionButton floatingActionButton;
 	private TextView contactTitle;
-	private CollapsingToolbarLayout collapsingToolbar;
-	private List<GroupModel> groupList;
-	private boolean isDisabledProfilePicReleaseSettings = false;
 	private View workIcon;
 
+	private void refreshAdapter() {
+		contactDetailRecyclerView.setAdapter(setupAdapter());
+	}
+
 	private final ResumePauseHandler.RunIfActive runIfActiveUpdate = new ResumePauseHandler.RunIfActive() {
 		@Override
 		public void runOnUiThread() {
-			reload();
-
 			groupList = groupService.getGroupsByIdentity(identity);
-			contactDetailRecyclerView.setAdapter(setupAdapter());
+			refreshAdapter();
 		}
 	};
 	private final ResumePauseHandler.RunIfActive runIfActiveGroupUpdate = new ResumePauseHandler.RunIfActive() {
 		@Override
 		public void runOnUiThread() {
 			groupList = groupService.getGroupsByIdentity(identity);
-			contactDetailRecyclerView.setAdapter(setupAdapter());
+			refreshAdapter();
 		}
 	};
 
@@ -183,28 +193,26 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 
 	private final ContactListener contactListener = new ContactListener() {
 		@Override
-		public void onModified(ContactModel modifiedContactModel) {
-		 	RuntimeUtil.runOnUiThread(() -> {
-				 updateBlockMenu();
-			 });
+		public void onModified(final @NonNull String identity) {
+			if (!this.shouldHandleChange(identity)) {
+				return;
+			}
+			RuntimeUtil.runOnUiThread(() -> updateBlockMenu());
 			resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
 		}
 
 		@Override
 		public void onAvatarChanged(ContactModel contactModel) {
+			if (!this.shouldHandleChange(contactModel.getIdentity())) {
+				return;
+			}
 			RuntimeUtil.runOnUiThread(() -> updateProfilepicMenu());
 			resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
 		}
 
-		@Override
-		public void onRemoved(ContactModel removedContactModel) {
-			//whaat, finish!
-		 	RuntimeUtil.runOnUiThread(() -> finish());
-		}
-
-		@Override
-		public boolean handle(String identity) {
-			return TestUtil.compare(contact.getIdentity(), identity);
+		/** @noinspection BooleanMethodIsAlwaysInverted*/
+		public boolean shouldHandleChange(@NonNull String identity) {
+			return identity.equals(ContactDetailActivity.this.identity);
 		}
 	};
 
@@ -271,13 +279,11 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		super.onCreate(savedInstanceState);
 
 		this.identity = this.getIntent().getStringExtra(ThreemaApplication.INTENT_DATA_CONTACT);
-
 		if (this.identity == null || this.identity.length() == 0) {
-			logger.error("no identity", this);
+			logger.error("no identity");
 			this.finish();
 			return;
 		}
-
 		if (this.identity.equals(getMyIdentity())) {
 			this.finish();
 			return;
@@ -292,12 +298,14 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 
 		this.resumePauseHandler = ResumePauseHandler.getByActivity(this, this);
 
+		// Set up services
+		final ModelRepositories modelRepositories;
 		try {
 			this.contactService = serviceManager.getContactService();
+			modelRepositories = serviceManager.getModelRepositories();
 			this.blackListIdentityService = serviceManager.getBlackListService();
 			this.profilePicRecipientsService = serviceManager.getProfilePicRecipientsService();
 			this.groupService = serviceManager.getGroupService();
-			this.messageService = serviceManager.getMessageService();
 			this.hiddenChatsListService = serviceManager.getHiddenChatsListService();
 			this.voipStateService = serviceManager.getVoipStateService();
 		} catch (Exception e) {
@@ -306,37 +314,38 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 			return;
 		}
 
-		if (this.contactService == null) {
-			logger.error("no contact service", this);
-			finish();
-			return;
-		}
-
+		// Look up contact data
 		this.contact = this.contactService.getByIdentity(this.identity);
-		if (this.contact == null) {
+		final ch.threema.data.models.ContactModel contactModel = modelRepositories.getContacts().getByIdentity(this.identity);
+		if (this.contact == null || contactModel == null) {
 			Toast.makeText(this, R.string.contact_not_found, Toast.LENGTH_LONG).show();
 			this.finish();
 			return;
 		}
+		this.groupList = this.groupService.getGroupsByIdentity(this.identity);
+
+		// Look up viewmodel
+		this.viewModel = new ViewModelProvider(
+			this,
+			ContactDetailViewModel.Companion.getFactory()
+		).get(ContactDetailViewModel.class);
 
-		this.collapsingToolbar = findViewById(R.id.collapsing_toolbar);
-		if (this.collapsingToolbar == null) {
+		// Set up toolbar
+		final CollapsingToolbarLayout collapsingToolbar = findViewById(R.id.collapsing_toolbar);
+		if (collapsingToolbar == null) {
 			logger.debug("Collapsing Toolbar not available");
 			finish();
 			return;
 		}
-
-		this.collapsingToolbar.setTitle(" ");
+		collapsingToolbar.setTitle(" ");
 		@ColorInt int scrimColor = contactService.getAvatarColor(contact);
-		this.collapsingToolbar.setContentScrimColor(scrimColor);
-		this.collapsingToolbar.setStatusBarScrimColor(scrimColor);
+		collapsingToolbar.setContentScrimColor(scrimColor);
+		collapsingToolbar.setStatusBarScrimColor(scrimColor);
 
+		// Look up view references
+		this.contactTitle = findViewById(R.id.contact_title);
+		this.workIcon = findViewById(R.id.work_icon);
 		this.avatarEditView = findViewById(R.id.avatar_edit_view);
-		this.avatarEditView.setHires(true);
-		this.avatarEditView.setContactModel(contact);
-
-		this.isReadonly = getIntent().getBooleanExtra(ThreemaApplication.INTENT_DATA_CONTACT_READONLY, false);
-
 		this.contactDetailRecyclerView = findViewById(R.id.contact_group_list);
 		if (this.contactDetailRecyclerView == null) {
 			logger.error("list not available");
@@ -344,13 +353,13 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 			return;
 		}
 
-		this.contactTitle = findViewById(R.id.contact_title);
-		this.workIcon = findViewById(R.id.work_icon);
-		ViewUtil.show(workIcon, contactService.showBadge(contact));
-		this.workIcon.setContentDescription(getString(ConfigUtils.isWorkBuild() ? R.string.private_contact : R.string.threema_work_contact));
+		// Configure avatar view
+		this.avatarEditView.setHires(true);
+		this.avatarEditView.setContactModel(contact);
 
-		this.groupList = this.groupService.getGroupsByIdentity(this.identity);
+		this.isReadonly = getIntent().getBooleanExtra(ThreemaApplication.INTENT_DATA_CONTACT_READONLY, false);
 
+		// Hide profile picture release settings if restricted by MDM
 		if (ConfigUtils.isWorkRestricted()) {
 			Boolean value = AppRestrictionUtil.getBooleanRestriction(getString(R.string.restriction__disable_send_profile_picture));
 			if (value != null) {
@@ -358,11 +367,24 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 			}
 		}
 
+		// Subscribe to viewmodel changes
+		this.viewModel.getContact().observe(this, this::onContactModelDataUpdate);
+
+		// Set up contact detail recycler view
 		this.contactDetailRecyclerView.setLayoutManager(new LinearLayoutManager(this));
 		this.contactDetailRecyclerView.setAdapter(setupAdapter());
 
-		if (this.contact.isHidden()) {
-			this.reload();
+		// Set description for badge
+		this.workIcon.setContentDescription(
+			getString(ConfigUtils.isWorkBuild() ? R.string.private_contact : R.string.threema_work_contact)
+		);
+
+		// Get current contact model (only used for further initialization)
+		//
+		// Note: This logic should probably be changed to be more reactive, instead of using
+		//       the contact model data snapshot here.
+		final ContactModelData contactModelDataSnapshot = contactModel.getData().getValue();
+		if (contactModelDataSnapshot.acquaintanceLevel == ContactModel.AcquaintanceLevel.GROUP) {
 			GenericAlertDialog.newInstance(
 				R.string.menu_add_contact,
 				String.format(getString(R.string.contact_add_confirm), NameUtil.getDisplayNameOrNickname(contact, true)),
@@ -371,10 +393,9 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 			).show(getSupportFragmentManager(), DIALOG_TAG_ADD_CONTACT);
 		} else {
 			onCreateLocal();
-			this.reload();
 
 			if (savedInstanceState == null) {
-				if (!ConfigUtils.isWorkBuild() && contactService.showBadge(contact)) {
+				if (!ConfigUtils.isWorkBuild() && contactService.showBadge(contactModelDataSnapshot)) {
 					if (!preferenceService.getIsWorkHintTooltipShown()) {
 						showWorkTooltip();
 					}
@@ -393,14 +414,9 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		ListenerManager.groupListeners.add(this.groupListener);
 
 		this.floatingActionButton = findViewById(R.id.floating);
-		this.floatingActionButton.setOnClickListener(new View.OnClickListener() {
-			@Override
-			public void onClick(View v) {
-				openContactEditor();
-			}
-		});
+		this.floatingActionButton.setOnClickListener(v -> openContactEditor());
 
-		if (contact.getAndroidContactLookupKey() != null) {
+		if (Objects.requireNonNull(viewModel).showEditFAB()) {
 			floatingActionButton.setContentDescription(getString(R.string.edit));
 			floatingActionButton.setImageResource(R.drawable.ic_outline_contacts_app_24);
 		}
@@ -410,6 +426,30 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		}
 	}
 
+	/**
+	 * Update the UI whenever the contact model data changes.
+	 */
+	@UiThread
+	private void onContactModelDataUpdate(@Nullable ContactModelData contactModelData) {
+		logger.debug("Contact data updated");
+
+		if (contactModelData == null) {
+			// The contact has been deleted. Therefore we finish this activity.
+			Toast.makeText(this, R.string.contact_deleted, Toast.LENGTH_SHORT).show();
+			finish();
+			return;
+		}
+
+		// Update name
+		this.contactTitle.setText(contactModelData.getDisplayName());
+
+		// Show or hide badge for work/private contacts
+		ViewUtil.show(workIcon, contactService.showBadge(contactModelData));
+
+		// Update adapter
+		this.refreshAdapter();
+	}
+
 	private void showWorkTooltip() {
 		workIcon.postDelayed(() -> {
 			int[] location = new int[2];
@@ -422,7 +462,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 
 			final AppBarLayout appBarLayout = findViewById(R.id.appbar);
 			if (appBarLayout != null) {
-				appBarLayout.addOnOffsetChangedListener(new AppBarLayout.BaseOnOffsetChangedListener() {
+				appBarLayout.addOnOffsetChangedListener(new AppBarLayout.BaseOnOffsetChangedListener<>() {
 					@Override
 					public void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) {
 						workTooltipPopup.dismiss(false);
@@ -431,28 +471,31 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 				});
 			}
 
-			new Handler().postDelayed(new Runnable() {
-				@Override
-				public void run() {
-					RuntimeUtil.runOnUiThread(new Runnable() {
-						@Override
-						public void run() {
-							workTooltipPopup.dismiss(false);
-						}
-					});
-				}
-			}, 4000);
+			new Handler().postDelayed(
+				() -> RuntimeUtil.runOnUiThread(
+					() -> workTooltipPopup.dismiss(false)
+				),
+				4000
+			);
 		}, 1000);
 	}
 
+	@UiThread
 	private ContactDetailAdapter setupAdapter() {
-		ContactDetailAdapter groupMembershipAdapter = new ContactDetailAdapter(
+		// By the time `setupAdapter` is called for the first time, the viewmodel should
+		// already be initialized.
+		final ContactDetailViewModel viewModel = Objects.requireNonNull(this.viewModel);
+		final ContactModelData contactModelData = Objects.requireNonNull(viewModel.getContact().getValue());
+
+		final ContactDetailAdapter contactDetailAdapter = new ContactDetailAdapter(
 			this,
 			this.groupList,
 			contact,
+			contactModelData,
 			Glide.with(this)
 		);
-		groupMembershipAdapter.setOnClickListener(new ContactDetailAdapter.OnClickListener() {
+
+		contactDetailAdapter.setOnClickListener(new ContactDetailAdapter.OnClickListener() {
 			@Override
 			public void onItemClick(View v, GroupModel groupModel) {
 
@@ -469,7 +512,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 			}
 		});
 
-		return groupMembershipAdapter;
+		return contactDetailAdapter;
 	}
 
 	@Override
@@ -505,10 +548,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		}
 	}
 
-	private void reload() {
-		this.contactTitle.setText(NameUtil.getDisplayNameOrNickname(contact, true));
-	}
-
 	@Override
 	public void onResume() {
 		if (this.resumePauseHandler != null) {
@@ -560,12 +599,19 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		}.execute();
 	}
 
-	private String getZeroLengthToNull(String v) {
-		return v == null || v.length() == 0 ? null : v;
-	}
-
 	private void editName() {
-		ContactEditDialog contactEditDialog = ContactEditDialog.newInstance(contact);
+		if (viewModel == null) {
+			logger.error("View model is null");
+			return;
+		}
+
+		ContactModelData contactModelData = viewModel.getContact().getValue();
+		if (contactModelData == null) {
+			logger.error("Contact model data is null");
+			return;
+		}
+
+		ContactEditDialog contactEditDialog = ContactEditDialog.newInstance(contactModelData);
 		contactEditDialog.show(getSupportFragmentManager(), DIALOG_TAG_EDIT);
 	}
 
@@ -594,7 +640,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		}
 
 		// display verification level in action bar
-		if (contact != null && contact.getVerificationLevel() != VerificationLevel.FULLY_VERIFIED) {
+		if (contact != null && contact.verificationLevel != VerificationLevel.FULLY_VERIFIED) {
 			MenuItem menuItem = menu.findItem(R.id.action_scan_id);
 			menuItem.setVisible(true);
 		}
@@ -656,7 +702,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		} else if (id == R.id.menu_threema_call) {
 			VoipUtil.initiateCall(this, contact, false, null);
 		} else if (id == R.id.action_block_contact) {
-			if (this.blackListIdentityService != null && this.blackListIdentityService.has(this.contact.getIdentity())) {
+			if (this.blackListIdentityService != null && this.blackListIdentityService.has(this.identity)) {
 				blockContact();
 			} else {
 				GenericAlertDialog.newInstance(R.string.block_contact, R.string.really_block_contact, R.string.yes, R.string.no).show(getSupportFragmentManager(), DIALOG_TAG_CONFIRM_BLOCK);
@@ -670,10 +716,10 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 				startActivity(mediaGalleryIntent);
 			}
 		} else if (id == R.id.action_add_profilepic_recipient) {
-			if (!profilePicRecipientsService.has(contact.getIdentity())) {
-				profilePicRecipientsService.add(contact.getIdentity());
+			if (!profilePicRecipientsService.has(this.identity)) {
+				profilePicRecipientsService.add(this.identity);
 			} else {
-				profilePicRecipientsService.remove(contact.getIdentity());
+				profilePicRecipientsService.remove(this.identity);
 			}
 			updateProfilepicMenu();
 		} else if (id == R.id.action_send_profilepic) {
@@ -685,7 +731,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	}
 
 	private void sendProfilePic() {
-		serviceManager.getTaskCreator().scheduleProfilePictureSendTaskAsync(contact.getIdentity());
+		serviceManager.getTaskCreator().scheduleProfilePictureSendTaskAsync(this.identity);
 	}
 
 	private void blockContact() {
@@ -696,7 +742,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 
 	private void updateBlockMenu() {
 		if (this.blockMenuItem != null) {
-			if (blackListIdentityService != null && blackListIdentityService.has(contact.getIdentity())) {
+			if (blackListIdentityService != null && blackListIdentityService.has(this.identity)) {
 				blockMenuItem.setTitle(R.string.unblock_contact);
 			} else {
 				blockMenuItem.setTitle(R.string.block_contact);
@@ -716,11 +762,11 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 			switch (preferenceService.getProfilePicRelease()) {
 				case PreferenceService.PROFILEPIC_RELEASE_EVERYONE:
 					this.profilePicItem.setVisible(false);
-					this.profilePicSendItem.setVisible(!ContactUtil.isEchoEchoOrChannelContact(contact));
+					this.profilePicSendItem.setVisible(!ContactUtil.isEchoEchoOrGatewayContact(contact));
 					break;
 				case PreferenceService.PROFILEPIC_RELEASE_SOME:
-					if (!ContactUtil.isEchoEchoOrChannelContact(contact)) {
-						if (profilePicRecipientsService != null && profilePicRecipientsService.has(contact.getIdentity())) {
+					if (!ContactUtil.isEchoEchoOrGatewayContact(contact)) {
+						if (profilePicRecipientsService != null && profilePicRecipientsService.has(this.identity)) {
 							profilePicItem.setTitle(R.string.menu_send_profilpic_off);
 							profilePicItem.setIcon(R.drawable.ic_person_remove_outline);
 							profilePicSendItem.setVisible(true);
@@ -759,14 +805,14 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 				fragment.onActivityResult(requestCode, resultCode, intent);
 			}
 		} catch (Exception e) {
-			//
+			logger.error("Could not set up fragment", e);
 		}
 
 		switch (requestCode) {
 			case ACTIVITY_ID_GROUP_DETAIL:
 				// contacts may have been edited
 				this.groupList = this.groupService.getGroupsByIdentity(this.identity);
-				contactDetailRecyclerView.setAdapter(setupAdapter());
+				this.refreshAdapter();
 				break;
 			case REQUEST_CODE_QR_SCANNER:
 				QRCodeService.QRCodeContentResult qrRes =
@@ -808,7 +854,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 				try {
 					AndroidContactUtil.getInstance().updateNameByAndroidContact(contact);
 					AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contact);
-					reload();
 					this.avatarEditView.setContactModel(contact);
 				} catch (ThreemaException e) {
 					logger.info("Unable to update contact name or avatar after returning from ContactEditor");
@@ -823,10 +868,10 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	}
 
 	void deleteContact(ContactModel contactModel) {
-		IdListService excludeFromSyncListService = this.serviceManager.getExcludedSyncIdentitiesService();
+		final IdListService excludeFromSyncListService = this.serviceManager.getExcludedSyncIdentitiesService();
 
 		//second question, if the contact is a synced contact
-		if (contactModel.getAndroidContactLookupKey() != null && excludeFromSyncListService != null
+		if (contactModel.isLinkedToAndroidContact()
 				&& !excludeFromSyncListService.has(contactModel.getIdentity())) {
 
 			GenericAlertDialog dialogFragment = GenericAlertDialog.newInstance(
@@ -844,7 +889,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	void unhideContact(ContactModel contactModel) {
 		contactService.setIsHidden(contactModel.getIdentity(), false);
 		onCreateLocal();
-		reload();
 	}
 
 	@Override
@@ -882,37 +926,13 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		}
 	}
 
+	/**
+	 * Called when the edit dialog is confirmed.
+	 */
 	@Override
-	public void onYes(String tag, String text1, String text2, File croppedAvatarFile) {
-		String firstName = this.getZeroLengthToNull(text1);
-		String lastName = this.getZeroLengthToNull(text2);
-
-		String existingFirstName = this.getZeroLengthToNull(contact.getFirstName());
-		String existingLastName = this.getZeroLengthToNull(contact.getLastName());
-
-		if(!TestUtil.compare(firstName, existingFirstName)
-				|| !TestUtil.compare(lastName, existingLastName)) {
-
-			//only save contact stuff if the name has changed!
-			this.contactService.setName(this.contact, firstName, lastName);
-
-			// Reset conversation cache because at this point the MessageSectionFragment is destroyed
-			// and it cannot react to the listeners.
-			if (serviceManager == null) {
-				logger.warn("Service manager is null; could not reset conversation cache");
-				return;
-			}
-			try {
-				ConversationService conversationService = serviceManager.getConversationService();
-				if (conversationService == null) {
-					logger.warn("Conversation service is null; could not reset conversation cache");
-					return;
-				}
-				conversationService.updateContactConversation(this.contact);
-			} catch (ThreemaException e) {
-				logger.error("Could not get conversation service to reset conversation cache", e);
-			}
-		}
+	public void onYes(String tag, @NonNull String firstName, @NonNull String lastName, @Nullable File ignored) {
+		final ContactDetailViewModel viewModel = Objects.requireNonNull(this.viewModel);
+		viewModel.updateContactName(firstName, lastName);
 	}
 
 	@Override
@@ -920,14 +940,21 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 
 	@TargetApi(Build.VERSION_CODES.M)
 	@Override
-	public void onRequestPermissionsResult(int requestCode,
-										   @NonNull String permissions[], @NonNull int[] grantResults) {
+	public void onRequestPermissionsResult(
+		int requestCode,
+		@NonNull String[] permissions,
+		@NonNull int[] grantResults
+	) {
 		super.onRequestPermissionsResult(requestCode, permissions, grantResults);
 		if (requestCode == PERMISSION_REQUEST_CAMERA) {
 			if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
 				scanQR();
 			} else if (!shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
-				ConfigUtils.showPermissionRationale(this, findViewById(R.id.main_content), R.string.permission_camera_qr_required);
+				ConfigUtils.showPermissionRationale(
+					this,
+					findViewById(R.id.main_content),
+					R.string.permission_camera_qr_required
+				);
 			}
 		}
 	}

+ 67 - 0
app/src/main/java/ch/threema/app/activities/ContactDetailViewModel.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.activities
+
+import androidx.lifecycle.DEFAULT_ARGS_KEY
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+import ch.threema.app.ThreemaApplication
+import ch.threema.data.models.ContactModel
+
+class ContactDetailViewModel(private val contactModel: ContactModel) : ViewModel() {
+    val contact = contactModel.liveData()
+
+    /**
+     * Update the contact's first and last name.
+     */
+    fun updateContactName(firstName: String, lastName: String) {
+        contactModel.setNameFromLocal(firstName, lastName)
+    }
+
+    /**
+     * Whether or not to show the floating edit action button.
+     */
+    fun showEditFAB(): Boolean {
+        // Don't show the edit button for contacts linked to an Android contact
+        return contact.value?.isLinkedToAndroidContact() ?: false
+    }
+
+    companion object {
+        /**
+         * View model must be initialized with the identity.
+         */
+        val Factory: ViewModelProvider.Factory = viewModelFactory {
+            initializer {
+                val modelRepositories = ThreemaApplication.getServiceManager()!!.modelRepositories
+                val bundle = this[DEFAULT_ARGS_KEY]
+                    ?: throw IllegalArgumentException("Bundle not passed to ContactDetailViewModel factory")
+                val identity = bundle.getString(ThreemaApplication.INTENT_DATA_CONTACT)
+                    ?: throw IllegalArgumentException("Identity not passed to ContactDetailViewModel factory")
+                val contactModel = modelRepositories.contacts.getByIdentity(identity)
+                    ?: throw IllegalArgumentException("ContactDetailViewModel: Contact with identity $identity not found")
+                ContactDetailViewModel(contactModel)
+            }
+        }
+    }
+}

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

@@ -33,6 +33,7 @@ import org.slf4j.Logger;
 import java.io.File;
 import java.util.Set;
 
+import androidx.annotation.Nullable;
 import androidx.appcompat.app.ActionBar;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -126,7 +127,7 @@ public class GroupAdd2Activity extends GroupEditActivity implements ContactEditD
 	}
 
 	@Override
-	public void onYes(String tag, String text1, String text2, File avatarFile) {
+	public void onYes(String tag, String text1, String text2, @Nullable File avatarFile) {
 		createGroup(text1, Set.of(this.groupIdentities), avatarFile);
 	}
 

+ 49 - 8
app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java

@@ -32,6 +32,7 @@ import android.graphics.BitmapFactory;
 import android.graphics.Color;
 import android.graphics.Paint;
 import android.graphics.PorterDuff;
+import android.graphics.Rect;
 import android.os.AsyncTask;
 import android.os.Bundle;
 import android.text.Editable;
@@ -221,17 +222,20 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
 	private final ContactListener contactListener = new ContactListener() {
 		@Override
-		public void onModified(ContactModel modifiedContactModel) {
-			resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
+		public void onModified(final @NonNull String identity) {
+			if (this.shouldHandleChange(identity)) {
+				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_RELOAD, runIfActiveUpdate);
+			}
 		}
 
 		@Override
 		public void onAvatarChanged(ContactModel contactModel) {
-			this.onModified(contactModel);
+			if (this.shouldHandleChange(contactModel.getIdentity())) {
+				this.onModified(contactModel.getIdentity());
+			}
 		}
 
-		@Override
-		public boolean handle(String identity) {
+		private boolean shouldHandleChange(@NonNull String identity) {
 			return groupDetailViewModel.containsModel(identity);
 		}
 	};
@@ -1076,14 +1080,51 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		}
 
 		if (this.groupService.isGroupCreator(this.groupModel) && hasChanges()) {
-			this.floatingActionButton.show();
+			this.floatingActionButton.show(new ExtendedFloatingActionButton.OnChangedCallback() {
+				@Override
+				public void onShown(ExtendedFloatingActionButton extendedFab) {
+					super.onShown(extendedFab);
+					adjustEditTextLocation(true);
+				}
+			});
 		} else {
-			this.floatingActionButton.hide();
+			this.floatingActionButton.hide(new ExtendedFloatingActionButton.OnChangedCallback() {
+				@Override
+				public void onHidden(ExtendedFloatingActionButton extendedFab) {
+					super.onHidden(extendedFab);
+					adjustEditTextLocation(false);
+				}
+			});
 		}
-
 		invalidateOptionsMenu();
 	}
 
+	synchronized private void adjustEditTextLocation(boolean show) {
+		floatingActionButton.post(() -> {
+            // check if FAB overlaps the group name
+            if (show) {
+                int[] editTextLocation = new int[2];
+                int[] fabLocation = new int[2];
+
+                groupNameEditText.getLocationInWindow(editTextLocation);
+                floatingActionButton.getLocationInWindow(fabLocation);
+
+                Rect editTextRect = new Rect(editTextLocation[0], editTextLocation[1],
+                    editTextLocation[0] + groupNameEditText.getMeasuredWidth(), editTextLocation[1] + groupNameEditText.getMeasuredHeight());
+                Rect fabRect = new Rect(fabLocation[0], fabLocation[1],
+                    fabLocation[0] + floatingActionButton.getMeasuredWidth(), fabLocation[1] + floatingActionButton.getMeasuredHeight());
+
+                if (editTextRect.intersect(fabRect)) {
+                    // place above fab
+                    groupNameEditText.setTranslationY((float) fabRect.top - editTextRect.bottom - getResources().getDimensionPixelSize(R.dimen.floating_button_margin));
+                }
+            }
+            else {
+                groupNameEditText.setTranslationY(0F);
+            }
+        });
+	}
+
 	private void navigateHome() {
 		Intent intent = new Intent(GroupDetailActivity.this, HomeActivity.class);
 		intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);

+ 6 - 13
app/src/main/java/ch/threema/app/activities/HomeActivity.java

@@ -77,7 +77,6 @@ import java.io.File;
 import java.lang.ref.WeakReference;
 import java.util.Arrays;
 import java.util.Date;
-import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Locale;
@@ -149,7 +148,6 @@ import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ConnectionIndicatorUtil;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.IntentDataUtil;
-import ch.threema.app.utils.LocaleUtil;
 import ch.threema.app.utils.PowermanagerUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
@@ -601,7 +599,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	@Override
 	@ExperimentalBadgeUtils
 	protected void onCreate(Bundle savedInstanceState) {
-		logger.debug("onCreate");
+		logger.info("onCreate");
 
 		final boolean isColdStart = savedInstanceState == null;
 
@@ -916,7 +914,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 	@Override
 	protected void onDestroy() {
-		logger.debug("onDestroy");
+		logger.info("onDestroy");
 
 		ThreemaApplication.activityDestroyed(this);
 
@@ -1015,12 +1013,6 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 		boolean isAppStart = savedInstanceState == null;
 
-		if (isAppStart) {
-			if (serviceManager != null) {
-				LocaleUtil.switchToAndroidXPerAppLanguageSelection(this, serviceManager.getPreferenceService());
-			}
-		}
-
 		if (serviceManager != null) {
 			this.userService = this.serviceManager.getUserService();
 			this.preferenceService = this.serviceManager.getPreferenceService();
@@ -1391,7 +1383,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 				@Override
 				protected Drawable doInBackground(Void... params) {
 					Bitmap bitmap = contactService.getAvatar(
-						new ContactModel(userService.getIdentity(), null),
+						// Create "fake" contact model for own user
+						new ContactModel(userService.getIdentity(), userService.getPublicKey()),
 						new AvatarOptions.Builder()
 							.setReturnPolicy(AvatarOptions.DefaultAvatarPolicy.DEFAULT_FALLBACK)
 							.toOptions()
@@ -1752,7 +1745,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 	@Override
 	public void onResume() {
-		logger.debug("onResume");
+		logger.info("onResume");
 
 		if (!isWhatsNewShown) {
 			ThreemaApplication.activityResumed(this);
@@ -1791,7 +1784,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 	@Override
 	protected void onPause() {
-		logger.debug("onPause");
+		logger.info("onPause");
 
 		super.onPause();
 

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

@@ -299,15 +299,7 @@ abstract public class IdentityListActivity extends ThreemaToolbarActivity implem
 
 	private void fireOnModifiedContact(final String identity) {
 		if (contactService != null) {
-			ListenerManager.contactListeners.handle(new ListenerManager.HandleListener<ContactListener>() {
-				@Override
-				public void handle(ContactListener listener) {
-					ContactModel contactModel = contactService.getByIdentity(identity);
-					if(contactModel != null) {
-						listener.onModified(contactModel);
-					}
-				}
-			});
+			ListenerManager.contactListeners.handle(listener -> listener.onModified(identity));
 		}
 	}
 

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

@@ -1373,7 +1373,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		View bottomPanel = findViewById(R.id.bottom_panel);
 		bottomPanel.setVisibility(View.VISIBLE);
 
-		if (preferenceService.getEmojiStyle() != PreferenceService.EmojiStyle_ANDROID) {
+		if (ConfigUtils.isDefaultEmojiStyle()) {
 			initializeEmojiView();
 		} else {
 			findViewById(R.id.emoji_button).setVisibility(View.GONE);
@@ -1442,7 +1442,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		emojiButton.setColorFilter(getResources().getColor(android.R.color.white));
 
 		emojiPicker = (EmojiPicker) ((ViewStub) findViewById(R.id.emoji_stub)).inflate();
-		emojiPicker.init(ThreemaApplication.requireServiceManager().getEmojiService());
+		emojiPicker.init(ThreemaApplication.requireServiceManager().getEmojiService(), false);
 		emojiButton.attach(this.emojiPicker);
 		emojiPicker.setEmojiKeyListener(emojiKeyListener);
 

+ 26 - 26
app/src/main/java/ch/threema/app/activities/MapActivity.java

@@ -50,19 +50,19 @@ import androidx.core.view.ViewCompat;
 import androidx.core.view.WindowInsetsCompat;
 
 import com.google.android.material.button.MaterialButton;
-import com.mapbox.mapboxsdk.annotations.IconFactory;
-import com.mapbox.mapboxsdk.annotations.MarkerOptions;
-import com.mapbox.mapboxsdk.camera.CameraUpdate;
-import com.mapbox.mapboxsdk.camera.CameraUpdateFactory;
-import com.mapbox.mapboxsdk.geometry.LatLng;
-import com.mapbox.mapboxsdk.location.LocationComponent;
-import com.mapbox.mapboxsdk.location.LocationComponentActivationOptions;
-import com.mapbox.mapboxsdk.location.modes.CameraMode;
-import com.mapbox.mapboxsdk.location.modes.RenderMode;
-import com.mapbox.mapboxsdk.maps.MapView;
-import com.mapbox.mapboxsdk.maps.MapboxMap;
-import com.mapbox.mapboxsdk.maps.OnMapReadyCallback;
-import com.mapbox.mapboxsdk.maps.Style;
+import org.maplibre.android.annotations.IconFactory;
+import org.maplibre.android.annotations.MarkerOptions;
+import org.maplibre.android.camera.CameraUpdate;
+import org.maplibre.android.camera.CameraUpdateFactory;
+import org.maplibre.android.geometry.LatLng;
+import org.maplibre.android.location.LocationComponent;
+import org.maplibre.android.location.LocationComponentActivationOptions;
+import org.maplibre.android.location.modes.CameraMode;
+import org.maplibre.android.location.modes.RenderMode;
+import org.maplibre.android.maps.MapView;
+import org.maplibre.android.maps.MapLibreMap;
+import org.maplibre.android.maps.OnMapReadyCallback;
+import org.maplibre.android.maps.Style;
 
 import org.slf4j.Logger;
 
@@ -105,7 +105,7 @@ public class MapActivity extends ThreemaActivity implements GenericAlertDialog.D
 	public static final String MAP_STYLE_URL = "https://map.threema.ch/styles/streets/style.json";
 
 	private MapView mapView;
-	private MapboxMap mapboxMap;
+	private MapLibreMap maplibreMap;
 	private FrameLayout parentView;
 	private Style mapStyle;
 
@@ -254,9 +254,9 @@ public class MapActivity extends ThreemaActivity implements GenericAlertDialog.D
 	private void initMap() {
 		mapView.getMapAsync(new OnMapReadyCallback() {
 			@Override
-			public void onMapReady(@NonNull MapboxMap mapboxMap1) {
-				mapboxMap = mapboxMap1;
-				mapboxMap.setStyle(new Style.Builder().fromUrl(MAP_STYLE_URL), new Style.OnStyleLoaded() {
+			public void onMapReady(@NonNull MapLibreMap mapLibreMap1) {
+				maplibreMap = mapLibreMap1;
+				maplibreMap.setStyle(new Style.Builder().fromUrl(MAP_STYLE_URL), new Style.OnStyleLoaded() {
 					@Override
 					public void onStyleLoaded(@NonNull Style style) {
 						// Map is set up and the style has loaded. Now you can add data or make other mapView adjustments
@@ -265,12 +265,12 @@ public class MapActivity extends ThreemaActivity implements GenericAlertDialog.D
 						if (checkLocationEnabled(locationManager)) {
 							setupLocationComponent(style);
 						}
-						mapboxMap.addMarker(getMarker(markerPosition, markerName, markerProvider));
+						maplibreMap.addMarker(getMarker(markerPosition, markerName, markerProvider));
 
 						int marginTop = getResources().getDimensionPixelSize(R.dimen.map_compass_margin_top) + insetTop;
 						int marginRight = getResources().getDimensionPixelSize(R.dimen.map_compass_margin_right);
 
-						mapboxMap.getUiSettings().setCompassMargins(0, marginTop, marginRight, 0);
+						maplibreMap.getUiSettings().setCompassMargins(0, marginTop, marginRight, 0);
 
 						moveCamera(markerPosition, false, -1);
 						mapView.postDelayed(new Runnable() {
@@ -311,7 +311,7 @@ public class MapActivity extends ThreemaActivity implements GenericAlertDialog.D
 			@Override
 			protected void onPostExecute(List<MarkerOptions> markerOptions) {
 				if (markerOptions.size() > 0) {
-					mapboxMap.addMarkers(markerOptions);
+					maplibreMap.addMarkers(markerOptions);
 				}
 			}
 		}.execute(markerPosition);
@@ -321,7 +321,7 @@ public class MapActivity extends ThreemaActivity implements GenericAlertDialog.D
 	private void setupLocationComponent(Style style) {
 		logger.debug("setupLocationComponent");
 
-		locationComponent = mapboxMap.getLocationComponent();
+		locationComponent = maplibreMap.getLocationComponent();
 		locationComponent.activateLocationComponent(LocationComponentActivationOptions.builder(this, style).build());
 		locationComponent.setCameraMode(CameraMode.NONE);
 		locationComponent.setRenderMode(RenderMode.COMPASS);
@@ -410,11 +410,11 @@ public class MapActivity extends ThreemaActivity implements GenericAlertDialog.D
 		long time = System.currentTimeMillis();
 		logger.debug("moveCamera to " + latLng.toString());
 
-		mapboxMap.cancelTransitions();
-		mapboxMap.addOnCameraIdleListener(new MapboxMap.OnCameraIdleListener() {
+		maplibreMap.cancelTransitions();
+		maplibreMap.addOnCameraIdleListener(new MapLibreMap.OnCameraIdleListener() {
 			@Override
 			public void onCameraIdle() {
-				mapboxMap.removeOnCameraIdleListener(this);
+				maplibreMap.removeOnCameraIdleListener(this);
 				RuntimeUtil.runOnUiThread(new Runnable() {
 					@Override
 					public void run() {
@@ -429,9 +429,9 @@ public class MapActivity extends ThreemaActivity implements GenericAlertDialog.D
 				CameraUpdateFactory.newLatLng(latLng);
 
 		if (animate) {
-			mapboxMap.animateCamera(cameraUpdate);
+			maplibreMap.animateCamera(cameraUpdate);
 		} else {
-			mapboxMap.moveCamera(cameraUpdate);
+			maplibreMap.moveCamera(cameraUpdate);
 		}
 	}
 

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

@@ -406,11 +406,11 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 
 		EmojiButton emojiButton = findViewById(R.id.emoji_button);
 
-		if (preferenceService.getEmojiStyle() != PreferenceService.EmojiStyle_ANDROID) {
+		if (ConfigUtils.isDefaultEmojiStyle()) {
 			emojiButton.setOnClickListener(v -> showEmojiPicker());
 
 			this.emojiPicker = (EmojiPicker) ((ViewStub) findViewById(R.id.emoji_stub)).inflate();
-			this.emojiPicker.init(ThreemaApplication.requireServiceManager().getEmojiService());
+			this.emojiPicker.init(ThreemaApplication.requireServiceManager().getEmojiService(), false);
 			emojiButton.attach(this.emojiPicker);
 			this.emojiPicker.setEmojiKeyListener(new EmojiPicker.EmojiKeyListener() {
 				@Override

+ 16 - 67
app/src/main/java/ch/threema/app/activities/TextChatBubbleActivity.java

@@ -25,20 +25,12 @@ import android.content.Intent;
 import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
-import android.util.TypedValue;
 import android.view.ActionMode;
-import android.view.LayoutInflater;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
-import android.view.ViewGroup;
-import android.widget.TextView;
-
-import androidx.annotation.ColorInt;
-import androidx.annotation.LayoutRes;
 
 import com.google.android.material.appbar.MaterialToolbar;
-import com.google.android.material.card.MaterialCardView;
 
 import org.slf4j.Logger;
 
@@ -48,13 +40,10 @@ import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.emojis.EmojiConversationTextView;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.services.MessageService;
-import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
+import ch.threema.app.ui.MessageBubbleView;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.LinkifyUtil;
-import ch.threema.app.utils.MessageUtil;
-import ch.threema.app.utils.QuoteUtil;
-import ch.threema.app.utils.StateBitmapUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.AbstractMessageModel;
@@ -68,8 +57,7 @@ public class TextChatBubbleActivity extends ThreemaToolbarActivity implements Ge
 
 	private int defaultTextSizeDp;
 	private MaterialToolbar toolbar;
-
-	private EmojiConversationTextView textView;
+	private MessageBubbleView messageBubbleView;
 
 	private final ActionMode.Callback textSelectionCallback = new ActionMode.Callback() {
 		@Override
@@ -136,8 +124,7 @@ public class TextChatBubbleActivity extends ThreemaToolbarActivity implements Ge
 
 		MessageService messageService;
 		MessageReceiver<? extends AbstractMessageModel> messageReceiver;
-		@LayoutRes int footerLayout;
-		@ColorInt int color;
+
 		String title;
 
 		try {
@@ -158,34 +145,32 @@ public class TextChatBubbleActivity extends ThreemaToolbarActivity implements Ge
 
 		if (messageModel.isOutbox()) {
 			// send
-			color = ConfigUtils.getColorFromAttribute(this, R.attr.colorSecondaryContainer);
 			title = getString(R.string.threema_message_to, messageReceiver.getDisplayName());
-			footerLayout = R.layout.conversation_bubble_footer_send;
 		} else {
 			// recv
-			color = getResources().getColor(R.color.bubble_receive);
 			title = getString(R.string.threema_message_from, messageReceiver.getDisplayName());
-			footerLayout = R.layout.conversation_bubble_footer_recv;
 		}
 
+		messageBubbleView = findViewById(R.id.message_bubble);
+		messageBubbleView.show(messageModel);
+		messageBubbleView.linkifyText(this, messageModel, false);
+
 		toolbar = findViewById(R.id.material_toolbar);
 		toolbar.setNavigationOnClickListener(view -> finish());
 		toolbar.setOnMenuItemClickListener(item -> {
 			if (item.getItemId() == R.id.enable_formatting) {
 				if (item.isChecked()) {
 					item.setChecked(false);
-					textView.setIgnoreMarkup(true);
-					setText(messageModel, true);
+					messageBubbleView.linkifyText(this, messageModel, true);
 				} else {
 					item.setChecked(true);
-					textView.setIgnoreMarkup(false);
-					setText(messageModel, false);
+					messageBubbleView.linkifyText(this, messageModel, false);
 				}
 			} else if (item.getItemId() == R.id.zoom_in) {
-				textView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, getTextSizeDp(textView) + TEXT_SIZE_INCREMENT_DP);
+				messageBubbleView.increaseTextSizeByDp((int) TEXT_SIZE_INCREMENT_DP);
 				updateMenus();
 			} else if (item.getItemId() == R.id.zoom_out) {
-				textView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, getTextSizeDp(textView) - TEXT_SIZE_INCREMENT_DP);
+				messageBubbleView.increaseTextSizeByDp((int) -TEXT_SIZE_INCREMENT_DP);
 				updateMenus();
 			}
 			return true;
@@ -194,36 +179,11 @@ public class TextChatBubbleActivity extends ThreemaToolbarActivity implements Ge
 
 		ConfigUtils.addIconsToOverflowMenu(this, toolbar.getMenu());
 
-		MaterialCardView cardView = findViewById(R.id.card_view);
-		cardView.setCardBackgroundColor(color);
-
-		View footerView = LayoutInflater.from(this).inflate(footerLayout, null);
-		((ViewGroup) findViewById(R.id.footer)).addView(footerView);
-
-		textView = findViewById(R.id.text_view);
-		setText(messageModel, false);
-		defaultTextSizeDp = getTextSizeDp(textView);
-
-		// display date
-		CharSequence s = MessageUtil.getDisplayDate(this, messageModel, true);
-		((TextView) footerView.findViewById(R.id.date_view)).setText(s != null ? s : "");
-
-		// display message status
-		StateBitmapUtil.getInstance().setStateDrawable(this, messageModel, findViewById(R.id.delivered_indicator), true);
-
-		// mock a composemessageholder
-		ComposeMessageHolder holder = new ComposeMessageHolder();
-		holder.groupAckContainer = footerView.findViewById(R.id.groupack_container);
-		holder.groupAckThumbsUpCount = footerView.findViewById(R.id.groupack_thumbsup_count);
-		holder.groupAckThumbsDownCount = footerView.findViewById(R.id.groupack_thumbsdown_count);
-		holder.groupAckThumbsUpImage = footerView.findViewById(R.id.groupack_thumbsup);
-		holder.groupAckThumbsDownImage = footerView.findViewById(R.id.groupack_thumbsdown);
-		holder.deliveredIndicator = findViewById(R.id.delivered_indicator);
-		StateBitmapUtil.getInstance().setGroupAckCount(messageModel, holder);
+		defaultTextSizeDp = messageBubbleView.getTextSizeDp();
 
 		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
 			// do not add on lollipop or lower due to this bug: https://issuetracker.google.com/issues/36937508
-			textView.setCustomSelectionActionModeCallback(textSelectionCallback);
+			messageBubbleView.setCustomSelectionActionModeCallback(textSelectionCallback);
 		}
 
 		findViewById(R.id.back_button).setOnClickListener(new View.OnClickListener() {
@@ -242,26 +202,15 @@ public class TextChatBubbleActivity extends ThreemaToolbarActivity implements Ge
 	}
 
 	private void updateMenus() {
-		if (textView != null && toolbar != null) {
+		if (messageBubbleView != null && toolbar != null) {
 			Menu menu = toolbar.getMenu();
 			if (menu != null) {
-				menu.findItem(R.id.zoom_in).setVisible(getTextSizeDp(textView) < (defaultTextSizeDp * 4));
-				menu.findItem(R.id.zoom_out).setVisible(getTextSizeDp(textView) > (defaultTextSizeDp / 2));
+				menu.findItem(R.id.zoom_in).setVisible(messageBubbleView.getTextSizeDp() < (defaultTextSizeDp * 4));
+				menu.findItem(R.id.zoom_out).setVisible(messageBubbleView.getTextSizeDp() > (defaultTextSizeDp / 2));
 			}
 		}
 	}
 
-	private int getTextSizeDp(TextView textView) {
-		return (int) Math.round(textView.getTextSize() / getResources().getDisplayMetrics().density);
-	}
-
-	private void setText(AbstractMessageModel messageModel, boolean ignoreMarkup) {
-		textView.setText(QuoteUtil.getMessageBody(messageModel, false));
-		if (!ignoreMarkup) {
-			LinkifyUtil.getInstance().linkify(null, this, textView, messageModel, true, false, null);
-		}
-	}
-
 	@Override
 	public void onYes(String tag, Object data) {
 		if (LinkifyUtil.DIALOG_TAG_CONFIRM_LINK.equals(tag)) {

+ 123 - 71
app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java

@@ -59,6 +59,7 @@ import ch.threema.app.adapters.decorators.AudioChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.BallotChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.ChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.DateSeparatorChatAdapterDecorator;
+import ch.threema.app.adapters.decorators.DeletedChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.FileChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.FirstUnreadChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.ForwardSecurityStatusChatAdapterDecorator;
@@ -119,7 +120,8 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 	private final int bubblePaddingLeftRight;
 	private final int bubblePaddingBottom;
 	private final int bubblePaddingBottomGrouped;
-
+	// lock for list update
+	final Object listUpdateLock = new Object();
 	private int firstUnreadPos = -1, unreadMessagesCount;
 	private final Context context;
 	private final ShapeAppearanceModel shapeAppearanceModelReceiveTop, shapeAppearanceModelReceiveMiddle, shapeAppearanceModelReceiveBottom, shapeAppearanceModelSendTop, shapeAppearanceModelSendMiddle, shapeAppearanceModelSendBottom, shapeAppearanceModelSingle;
@@ -149,6 +151,8 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 		TYPE_FILE_VIDEO_SEND,
 		TYPE_GROUP_CALL_STATUS,
 		TYPE_FORWARD_SECURITY_STATUS,
+		TYPE_DELETED_SEND,
+		TYPE_DELETED_RECV
 	})
 	public @interface ItemLayoutType {}
 
@@ -174,9 +178,11 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 	public static final int TYPE_FILE_VIDEO_SEND = 19;
 	public static final int TYPE_GROUP_CALL_STATUS = 20;
 	public static final int TYPE_FORWARD_SECURITY_STATUS = 21;
+	public static final int TYPE_DELETED_SEND = 22;
+	public static final int TYPE_DELETED_RECV = 23;
 
 	// don't forget to update this after adding new types:
-	private static final int TYPE_MAX_COUNT = TYPE_FORWARD_SECURITY_STATUS + 1;
+	private static final int TYPE_MAX_COUNT = TYPE_DELETED_RECV + 1;
 
 	private OnClickListener onClickListener;
 	private Map<String, Integer> identityColors = null;
@@ -361,6 +367,9 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 			}
 			else {
 				boolean o = m.isOutbox();
+				if (m.isDeleted()) {
+					return o ? TYPE_DELETED_SEND : TYPE_DELETED_RECV;
+				}
 				switch (m.getType()) {
 					case LOCATION:
 						return o ? TYPE_LOCATION_SEND : TYPE_LOCATION_RECV;
@@ -447,6 +456,10 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 				return R.layout.conversation_list_item_date_separator;
 			case TYPE_GROUP_CALL_STATUS:
 				return R.layout.conversation_list_item_group_call_status;
+			case TYPE_DELETED_SEND:
+				return R.layout.conversation_list_item_deleted_send;
+			case TYPE_DELETED_RECV:
+				return R.layout.conversation_list_item_deleted_recv;
 		}
 
 		//return default!?
@@ -462,20 +475,20 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 	@Override
 	public View getView(final int position, View convertView, ViewGroup parent) {
 		View itemView = convertView;
-		final ComposeMessageHolder holder;
+		ComposeMessageHolder holder = itemView != null ? (ComposeMessageHolder) itemView.getTag() : null;
 		final AbstractMessageModel messageModel = values.get(position);
 		MessageType messageType = messageModel.getType();
 
 		@ItemLayoutType int itemType = this.getItemType(messageModel);
-		int itemLayout = this.getLayoutByItemType(itemType);
 
 		if (messageModel.isStatusMessage() && messageModel instanceof FirstUnreadMessageModel) {
 			firstUnreadPos = position;
 		}
 
-		if ((convertView == null) || (getItemViewType(position) != itemType)) {
+		if (holder == null || holder.itemType != itemType) {
 			// this is a new view or the ListView item type (and thus the layout) has changed
 			holder = new ComposeMessageHolder();
+			int itemLayout = this.getLayoutByItemType(itemType);
 			itemView = this.layoutInflater.inflate(itemLayout, parent, false);
 
 			if (itemView != null) {
@@ -511,12 +524,12 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 					holder.groupAckThumbsDownImage = itemView.findViewById(R.id.groupack_thumbsdown);
 					holder.tapToResend = itemView.findViewById(R.id.tap_to_resend);
 					holder.starredIcon = itemView.findViewById(R.id.star_icon);
+					holder.editedText = itemView.findViewById(R.id.edited_text);
 				}
 				itemView.setTag(holder);
 			}
 		} else {
 			// recycled view - reset a few views to their initial state
-			holder = (ComposeMessageHolder) itemView.getTag();
 			if (holder.messagePlayer != null) {
 				// remove any references to listeners in case of a recycled view
 				holder.messagePlayer.removeListeners();
@@ -550,64 +563,10 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 				messageType = MessageType.STATUS;
 			}
 
-			switch (messageType) {
-				case STATUS:
-					decorator = new StatusChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-					break;
-				case VIDEO:
-					decorator = new VideoChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-					break;
-				case IMAGE:
-					decorator = new ImageChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-					break;
-				case LOCATION:
-					decorator = new LocationChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-					break;
-				case VOICEMESSAGE:
-					decorator = new AudioChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-					break;
-				case BALLOT:
-					decorator = new BallotChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-					break;
-				case FILE:
-					if (MimeUtil.isVideoFile(messageModel.getFileData().getMimeType()) &&
-						(messageModel.getFileData().getRenderingType() == FileData.RENDERING_MEDIA ||
-							messageModel.getFileData().getRenderingType() == FileData.RENDERING_STICKER)) {
-						decorator = new VideoChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-					} else if (MimeUtil.isAudioFile(messageModel.getFileData().getMimeType()) &&
-						messageModel.getFileData().getRenderingType() == FileData.RENDERING_MEDIA) {
-						decorator = new AudioChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-					} else if (MimeUtil.isAnimatedImageFormat(messageModel.getFileData().getMimeType()) &&
-						(messageModel.getFileData().getRenderingType() == FileData.RENDERING_MEDIA ||
-							messageModel.getFileData().getRenderingType() == FileData.RENDERING_STICKER)) {
-						decorator = new AnimatedImageDrawableDecorator(this.context, messageModel, this.decoratorHelper);
-					} else {
-						decorator = new FileChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-					}
-					break;
-				case VOIP_STATUS:
-					decorator = new VoipStatusDataChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-					break;
-				case GROUP_CALL_STATUS:
-					decorator = new GroupCallStatusDataChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-					break;
-				case FORWARD_SECURITY_STATUS:
-					decorator = new ForwardSecurityStatusChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-					break;
-				case GROUP_STATUS:
-					decorator = new GroupStatusAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-					break;
-					// Fallback to text chat adapter
-				default:
-					if (messageModel.isStatusMessage()) {
-						if (messageModel instanceof DateSeparatorMessageModel) {
-							decorator = new DateSeparatorChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-						} else {
-							decorator = new StatusChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-						}
-					} else {
-						decorator = new TextChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-					}
+			if (itemType == TYPE_DELETED_SEND || itemType == TYPE_DELETED_RECV) {
+				decorator = new DeletedChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+			} else {
+				decorator = initDecorator(messageModel, messageType);
 			}
 
 			if (groupId > 0) {
@@ -641,14 +600,66 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 			/* show matches in decorator */
 			decorator.setFilter(convListFilter.getFilterString());
 		}
-		if (parent != null && parent instanceof ListView) {
+		if (parent instanceof ListView) {
 			decorator.setInListView(((ListView) parent));
 		}
 		decorator.decorate(holder, position);
+		holder.itemType = itemType;
 
 		return itemView;
 	}
 
+	private ChatAdapterDecorator initDecorator(@NonNull AbstractMessageModel messageModel, MessageType messageType) {
+		switch (messageType) {
+			case STATUS:
+				return new StatusChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+			case VIDEO:
+				return new VideoChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+			case IMAGE:
+				return new ImageChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+			case LOCATION:
+				return new LocationChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+			case VOICEMESSAGE:
+				return new AudioChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+			case BALLOT:
+				return new BallotChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+			case FILE:
+				if (MimeUtil.isVideoFile(messageModel.getFileData().getMimeType()) &&
+					(messageModel.getFileData().getRenderingType() == FileData.RENDERING_MEDIA ||
+						messageModel.getFileData().getRenderingType() == FileData.RENDERING_STICKER)) {
+					return new VideoChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+				} else if (MimeUtil.isAudioFile(messageModel.getFileData().getMimeType()) &&
+					messageModel.getFileData().getRenderingType() == FileData.RENDERING_MEDIA) {
+					return new AudioChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+				} else if (MimeUtil.isAnimatedImageFormat(messageModel.getFileData().getMimeType()) &&
+					(messageModel.getFileData().getRenderingType() == FileData.RENDERING_MEDIA ||
+						messageModel.getFileData().getRenderingType() == FileData.RENDERING_STICKER)) {
+					return new AnimatedImageDrawableDecorator(this.context, messageModel, this.decoratorHelper);
+				} else {
+					return new FileChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+				}
+			case VOIP_STATUS:
+				return new VoipStatusDataChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+			case GROUP_CALL_STATUS:
+				return new GroupCallStatusDataChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+			case FORWARD_SECURITY_STATUS:
+				return new ForwardSecurityStatusChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+			case GROUP_STATUS:
+				return new GroupStatusAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+			// Fallback to text chat adapter
+			default:
+				if (messageModel.isStatusMessage()) {
+					if (messageModel instanceof DateSeparatorMessageModel) {
+						return new DateSeparatorChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+					} else {
+						return new StatusChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+					}
+				} else {
+					return new TextChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+				}
+		}
+	}
+
 	/**
 	 * Adjust margins of item view so that items from the same sender may be displayed in a grouped fashion
 	 * To be used in getView() call
@@ -825,6 +836,9 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 				} else {
 					// filtering of matching messages by content
 					for (AbstractMessageModel messageModel : values) {
+						if (messageModel.isDeleted()) {
+							continue;
+						}
 						if ((messageModel.getType() == MessageType.TEXT && !messageModel.isStatusMessage())
 								|| messageModel.getType() == MessageType.LOCATION
 								|| messageModel.getType() == MessageType.BALLOT) {
@@ -1048,7 +1062,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 			//Destroy toast!
 			SingleToast.getInstance().close();
 			return resultMap.get(resultMap.size() - 1);
-		} else if (filterString != null && filterString.length() > 0) {
+		} else if (filterString != null && !filterString.isEmpty()) {
 			if (convListFilter.getHighlightMatches()) {
 				SingleToast.getInstance().showShortText(context.getString(R.string.search_no_matches));
 			} else {
@@ -1062,21 +1076,19 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 		this.unreadMessagesCount = unreadMessagesCount;
 	}
 
-	public boolean removeFirstUnreadPosition() {
+	public void removeFirstUnreadPosition() {
 		if(this.firstUnreadPos >= 0) {
 			if(this.firstUnreadPos >= this.getCount()) {
 				this.firstUnreadPos = -1;
-				return false;
+				return;
 			}
 
 			AbstractMessageModel m = this.getItem(this.firstUnreadPos);
-			if(m != null && m instanceof FirstUnreadMessageModel) {
+			if (m instanceof FirstUnreadMessageModel) {
 				this.firstUnreadPos = -1;
 				this.remove(m);
-				return true;
 			}
 		}
-		return false;
 	}
 
 	public void setIdentityColors(Map<String, Integer> colors) {
@@ -1101,6 +1113,8 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 				super.remove(newObject);
 			}
 		}
+
+		notifyDataSetChanged();
 	}
 
 	@Override
@@ -1138,4 +1152,42 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 		}
 		return AbsListView.INVALID_POSITION;
 	}
+
+	@Override
+	public void notifyDataSetChanged() {
+		synchronized (listUpdateLock) {
+			super.notifyDataSetChanged();
+		}
+	}
+
+	/**
+	 * Refresh only items in this adapter that contain the specified AbstractMessageModels
+	 * @param targetMessageModels List of affected AbstractMessageModels
+	 */
+	@UiThread
+	public void notifyItemsChanged(final List<AbstractMessageModel> targetMessageModels) {
+		synchronized (listUpdateLock) {
+			if (listView != null) {
+				int n = 0;
+				final int targetSize = targetMessageModels.size();
+				final int firstVisiblePosition = listView.getFirstVisiblePosition();
+				for (int i = firstVisiblePosition, j = listView.getLastVisiblePosition(); i <= j; i++) {
+					AbstractMessageModel messageModel = (AbstractMessageModel) listView.getItemAtPosition(i);
+					if (messageModel != null) {
+						for (AbstractMessageModel targetMessageModel : targetMessageModels) {
+							if (messageModel.getUid() != null && messageModel.getUid().equals(targetMessageModel.getUid())) {
+								View view = listView.getChildAt(i - firstVisiblePosition);
+								getView(i, view, listView);
+								n++;
+								break;
+							}
+						}
+					}
+					if (n >= targetSize) {
+						break;
+					}
+				}
+			}
+		}
+	}
 }

+ 79 - 65
app/src/main/java/ch/threema/app/adapters/ContactDetailAdapter.java

@@ -35,11 +35,6 @@ import android.widget.LinearLayout;
 import android.widget.TextView;
 import android.widget.Toast;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.StringRes;
-import androidx.appcompat.app.AppCompatActivity;
-import androidx.recyclerview.widget.RecyclerView;
-
 import com.bumptech.glide.RequestManager;
 import com.google.android.material.button.MaterialButton;
 import com.google.android.material.materialswitch.MaterialSwitch;
@@ -48,7 +43,13 @@ import com.google.android.material.textfield.MaterialAutoCompleteTextView;
 import org.slf4j.Logger;
 
 import java.util.List;
+import java.util.Objects;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+import androidx.annotation.UiThread;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.PublicKeyDialog;
@@ -58,15 +59,26 @@ import ch.threema.app.services.ContactService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.IdListService;
 import ch.threema.app.services.PreferenceService;
-import ch.threema.app.services.UserService;
 import ch.threema.app.ui.VerificationLevelImageView;
 import ch.threema.app.utils.AndroidContactUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.data.models.ContactModelData;
 import ch.threema.protobuf.csp.e2e.fs.Terminate;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 
+/**
+ * The adapter for contact details.
+ *
+ * It is comprised of two parts:
+ *
+ * - The header, which contains the Threema ID, nickname, privacy settings, etc
+ * - The items, which contain the group memberships
+ *
+ * Note that this adapter does not need to be reactive. It is simply recreated by the activity
+ * when data changes.
+ */
 public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ContactDetailAdapter");
 
@@ -76,58 +88,59 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 	private final Context context;
 	private ContactService contactService;
 	private GroupService groupService;
-	private UserService userService;
 	private PreferenceService preferenceService;
 	private IdListService excludeFromSyncListService;
 	private IdListService blackListIdentityService;
+	@Deprecated
 	private final ContactModel contactModel;
+	private final @NonNull ContactModelData contactModelData;
 	private final List<GroupModel> values;
 	private OnClickListener onClickListener;
 	private final @NonNull RequestManager requestManager;
 
 	public static class ItemHolder extends RecyclerView.ViewHolder {
-		public final View view;
-		public final TextView nameView;
-		public final ImageView avatarView, statusView;
+		public final @NonNull View view;
+		public final @NonNull TextView nameView;
+		public final @NonNull ImageView avatarView, statusView;
 
-		public ItemHolder(View view) {
+		public ItemHolder(@NonNull View view) {
 			super(view);
 			this.view = view;
-			this.nameView = itemView.findViewById(R.id.contact_name);
-			this.avatarView = itemView.findViewById(R.id.contact_avatar);
-			this.statusView = itemView.findViewById(R.id.status);
+			this.nameView = Objects.requireNonNull(itemView.findViewById(R.id.contact_name));
+			this.avatarView = Objects.requireNonNull(itemView.findViewById(R.id.contact_avatar));
+			this.statusView = Objects.requireNonNull(itemView.findViewById(R.id.status));
 		}
 	}
 
 	public class HeaderHolder extends RecyclerView.ViewHolder {
-		private final VerificationLevelImageView verificationLevelImageView;
-		private final TextView threemaIdView;
-		private final MaterialSwitch synchronize;
-		private final View nicknameContainer, synchronizeContainer;
-		private final ImageView syncSourceIcon;
-		private final TextView publicNickNameView;
-		private final LinearLayout groupMembershipTitle;
-		private final MaterialAutoCompleteTextView readReceiptsSpinner, typingIndicatorsSpinner;
-		private final View clearForwardSecuritySection;
-		private final MaterialButton clearForwardSecurityButton;
+		private final @NonNull VerificationLevelImageView verificationLevelImageView;
+		private final @NonNull TextView threemaIdView;
+		private final @NonNull MaterialSwitch synchronize;
+		private final @NonNull View nicknameContainer, synchronizeContainer;
+		private final @NonNull ImageView syncSourceIcon;
+		private final @NonNull TextView publicNickNameView;
+		private final @NonNull LinearLayout groupMembershipTitle;
+		private final @NonNull MaterialAutoCompleteTextView readReceiptsSpinner, typingIndicatorsSpinner;
+		private final @NonNull View clearForwardSecuritySection;
+		private final @NonNull MaterialButton clearForwardSecurityButton;
 		private int onThreemaIDClickCount = 0;
 
 		public HeaderHolder(View view) {
 			super(view);
 
-			this.threemaIdView = itemView.findViewById(R.id.threema_id);
-			this.verificationLevelImageView = itemView.findViewById(R.id.verification_level_image);
-			ImageView verificationLevelIconView = itemView.findViewById(R.id.verification_information_icon);
-			this.synchronize = itemView.findViewById(R.id.synchronize_contact);
-			this.synchronizeContainer = itemView.findViewById(R.id.synchronize_contact_container);
-			this.nicknameContainer = itemView.findViewById(R.id.nickname_container);
-			this.publicNickNameView = itemView.findViewById(R.id.public_nickname);
-			this.groupMembershipTitle = itemView.findViewById(R.id.group_members_title_container);
-			this.syncSourceIcon = itemView.findViewById(R.id.sync_source_icon);
-			this.readReceiptsSpinner = itemView.findViewById(R.id.read_receipts_spinner);
-			this.typingIndicatorsSpinner = itemView.findViewById(R.id.typing_indicators_spinner);
-			this.clearForwardSecuritySection = itemView.findViewById(R.id.clear_forward_security_section);
-			this.clearForwardSecurityButton = itemView.findViewById(R.id.clear_forward_security);
+			this.threemaIdView = Objects.requireNonNull(itemView.findViewById(R.id.threema_id));
+			this.verificationLevelImageView = Objects.requireNonNull(itemView.findViewById(R.id.verification_level_image));
+			ImageView verificationLevelIconView = Objects.requireNonNull(itemView.findViewById(R.id.verification_information_icon));
+			this.synchronize = Objects.requireNonNull(itemView.findViewById(R.id.synchronize_contact));
+			this.synchronizeContainer = Objects.requireNonNull(itemView.findViewById(R.id.synchronize_contact_container));
+			this.nicknameContainer = Objects.requireNonNull(itemView.findViewById(R.id.nickname_container));
+			this.publicNickNameView = Objects.requireNonNull(itemView.findViewById(R.id.public_nickname));
+			this.groupMembershipTitle = Objects.requireNonNull(itemView.findViewById(R.id.group_members_title_container));
+			this.syncSourceIcon = Objects.requireNonNull(itemView.findViewById(R.id.sync_source_icon));
+			this.readReceiptsSpinner = Objects.requireNonNull(itemView.findViewById(R.id.read_receipts_spinner));
+			this.typingIndicatorsSpinner = Objects.requireNonNull(itemView.findViewById(R.id.typing_indicators_spinner));
+			this.clearForwardSecuritySection = Objects.requireNonNull(itemView.findViewById(R.id.clear_forward_security_section));
+			this.clearForwardSecurityButton = Objects.requireNonNull(itemView.findViewById(R.id.clear_forward_security));
 
 			verificationLevelIconView.setOnClickListener(v -> {
 				if (onClickListener != null) {
@@ -136,8 +149,7 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 			});
 
 			threemaIdView.setOnLongClickListener(ignored -> {
-				String identity = contactModel.getIdentity();
-				copyTextToClipboard(identity, R.string.contact_details_id_copied);
+				copyTextToClipboard(contactModelData.identity, R.string.contact_details_id_copied);
 				return true;
 			});
 
@@ -165,15 +177,15 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 			});
 
 			publicNickNameView.setOnLongClickListener(ignored -> {
-				String nickname = contactModel.getPublicNickName();
-				copyTextToClipboard(nickname, R.string.contact_details_nickname_copied);
+				copyTextToClipboard(contactModelData.nickname, R.string.contact_details_nickname_copied);
 				return true;
 			});
 
 
 			itemView.findViewById(R.id.public_key_button).setOnClickListener(v -> {
 				if (context instanceof AppCompatActivity) {
-					PublicKeyDialog.newInstance(context.getString(R.string.public_key_for, contactModel.getIdentity()), contactModel.getPublicKey())
+					PublicKeyDialog
+						.newInstance(context.getString(R.string.public_key_for, contactModelData.identity), contactModelData.publicKey)
 						.show(((AppCompatActivity) context).getSupportFragmentManager(), "pk");
 				}
 			});
@@ -187,27 +199,29 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 		}
 	}
 
+	@UiThread
 	public ContactDetailAdapter(
 		Context context,
 		List<GroupModel>values,
 		ContactModel contactModel,
+		@NonNull ContactModelData contactModelData,
 		@NonNull RequestManager requestManager
 	) {
 		this.context = context;
 		this.values = values;
 		this.contactModel = contactModel;
+		this.contactModelData = contactModelData;
 		this.requestManager = requestManager;
 
 		try {
 			ServiceManager serviceManager = ThreemaApplication.requireServiceManager();
 			this.contactService = serviceManager.getContactService();
 			this.groupService = serviceManager.getGroupService();
-			this.userService = serviceManager.getUserService();
 			this.excludeFromSyncListService = serviceManager.getExcludedSyncIdentitiesService();
 			this.blackListIdentityService = serviceManager.getBlackListService();
 			this.preferenceService = serviceManager.getPreferenceService();
 		} catch (Exception e) {
-			logger.error("Exception", e);
+			logger.error("Failed to set up services", e);
 		}
 	}
 
@@ -257,29 +271,29 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 			HeaderHolder headerHolder = (HeaderHolder) holder;
 
 			String identityAdditional = null;
-			if (this.contactModel.getState() != null) {
-				switch (this.contactModel.getState()) {
-					case ACTIVE:
-						if (blackListIdentityService.has(contactModel.getIdentity())) {
-							identityAdditional = context.getString(R.string.blocked);
-						}
-						break;
-					case INACTIVE:
-						identityAdditional = context.getString(R.string.contact_state_inactive);
-						break;
-					case INVALID:
-						identityAdditional = context.getString(R.string.contact_state_invalid);
-						break;
-				}
+			switch (this.contactModelData.activityState) {
+				case ACTIVE:
+					if (blackListIdentityService.has(contactModelData.identity)) {
+						identityAdditional = context.getString(R.string.blocked);
+					}
+					break;
+				case INACTIVE:
+					identityAdditional = context.getString(R.string.contact_state_inactive);
+					break;
+				case INVALID:
+					identityAdditional = context.getString(R.string.contact_state_invalid);
+					break;
 			}
-			headerHolder.threemaIdView.setText(contactModel.getIdentity() + (identityAdditional != null ? " (" + identityAdditional + ")" : ""));
+			headerHolder.threemaIdView.setText(
+				contactModelData.identity + (identityAdditional != null ? " (" + identityAdditional + ")" : "")
+			);
 			headerHolder.verificationLevelImageView.setContactModel(contactModel);
 			headerHolder.verificationLevelImageView.setVisibility(View.VISIBLE);
 
-			boolean isSyncExcluded = excludeFromSyncListService.has(contactModel.getIdentity());
+			boolean isSyncExcluded = excludeFromSyncListService.has(contactModelData.identity);
 
 			if (preferenceService.isSyncContacts()
-				&& (contactModel.getAndroidContactLookupKey() != null || isSyncExcluded)
+				&& (contactModelData.isLinkedToAndroidContact() || isSyncExcluded)
 				&& ConfigUtils.isPermissionGranted(ThreemaApplication.getAppContext(), Manifest.permission.READ_CONTACTS)
 			) {
 				headerHolder.synchronizeContainer.setVisibility(View.VISIBLE);
@@ -300,18 +314,18 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 				headerHolder.synchronize.setChecked(isSyncExcluded);
 				headerHolder.synchronize.setOnCheckedChangeListener((buttonView, isChecked) -> {
 					if (isChecked) {
-						excludeFromSyncListService.add(contactModel.getIdentity());
+						excludeFromSyncListService.add(contactModelData.identity);
 					} else {
-						excludeFromSyncListService.remove(contactModel.getIdentity());
+						excludeFromSyncListService.remove(contactModelData.identity);
 					}
 				});
 			} else {
 				headerHolder.synchronizeContainer.setVisibility(View.GONE);
 			}
 
-			String nicknameString = contactModel.getPublicNickName();
+			final String nicknameString = contactModelData.nickname;
 			if (nicknameString != null && nicknameString.length() > 0) {
-				headerHolder.publicNickNameView.setText(contactModel.getPublicNickName());
+				headerHolder.publicNickNameView.setText(nicknameString);
 			} else {
 				headerHolder.nicknameContainer.setVisibility(View.GONE);
 			}

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

@@ -256,7 +256,7 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 		if (sortingValue.length() == 0) {
 			firstLetter = PLACEHOLDER_BLANK_HEADER;
 		} else {
-			if (ContactUtil.isChannelContact(c)) {
+			if (ContactUtil.isGatewayContact(c)) {
 				firstLetter = afterSorting ? CHANNEL_SIGN : PLACEHOLDER_CHANNELS;
 			} else if (getItemViewType(position) == VIEW_TYPE_RECENTLY_ADDED) {
 				if (contactListFilter != null && contactListFilter.getFilterString() != null) {

+ 15 - 2
app/src/main/java/ch/threema/app/adapters/MessageListViewHolder.kt

@@ -23,6 +23,7 @@ package ch.threema.app.adapters
 
 import android.annotation.SuppressLint
 import android.content.Context
+import android.graphics.Typeface
 import android.view.View
 import android.view.View.GONE
 import android.view.View.INVISIBLE
@@ -278,12 +279,14 @@ class MessageListViewHolder(
 
         initializeMuteAppearance(messageListAdapterItem)
 
-        initializeHiddenAppearance(isHidden)
-
         initializeDeliveryView(messageListAdapterItem, isHidden, draft != null)
 
         initializeGroupCallIndicator(messageListAdapterItem)
 
+        messageListAdapterItem.latestMessage?.isDeleted?.let { initializeDeletedAppearance(it) }
+
+        initializeHiddenAppearance(isHidden)
+
         AdapterUtil.styleConversation(fromView, params.groupService, messageListAdapterItem.conversationModel)
 
         AvatarListItemUtil.loadAvatar(
@@ -423,6 +426,16 @@ class MessageListViewHolder(
         }
     }
 
+    private fun initializeDeletedAppearance(isDeleted: Boolean) {
+        if (isDeleted) {
+            subjectView.setText(R.string.message_was_deleted)
+            subjectView.setTextColor(context.resources.getColor(R.color.text_color_deleted))
+            subjectView.setTypeface(subjectView.typeface, Typeface.ITALIC)
+            attachmentView.visibility = GONE
+            deliveryView.visibility = GONE
+        }
+    }
+
     /**
      * Initializes the view holder regarding ongoing group calls. If a group call is running, it
      * makes the join group call button visible and disables all the views that would be hidden

+ 8 - 3
app/src/main/java/ch/threema/app/adapters/decorators/BallotChatAdapterDecorator.java

@@ -23,6 +23,7 @@ package ch.threema.app.adapters.decorators;
 
 import android.content.Context;
 import android.os.Parcel;
+import android.view.View;
 
 import org.slf4j.Logger;
 
@@ -32,6 +33,7 @@ import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.SelectorDialog;
 import ch.threema.app.services.GroupService;
+import ch.threema.app.ui.DebouncedOnClickListener;
 import ch.threema.app.ui.SelectorDialogItem;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
 import ch.threema.app.utils.BallotUtil;
@@ -88,9 +90,12 @@ public class BallotChatAdapterDecorator extends ChatAdapterDecorator {
 				holder.secondaryTextView.setText(explain);
 			}
 
-			this.setOnClickListener(view -> {
-				if (messageModel.getState() != MessageState.FS_KEY_MISMATCH && messageModel.getState() != MessageState.SENDFAILED) {
-					onActionButtonClick(ballotModel);
+			this.setOnClickListener(new DebouncedOnClickListener(500) {
+				@Override
+				public void onDebouncedClick(View v) {
+					if (messageModel.getState() != MessageState.FS_KEY_MISMATCH && messageModel.getState() != MessageState.SENDFAILED) {
+						BallotChatAdapterDecorator.this.onActionButtonClick(ballotModel);
+					}
 				}
 			}, holder.messageBlockView);
 

+ 11 - 2
app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java

@@ -399,8 +399,17 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 				holder.starredIcon.setVisibility((messageModel.getDisplayTags() & DisplayTag.DISPLAY_TAG_STARRED) == DisplayTag.DISPLAY_TAG_STARRED ? View.VISIBLE : View.GONE);
 			}
 
-			stateBitmapUtil.setStateDrawable(getContext(), messageModel, holder.deliveredIndicator, true);
-			stateBitmapUtil.setGroupAckCount(messageModel, holder);
+			if (holder.deliveredIndicator != null) {
+				stateBitmapUtil.setStateDrawable(getContext(), messageModel, holder.deliveredIndicator, true);
+			}
+
+			if (holder.groupAckContainer != null) {
+				stateBitmapUtil.setGroupAckCount(messageModel, holder);
+			}
+
+			if (holder.editedText != null) {
+				holder.editedText.setVisibility(messageModel.getEditedAt() != null ? View.VISIBLE : View.GONE);
+			}
 		}
 	}
 

+ 36 - 0
app/src/main/java/ch/threema/app/adapters/decorators/DeletedChatAdapterDecorator.kt

@@ -0,0 +1,36 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.adapters.decorators
+
+import android.content.Context
+import ch.threema.app.ui.listitemholder.ComposeMessageHolder
+import ch.threema.app.utils.MessageUtil
+import ch.threema.storage.models.AbstractMessageModel
+
+class DeletedChatAdapterDecorator(context: Context, messageModel: AbstractMessageModel, helper: Helper) : ChatAdapterDecorator(context, messageModel, helper) {
+    override fun configureChatMessage(holder: ComposeMessageHolder, position: Int) {
+        holder.dateView.text = MessageUtil.getDisplayDate(context, messageModel, true)
+        setOnClickListener({
+
+        }, holder.messageBlockView)
+    }
+}

+ 14 - 3
app/src/main/java/ch/threema/app/archive/ArchiveAdapter.java

@@ -23,6 +23,7 @@ package ch.threema.app.archive;
 
 import android.content.Context;
 import android.graphics.PorterDuff;
+import android.graphics.Typeface;
 import android.util.SparseBooleanArray;
 import android.view.LayoutInflater;
 import android.view.View;
@@ -55,7 +56,6 @@ import ch.threema.app.ui.listitemholder.AvatarListItemHolder;
 import ch.threema.app.utils.AdapterUtil;
 import ch.threema.app.utils.MessageUtil;
 import ch.threema.app.utils.NameUtil;
-import ch.threema.app.utils.ViewUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.AbstractMessageModel;
@@ -157,6 +157,16 @@ public class ArchiveAdapter extends RecyclerView.Adapter<ArchiveAdapter.ArchiveV
 					// give user some privacy even in visible mode
 					holder.subjectView.setText(R.string.private_chat_subject);
 					holder.subjectView.setVisibility(View.VISIBLE);
+					holder.attachmentView.setVisibility(View.GONE);
+					holder.dateView.setVisibility(View.INVISIBLE);
+					holder.deliveryView.setVisibility(View.GONE);
+				} else if (messageModel.isDeleted()) {
+					holder.subjectView.setText(R.string.message_was_deleted);
+					holder.subjectView.setVisibility(View.VISIBLE);
+
+					holder.subjectView.setTextColor(context.getResources().getColor(R.color.text_color_deleted));
+					holder.subjectView.setTypeface(holder.subjectView.getTypeface(), Typeface.ITALIC);
+
 					holder.attachmentView.setVisibility(View.GONE);
 					holder.dateView.setVisibility(View.INVISIBLE);
 					holder.deliveryView.setVisibility(View.GONE);
@@ -199,9 +209,10 @@ public class ArchiveAdapter extends RecyclerView.Adapter<ArchiveAdapter.ArchiveV
 						holder.attachmentView.setVisibility(View.GONE);
 					}
 
-					if (ViewUtil.show(holder.subjectView, subject != null)) {
+					boolean showSubject = subject != null;
+					holder.subjectView.setVisibility(showSubject ? View.VISIBLE : View.INVISIBLE);
+					if (showSubject) {
 						// Append space if attachmentView is visible
-
 						if (holder.attachmentView.getVisibility() == View.VISIBLE) {
 							subject = " " + subject;
 						}

+ 3 - 3
app/src/main/java/ch/threema/app/asynctasks/AddContactAsyncTask.java

@@ -31,8 +31,8 @@ import ch.threema.app.ThreemaApplication;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.ContactService;
 import ch.threema.base.utils.LoggingUtil;
-import ch.threema.domain.models.VerificationLevel;
 import ch.threema.domain.models.IdentityType;
+import ch.threema.domain.models.VerificationLevel;
 import ch.threema.storage.models.ContactModel;
 
 public class AddContactAsyncTask extends AsyncTask<Void, Void, Boolean> {
@@ -79,8 +79,8 @@ public class AddContactAsyncTask extends AsyncTask<Void, Void, Boolean> {
 				if (contactModel.getIdentityType() == IdentityType.WORK || markAsWorkVerified) {
 					contactModel.setIsWork(true);
 
-					if(contactModel.getVerificationLevel() != VerificationLevel.FULLY_VERIFIED) {
-						contactModel.setVerificationLevel(VerificationLevel.SERVER_VERIFIED);
+					if(contactModel.verificationLevel != VerificationLevel.FULLY_VERIFIED) {
+						contactModel.verificationLevel = VerificationLevel.SERVER_VERIFIED;
 					}
 					contactService.save(contactModel);
 				}

+ 303 - 0
app/src/main/java/ch/threema/app/asynctasks/AddOrUpdateContactBackgroundTask.kt

@@ -0,0 +1,303 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.asynctasks
+
+import android.content.Context
+import ch.threema.app.R
+import ch.threema.app.utils.AppRestrictionUtil
+import ch.threema.app.utils.executor.BackgroundTask
+import ch.threema.base.ThreemaException
+import ch.threema.base.utils.LoggingUtil
+import ch.threema.data.models.ContactModel
+import ch.threema.data.models.ContactModelData
+import ch.threema.data.repositories.ContactCreateException
+import ch.threema.data.repositories.ContactModelRepository
+import ch.threema.domain.models.IdentityState
+import ch.threema.domain.models.IdentityType
+import ch.threema.domain.models.VerificationLevel
+import ch.threema.domain.protocol.api.APIConnector
+import ch.threema.domain.protocol.api.APIConnector.FetchIdentityResult
+import ch.threema.domain.protocol.api.APIConnector.HttpConnectionException
+import ch.threema.domain.protocol.api.APIConnector.NetworkException
+import ch.threema.storage.models.ContactModel.AcquaintanceLevel
+import kotlinx.coroutines.runBlocking
+import java.net.HttpURLConnection
+import java.util.Date
+
+private val logger = LoggingUtil.getThreemaLogger("AddContactBackgroundTask")
+
+/**
+ * This background task should be used if a new identity should be added to the contacts. The task
+ * will fetch the public key, identity type, activity state, and feature mask from the server.
+ *
+ * If [expectedPublicKey] is set, this background task verifies that the public key matches before
+ * adding the new contact. If the contact already exists, it checks that the public key matches and
+ * returns [Failed] if it doesn't match.
+ *
+ * This task also updates the contact if it already exists. This includes changing the acquaintance
+ * level from group to direct or changing the verification level to fully verified.
+ *
+ * Note that this task can be overridden and the behavior can be adjusted by overwriting [onBefore]
+ * and [onFinished].
+ */
+open class AddOrUpdateContactBackgroundTask(
+    protected val identity: String,
+    private val myIdentity: String,
+    private val apiConnector: APIConnector,
+    private val contactModelRepository: ContactModelRepository,
+    private val addContactRestrictionPolicy: AddContactRestrictionPolicy,
+    private val context: Context,
+    private val expectedPublicKey: ByteArray? = null,
+) : BackgroundTask<ContactAddResult> {
+
+    final override fun runBefore() {
+        onBefore()
+    }
+
+    final override fun runInBackground(): ContactAddResult {
+        if (identity == myIdentity) {
+            return failed(R.string.identity_already_exists)
+        }
+
+        // Update contact if it exists
+        contactModelRepository.getByIdentity(identity)?.let {
+            val data = it.data.value
+
+            if (data != null) {
+                return updateContact(it, data, expectedPublicKey)
+            }
+        }
+
+        // Only proceed if adding contacts is allowed
+        if (addContactRestrictionPolicy == AddContactRestrictionPolicy.CHECK
+            && AppRestrictionUtil.isAddContactDisabled(context)
+        ) {
+            return PolicyViolation
+        }
+
+        // Fetch the identity
+        val result = try {
+            apiConnector.fetchIdentity(identity)
+        } catch (e: Exception) {
+            logger.error("Failed to fetch identity", e)
+
+            when (e) {
+                is HttpConnectionException -> {
+                    if (e.errorCode == HttpURLConnection.HTTP_NOT_FOUND) {
+                        return failed(R.string.invalid_threema_id)
+                    } else {
+                        return failed(R.string.connection_error)
+                    }
+                }
+
+                is NetworkException, is ThreemaException -> {
+                    return failed(R.string.connection_error)
+                }
+
+                else -> {
+                    throw e
+                }
+            }
+        }
+
+        // Add the new contact
+        return addNewContact(result, expectedPublicKey)
+    }
+
+    final override fun runAfter(result: ContactAddResult) {
+        onFinished(result)
+    }
+
+    /**
+     * This will be run before the contact is being fetched from the server.
+     */
+    open fun onBefore() {}
+
+    /**
+     * As soon as the contact has been added or an error occurred, this method is run with the
+     * provided result.
+     */
+    open fun onFinished(result: ContactAddResult) {}
+
+    private fun addNewContact(
+        result: FetchIdentityResult,
+        expectedPublicKey: ByteArray?,
+    ): ContactAddResult {
+        val verificationLevel = if (expectedPublicKey != null) {
+            if (expectedPublicKey.contentEquals(result.publicKey)) {
+                VerificationLevel.FULLY_VERIFIED
+            } else {
+                return failed(R.string.id_mismatch)
+            }
+        } else {
+            VerificationLevel.UNVERIFIED
+        }
+
+        val identityType = when (result.type) {
+            0 -> IdentityType.NORMAL
+            1 -> IdentityType.WORK
+            else -> {
+                logger.warn("Identity fetch returned invalid identity type: {}", result.type)
+                IdentityType.NORMAL
+            }
+        }
+
+        val activityState = when (result.state) {
+            IdentityState.ACTIVE -> ch.threema.storage.models.ContactModel.State.ACTIVE
+            IdentityState.INACTIVE -> ch.threema.storage.models.ContactModel.State.INACTIVE
+            IdentityState.INVALID -> ch.threema.storage.models.ContactModel.State.INVALID
+            else -> {
+                logger.warn("Identity fetch returned invalid identity state: {}", result.state)
+                ch.threema.storage.models.ContactModel.State.ACTIVE
+            }
+        }
+
+        return runBlocking {
+            try {
+                val contactModel = contactModelRepository.createFromLocal(
+                    result.identity,
+                    result.publicKey,
+                    Date(),
+                    identityType,
+                    AcquaintanceLevel.DIRECT,
+                    activityState,
+                    result.featureMask.toULong(),
+                    verificationLevel,
+                )
+                Success(contactModel)
+            } catch (e: ContactCreateException) {
+                logger.error("Could not insert new contact", e)
+                failed(R.string.add_contact_failed)
+            }
+        }
+    }
+
+    private fun updateContact(contactModel: ContactModel, data: ContactModelData, expectedPublicKey: ByteArray?): ContactAddResult {
+        var verificationLevelChanged = false
+        var contactVerifiedAgain = false
+        var acquaintanceLevelChanged = false
+
+        if (expectedPublicKey != null) {
+            if (expectedPublicKey.contentEquals(data.publicKey)) {
+                if (data.verificationLevel != VerificationLevel.FULLY_VERIFIED) {
+                    contactModel.setVerificationLevelFromLocal(VerificationLevel.FULLY_VERIFIED)
+                    verificationLevelChanged = true
+                } else {
+                    contactVerifiedAgain = true
+                }
+            } else {
+                return failed(R.string.id_mismatch)
+            }
+        }
+
+        if (data.acquaintanceLevel == AcquaintanceLevel.GROUP) {
+            contactModel.setAcquaintanceLevelFromLocal(AcquaintanceLevel.DIRECT)
+            acquaintanceLevelChanged = true
+        }
+
+        return when {
+            acquaintanceLevelChanged || verificationLevelChanged -> ContactModified(
+                contactModel, acquaintanceLevelChanged, verificationLevelChanged
+            )
+
+            contactVerifiedAgain -> AlreadyVerified(contactModel)
+            else -> ContactExists(contactModel)
+        }
+    }
+
+    private fun failed(stringId: Int) = Failed(context.getString(stringId))
+}
+
+/**
+ * This is used to define whether the contact add restriction should be respected or if a contact
+ * should be added anyways.
+ */
+enum class AddContactRestrictionPolicy {
+    /**
+     * The add contact restriction must be followed and a contact won't be added if this is
+     * prohibited. In this case the result will be of the type [PolicyViolation].
+     */
+    CHECK,
+
+    /**
+     * The add contact restriction won't be respected and the contact will be added anyways. Note
+     * that this must only be used in cases where adding the contact is not triggered by the user.
+     */
+    IGNORE
+}
+
+/**
+ * The result type of adding a contact.
+ */
+sealed interface ContactAddResult
+
+/**
+ * The contact has been added successfully. The new contact is provided.
+ */
+data class Success(val contactModel: ContactModel) : ContactAddResult
+
+/**
+ * The contact already existed and has now been updated.
+ */
+data class ContactModified(
+    /**
+     * The updated contact model.
+     */
+    val contactModel: ContactModel,
+    /**
+     * If true, the acquaintance level changed from [AcquaintanceLevel.GROUP] to
+     * [AcquaintanceLevel.DIRECT].
+     */
+    val acquaintanceLevelChanged: Boolean,
+    /**
+     * If true, the verification level has changed to [VerificationLevel.FULLY_VERIFIED].
+     */
+    val verificationLevelChanged: Boolean,
+) : ContactAddResult
+
+/**
+ * The result type when adding the contact has failed.
+ */
+interface Error : ContactAddResult
+
+/**
+ * The contact already exists. This is only returned, if no expected public key is given and the
+ * contact already exists. This means, that neither the verification level nor the acquaintance
+ * level did change.
+ */
+data class ContactExists(val contactModel: ContactModel) : Error
+
+/**
+ * The contact already exists and has been fully verified before.
+ */
+data class AlreadyVerified(val contactModel: ContactModel) : Error
+
+/**
+ * Adding the contact failed. The [message] contains a (translated) error message that can be shown
+ * to the user.
+ */
+data class Failed(val message: String) : Error
+
+/**
+ * The contact could not be added since adding contacts is restricted.
+ */
+object PolicyViolation : Error

+ 2 - 2
app/src/main/java/ch/threema/app/asynctasks/DeleteIdentityAsyncTask.java

@@ -114,9 +114,9 @@ public class DeleteIdentityAsyncTask extends AsyncTask<Void, Void, Exception> {
 			}
 
 			File aesFile = new File(ThreemaApplication.getAppContext().getFilesDir(), ThreemaApplication.AES_KEY_FILE);
-			File databaseFile = ThreemaApplication.getAppContext().getDatabasePath(DatabaseServiceNew.DATABASE_NAME_V4);
+			File databaseFile = ThreemaApplication.getAppContext().getDatabasePath(DatabaseServiceNew.DEFAULT_DATABASE_NAME_V4);
 			File nonceDatabaseFile = ThreemaApplication.getAppContext().getDatabasePath(DatabaseNonceStore.DATABASE_NAME_V4);
-			File backupFile = ThreemaApplication.getAppContext().getDatabasePath(DatabaseServiceNew.DATABASE_NAME_V4 + DatabaseServiceNew.DATABASE_BACKUP_EXT);
+			File backupFile = ThreemaApplication.getAppContext().getDatabasePath(DatabaseServiceNew.DEFAULT_DATABASE_NAME_V4 + DatabaseServiceNew.DATABASE_BACKUP_EXT);
 			File cacheDirectory = ThreemaApplication.getAppContext().getCacheDir();
 			File externalCacheDirectory = ThreemaApplication.getAppContext().getExternalCacheDir();
 

+ 12 - 4
app/src/main/java/ch/threema/app/asynctasks/EmptyOrDeleteConversationsAsyncTask.java

@@ -26,6 +26,8 @@ import android.view.View;
 
 import com.google.android.material.snackbar.Snackbar;
 
+import org.slf4j.Logger;
+
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
@@ -42,18 +44,19 @@ import ch.threema.app.services.DistributionListService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
+import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.DistributionListModel;
 import ch.threema.storage.models.GroupModel;
 
 /**
  * Empty or delete one or more conversation/chat.
- *
+ * <p>
  * The primary use case is a user pressing the "delete" icon on a conversation
  * in the conversation list. This will show a dialog while the process is ongoing.
- *
+ * <p>
  * Note: Behavior with Mode.DELETE depends on the {@link MessageReceiver} passed in:
- *
+ * <p>
  *   - Contacts: Delete conversation, but not contact
  *   - Groups:
  *     - Left: Delete conversation and group
@@ -62,6 +65,8 @@ import ch.threema.storage.models.GroupModel;
  *   - Distribution lists: Delete distribution list
  */
 public class EmptyOrDeleteConversationsAsyncTask extends AsyncTask<Void, Void, Void> {
+	private static final Logger logger = LoggingUtil.getThreemaLogger("EmptyOrDeleteConversationAsyncTask");
+
 	private static final String DIALOG_TAG_EMPTYING_OR_DELETING_CHAT = "edc";
 
 	// Services
@@ -132,14 +137,17 @@ public class EmptyOrDeleteConversationsAsyncTask extends AsyncTask<Void, Void, V
 	@Override
 	protected Void doInBackground(Void... params) {
 		// Empty or delete conversations
+		logger.info("{} chat for {} receivers.", this.mode, messageReceivers.length);
 		switch (this.mode) {
 			case EMPTY:
 				for (MessageReceiver receiver : this.messageReceivers) {
-					this.conversationService.empty(receiver);
+					int countRemoved = this.conversationService.empty(receiver);
+					logger.info("Removed {} messages for receiver {} (type={}).", countRemoved, receiver.getUniqueIdString(), receiver.getType());
 				}
 				break;
 			case DELETE:
 				for (MessageReceiver receiver : this.messageReceivers) {
+					logger.info("Delete chat with receiver {} (type={}).", receiver.getUniqueIdString(), receiver.getType());
 					if (receiver instanceof ContactMessageReceiver) {
 						final ContactModel contactModel = ((ContactMessageReceiver) receiver).getContact();
 						this.deleteContactConversation(contactModel);

+ 13 - 7
app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java

@@ -190,7 +190,7 @@ public class BackupService extends Service {
 			if (!isCanceled) {
 				config = (BackupRestoreDataConfig) intent.getSerializableExtra(EXTRA_BACKUP_RESTORE_DATA_CONFIG);
 
-				if (config == null || userService.getIdentity() == null || userService.getIdentity().length() == 0) {
+				if (config == null || userService.getIdentity() == null || userService.getIdentity().isEmpty()) {
 					safeStopSelf();
 					return START_NOT_STICKY;
 				}
@@ -542,7 +542,7 @@ public class BackupService extends Service {
 			try {
 				ZipUtil.addZipStream(
 					zipOutputStream,
-					this.fileService.getContactAvatarStream(contactService.getMe()),
+					this.fileService.getContactAvatarStream(contactService.getMe().getIdentity()),
 					Tags.CONTACT_AVATAR_FILE_PREFIX + Tags.CONTACT_AVATAR_FILE_SUFFIX_ME,
 					false
 				);
@@ -556,7 +556,6 @@ public class BackupService extends Service {
 			Tags.TAG_CONTACT_PUBLIC_KEY,
 			Tags.TAG_CONTACT_VERIFICATION_LEVEL,
 			Tags.TAG_CONTACT_ANDROID_CONTACT_ID,
-			Tags.TAG_CONTACT_THREEMA_ANDROID_CONTACT_ID,
 			Tags.TAG_CONTACT_FIRST_NAME,
 			Tags.TAG_CONTACT_LAST_NAME,
 			Tags.TAG_CONTACT_NICK_NAME,
@@ -584,6 +583,8 @@ public class BackupService extends Service {
 			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.
@@ -600,9 +601,8 @@ public class BackupService extends Service {
 					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.getVerificationLevel().toString())
+						.write(Tags.TAG_CONTACT_VERIFICATION_LEVEL, contactModel.verificationLevel.toString())
 						.write(Tags.TAG_CONTACT_ANDROID_CONTACT_ID, contactModel.getAndroidContactLookupKey())
-						.write(Tags.TAG_CONTACT_THREEMA_ANDROID_CONTACT_ID, contactModel.getThreemaAndroidContactId())
 						.write(Tags.TAG_CONTACT_FIRST_NAME, contactModel.getFirstName())
 						.write(Tags.TAG_CONTACT_LAST_NAME, contactModel.getLastName())
 						.write(Tags.TAG_CONTACT_NICK_NAME, contactModel.getPublicNickName())
@@ -618,7 +618,7 @@ public class BackupService extends Service {
 							if (!userService.getIdentity().equals(contactModel.getIdentity())) {
 								ZipUtil.addZipStream(
 									zipOutputStream,
-									this.fileService.getContactAvatarStream(contactModel),
+									this.fileService.getContactAvatarStream(contactModel.getIdentity()),
 									Tags.CONTACT_AVATAR_FILE_PREFIX + identityId,
 									false
 								);
@@ -631,7 +631,7 @@ public class BackupService extends Service {
 						try {
 							ZipUtil.addZipStream(
 								zipOutputStream,
-								this.fileService.getContactPhotoStream(contactModel),
+								this.fileService.getContactPhotoStream(contactModel.getIdentity()),
 								Tags.CONTACT_PROFILE_PIC_FILE_PREFIX + identityId,
 								false
 							);
@@ -675,6 +675,8 @@ public class BackupService extends Service {
 										.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();
 								}
 
@@ -748,6 +750,8 @@ public class BackupService extends Service {
 			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() {
@@ -848,6 +852,8 @@ public class BackupService extends Service {
 									.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(

+ 25 - 13
app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java

@@ -21,10 +21,6 @@
 
 package ch.threema.app.backuprestore.csv;
 
-import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_ALERT;
-import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS;
-import static ch.threema.app.utils.IntentDataUtil.PENDING_INTENT_FLAG_IMMUTABLE;
-
 import android.annotation.SuppressLint;
 import android.app.Notification;
 import android.app.NotificationManager;
@@ -41,10 +37,6 @@ import android.text.TextUtils;
 import android.text.format.DateUtils;
 import android.widget.Toast;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.core.app.NotificationCompat;
-
 import net.lingala.zip4j.ZipFile;
 import net.lingala.zip4j.io.inputstream.ZipInputStream;
 import net.lingala.zip4j.model.FileHeader;
@@ -64,6 +56,9 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.app.NotificationCompat;
 import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
@@ -79,7 +74,6 @@ import ch.threema.app.notifications.NotificationBuilderWrapper;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.FileService;
-import ch.threema.app.services.GroupService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.utils.BackupUtils;
@@ -123,6 +117,10 @@ import ch.threema.storage.models.data.MessageContentsType;
 import ch.threema.storage.models.data.media.BallotDataModel;
 import ch.threema.storage.models.data.media.FileDataModel;
 
+import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_ALERT;
+import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS;
+import static ch.threema.app.utils.IntentDataUtil.PENDING_INTENT_FLAG_IMMUTABLE;
+
 public class RestoreService extends Service {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("RestoreService");
 
@@ -969,7 +967,7 @@ public class RestoreService extends Service {
 		// Set contact avatar
 		try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
 			return fileService.writeContactAvatar(
-				contactModel,
+				contactModel.getIdentity(),
 				IOUtils.toByteArray(inputStream)
 			);
 		} catch (Exception e) {
@@ -998,7 +996,7 @@ public class RestoreService extends Service {
 		// Set contact profile picture
 		try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
 			return fileService.writeContactPhoto(
-				contactModel,
+				contactModel.getIdentity(),
 				IOUtils.toByteArray(inputStream));
 		} catch (Exception e) {
 			logger.error("Exception while writing contact profile picture", e);
@@ -1468,7 +1466,6 @@ public class RestoreService extends Service {
 				GroupMemberModel m = new GroupMemberModel();
 				m.setGroupId(groupId);
 				m.setIdentity(identity);
-				m.setActive(true);
 				res.add(m);
 			}
 		}
@@ -1503,7 +1500,7 @@ public class RestoreService extends Service {
 		} else if (verificationString.equals(VerificationLevel.FULLY_VERIFIED.name())) {
 			verification = VerificationLevel.FULLY_VERIFIED;
 		}
-		contactModel.setVerificationLevel(verification);
+		contactModel.verificationLevel = verification;
 		contactModel.setFirstName(row.getString(Tags.TAG_CONTACT_FIRST_NAME));
 		contactModel.setLastName(row.getString(Tags.TAG_CONTACT_LAST_NAME));
 
@@ -1655,6 +1652,15 @@ public class RestoreService extends Service {
 			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;
 	}
 
@@ -1684,6 +1690,12 @@ public class RestoreService extends Service {
 				}
 			}
 		}
+		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;
 	}
 

+ 3 - 1
app/src/main/java/ch/threema/app/backuprestore/csv/RestoreSettings.java

@@ -42,8 +42,10 @@ public class RestoreSettings {
 	 * 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
 	 */
-	public static final int CURRENT_VERSION = 22;
+	public static final int CURRENT_VERSION = 24;
 	private int version;
 
 	public RestoreSettings(int version) {

+ 2 - 1
app/src/main/java/ch/threema/app/backuprestore/csv/Tags.java

@@ -59,7 +59,6 @@ public abstract class Tags {
 	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_THREEMA_ANDROID_CONTACT_ID = "tacid";
 	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";
@@ -90,6 +89,8 @@ public abstract class Tags {
 	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";

+ 4 - 0
app/src/main/java/ch/threema/app/connection/CspD2mDualConnectionSupplier.kt

@@ -35,6 +35,7 @@ import ch.threema.domain.protocol.connection.csp.socket.ProxyAwareSocketFactory
 import ch.threema.domain.protocol.connection.d2m.D2mConnectionConfiguration
 import ch.threema.domain.protocol.connection.util.ConnectionLoggingUtil
 import ch.threema.domain.stores.IdentityStoreInterface
+import ch.threema.domain.taskmanager.IncomingMessageProcessor
 import ch.threema.domain.taskmanager.TaskManager
 import java8.util.function.Supplier
 import okhttp3.OkHttpClient
@@ -51,6 +52,7 @@ private val logger = ConnectionLoggingUtil.getConnectionLogger("CspD2mDualConnec
  */
 class CspD2mDualConnectionSupplier (
     private val multiDeviceManager: MultiDeviceManager,
+    private val incomingMessageProcessor: IncomingMessageProcessor,
     private val taskManager: TaskManager,
     private val deviceCookieManager: DeviceCookieManager,
     private val serverAddressProviderService: ServerAddressProviderService,
@@ -99,6 +101,7 @@ class CspD2mDualConnectionSupplier (
             version,
             isTestBuild,
             deviceCookieManager,
+            incomingMessageProcessor,
             taskManager,
             AsyncResolver::getAllByName,
             isIpv6Preferred,
@@ -114,6 +117,7 @@ class CspD2mDualConnectionSupplier (
             version,
             isTestBuild,
             deviceCookieManager,
+            incomingMessageProcessor,
             taskManager,
             multiDeviceManager.propertiesProvider,
             multiDeviceManager.socketCloseListener,

+ 27 - 50
app/src/main/java/ch/threema/app/dialogs/ContactEditDialog.java

@@ -38,6 +38,7 @@ import java.io.File;
 import java.lang.ref.WeakReference;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
 import androidx.appcompat.app.AppCompatDialog;
 import ch.threema.app.R;
@@ -45,15 +46,15 @@ import ch.threema.app.ThreemaApplication;
 import ch.threema.app.emojis.EmojiEditText;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.services.ContactService;
-import ch.threema.app.services.GroupService;
 import ch.threema.app.ui.AvatarEditView;
 import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.ViewUtil;
 import ch.threema.base.utils.LoggingUtil;
-import ch.threema.domain.models.Contact;
+import ch.threema.data.models.ContactModelData;
 import ch.threema.localcrypto.MasterKeyLockedException;
-import ch.threema.storage.models.ContactModel;
+
+import static ch.threema.domain.models.ContactKt.CONTACT_NAME_MAX_LENGTH_BYTES;
 
 public class ContactEditDialog extends ThreemaDialogFragment implements AvatarEditView.AvatarEditListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ContactEditDialog");
@@ -78,20 +79,20 @@ public class ContactEditDialog extends ThreemaDialogFragment implements AvatarEd
 	private AvatarEditView avatarEditView;
 	private File croppedAvatarFile = null;
 
-	public static ContactEditDialog newInstance(ContactModel contactModel) {
+	public static ContactEditDialog newInstance(@NonNull ContactModelData contactModelData) {
 		final int inputType = InputType.TYPE_CLASS_TEXT
 			| InputType.TYPE_TEXT_VARIATION_PERSON_NAME
 			| InputType.TYPE_TEXT_FLAG_CAP_WORDS;
 
-		if(ContactUtil.isChannelContact(contactModel)) {
+		if(ContactUtil.isGatewayContact(contactModelData.identity)) {
 			//business contact don't have a second name
 			return newInstance(
 					R.string.edit_name_only,
-					contactModel.getFirstName(),
+					contactModelData.firstName,
 					null,
 					R.string.name,
 					0,
-					contactModel.getIdentity(),
+					contactModelData.identity,
 					inputType,
 					ContactUtil.CHANNEL_NAME_MAX_LENGTH_BYTES);
 
@@ -99,17 +100,17 @@ public class ContactEditDialog extends ThreemaDialogFragment implements AvatarEd
 		else {
 			return newInstance(
 					R.string.edit_name_only,
-					contactModel.getFirstName(),
-					contactModel.getLastName(),
+					contactModelData.firstName,
+					contactModelData.lastName,
 					R.string.first_name,
 					R.string.last_name,
-					contactModel.getIdentity(),
+					contactModelData.identity,
 					inputType,
-					Contact.CONTACT_NAME_MAX_LENGTH_BYTES);
+					CONTACT_NAME_MAX_LENGTH_BYTES);
 		}
 	}
 
-	public static ContactEditDialog newInstance(Bundle args) {
+	private static ContactEditDialog newInstance(Bundle args) {
 		ContactEditDialog dialog = new ContactEditDialog();
 		dialog.setArguments(args);
 		return dialog;
@@ -118,7 +119,7 @@ public class ContactEditDialog extends ThreemaDialogFragment implements AvatarEd
 	/**
 	 * Create a ContactEditDialog with two input fields.
 	 */
-	public static ContactEditDialog newInstance(@StringRes int title, String text1, String text2,
+	private static ContactEditDialog newInstance(@StringRes int title, String text1, String text2,
 	                                            @StringRes int hint1, @StringRes int hint2,
 	                                            String identity, int inputType, int maxLength) {
 		final Bundle args = new Bundle();
@@ -133,22 +134,6 @@ public class ContactEditDialog extends ThreemaDialogFragment implements AvatarEd
 		return newInstance(args);
 	}
 
-	/**
-	 * Create a ContactEditDialog with just one input field.
-	 */
-	public static ContactEditDialog newInstance(@StringRes int title, String text1,
-	                                            @StringRes int hint1,
-	                                            String identity, int inputType, int maxLength) {
-		final Bundle args = new Bundle();
-		args.putInt(ARG_TITLE, title);
-		args.putString(ARG_TEXT1, text1);
-		args.putInt(ARG_HINT1, hint1);
-		args.putString(ARG_IDENTITY, identity);
-		args.putInt("inputType", inputType);
-		args.putInt("maxLength", maxLength);
-		return newInstance(args);
-	}
-
 	/**
 	 * Create a ContactEditDialog for a group
 	 */
@@ -187,7 +172,7 @@ public class ContactEditDialog extends ThreemaDialogFragment implements AvatarEd
 	}
 
 	public interface ContactEditDialogClickListener {
-		void onYes(String tag, String text1, String text2, File avatar);
+		void onYes(String tag, String text1, String text2, @Nullable File avatar);
 		void onNo(String tag);
 	}
 
@@ -234,10 +219,8 @@ public class ContactEditDialog extends ThreemaDialogFragment implements AvatarEd
 		croppedAvatarFile = (File) getArguments().getSerializable("avatarPreset");
 
 		ContactService contactService = null;
-		GroupService groupService = null;
 		try {
 			contactService = ThreemaApplication.getServiceManager().getContactService();
-			groupService = ThreemaApplication.getServiceManager().getGroupService();
 		} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
 			logger.error("Exception", e);
 		}
@@ -262,10 +245,8 @@ public class ContactEditDialog extends ThreemaDialogFragment implements AvatarEd
 		if (!TestUtil.empty(identity)) {
 			avatarEditView.setVisibility(View.GONE);
 			if (contactService != null) {
-				ContactModel contactModel = contactService.getByIdentity(identity);
-
-				//hide second name on business contact
-				if (ContactUtil.isChannelContact(contactModel)) {
+				// Hide second name on business contact
+				if (ContactUtil.isGatewayContact(identity)) {
 					ViewUtil.show(editText2, false);
 				}
 			}
@@ -313,21 +294,17 @@ public class ContactEditDialog extends ThreemaDialogFragment implements AvatarEd
 
 		builder.setView(dialogView);
 
-		builder.setPositiveButton(getString(R.string.ok), new DialogInterface.OnClickListener() {
-					public void onClick(DialogInterface dialog, int whichButton) {
-						if (callbackRef.get() != null) {
-							callbackRef.get().onYes(tag, editText1.getText().toString(), editText2.getText().toString(), croppedAvatarFile);
-						}
-					}
-				}
+		builder.setPositiveButton(getString(R.string.ok), (dialog, whichButton) -> {
+			if (callbackRef.get() != null) {
+				callbackRef.get().onYes(tag, editText1.getText().toString(), editText2.getText().toString(), null);
+			}
+		}
 		);
-		builder.setNegativeButton(getString(R.string.cancel), new DialogInterface.OnClickListener() {
-					public void onClick(DialogInterface dialog, int whichButton) {
-						if (callbackRef.get() != null) {
-							callbackRef.get().onNo(tag);
-						}
-					}
-				}
+		builder.setNegativeButton(getString(R.string.cancel), (dialog, whichButton) -> {
+			if (callbackRef.get() != null) {
+				callbackRef.get().onNo(tag);
+			}
+		}
 		);
 
 		setCancelable(false);

+ 10 - 0
app/src/main/java/ch/threema/app/dialogs/GenericAlertDialog.java

@@ -160,6 +160,16 @@ public class GenericAlertDialog extends ThreemaDialogFragment {
 		return dialog;
 	}
 
+	public static GenericAlertDialog newInstance(String titleString, CharSequence messageString,
+	                                             @StringRes int positive, @StringRes int negative, @StringRes int neutral) {
+		GenericAlertDialog dialog = newInstance(titleString, messageString, positive, negative);
+		if (dialog.getArguments() != null) {
+			dialog.getArguments().putInt("neutral", neutral);
+		}
+		return dialog;
+	}
+
+
 	public interface DialogClickListener {
 		void onYes(String tag, Object data);
 		default void onNo(String tag, Object data) {};

+ 16 - 0
app/src/main/java/ch/threema/app/dialogs/MessageDetailDialog.java

@@ -198,6 +198,10 @@ public class MessageDetailDialog extends ThreemaDialogFragment implements View.O
 			final TextView readDate = dialogView.findViewById(R.id.read_date);
 			final TextView modifiedText = dialogView.findViewById(R.id.modified_text);
 			final TextView modifiedDate = dialogView.findViewById(R.id.modified_date);
+			final TextView editedText = dialogView.findViewById(R.id.edited_text);
+			final TextView editedDate = dialogView.findViewById(R.id.edited_date);
+			final TextView deletedText = dialogView.findViewById(R.id.deleted_text);
+			final TextView deletedDate = dialogView.findViewById(R.id.deleted_date);
 			final TextView messageIdText = dialogView.findViewById(R.id.messageid_text);
 			final TextView messageIdDate = dialogView.findViewById(R.id.messageid_date);
 			final TextView mimeTypeText = dialogView.findViewById(R.id.filetype_text);
@@ -322,6 +326,18 @@ public class MessageDetailDialog extends ThreemaDialogFragment implements View.O
 					}
 				}
 
+				if (messageModel.getEditedAt() != null) {
+					editedDate.setText(LocaleUtil.formatTimeStampStringAbsolute(getContext(), messageModel.getEditedAt().getTime()));
+					editedText.setVisibility(View.VISIBLE);
+					editedDate.setVisibility(View.VISIBLE);
+				}
+
+				if (messageModel.getDeletedAt() != null) {
+					deletedDate.setText(LocaleUtil.formatTimeStampStringAbsolute(getContext(), messageModel.getDeletedAt().getTime()));
+					deletedText.setVisibility(View.VISIBLE);
+					deletedDate.setVisibility(View.VISIBLE);
+				}
+
 				if (messageModel.getType() == MessageType.FILE && messageModel.getFileData() != null) {
 					if (!TestUtil.empty(messageModel.getFileData().getMimeType())) {
 						mimeTypeMime.setText(messageModel.getFileData().getMimeType());

+ 2 - 15
app/src/main/java/ch/threema/app/dialogs/ResendGroupMessageDialog.kt

@@ -22,13 +22,10 @@
 package ch.threema.app.dialogs
 
 import android.app.Dialog
-import android.icu.text.ListFormatter
-import android.os.Build
 import android.os.Bundle
 import ch.threema.app.R
 import ch.threema.app.services.ContactService
-import ch.threema.app.utils.LocaleUtil
-import ch.threema.app.utils.NameUtil
+import ch.threema.app.utils.ContactUtil
 import com.google.android.material.dialog.MaterialAlertDialogBuilder
 
 class ResendGroupMessageDialog(
@@ -38,17 +35,7 @@ class ResendGroupMessageDialog(
 ) : ThreemaDialogFragment() {
 
     override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
-        val context = context
-
-        val contactNames = rejectedIdentities.mapNotNull { contactService.getByIdentity(it) }
-            .map { NameUtil.getDisplayNameOrNickname(it, false) }
-        val concatenatedContactNames =
-            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && context != null) {
-                ListFormatter.getInstance(LocaleUtil.getCurrentLocale(context)).format(contactNames)
-            } else {
-                contactNames.joinToString(separator = ", ")
-            }
-
+        val concatenatedContactNames = ContactUtil.joinDisplayNames(context, contactService.getByIdentities(rejectedIdentities.toList()))
         val builder = MaterialAlertDialogBuilder(requireActivity())
         builder.setTitle(getString(R.string.resend_message_dialog_title))
         builder.setMessage(getString(R.string.resend_message_dialog_message, concatenatedContactNames))

+ 8 - 2
app/src/main/java/ch/threema/app/emojis/EmojiPicker.java

@@ -62,6 +62,7 @@ public class EmojiPicker extends LinearLayout implements EmojiSearchWidget.Emoji
 	private RecentEmojiRemovePopup recentRemovePopup;
 	private RelativeLayout pickerHeader;
 	private EmojiSearchWidget emojiSearchWidget;
+	private boolean isKeyboardAnimated = false; // whether keyboard animation is enabled for this activity
 
 	private final LinearLayout.LayoutParams searchLayoutParams = new LinearLayout.LayoutParams(
 		ViewGroup.LayoutParams.MATCH_PARENT,
@@ -98,8 +99,9 @@ public class EmojiPicker extends LinearLayout implements EmojiSearchWidget.Emoji
 		this.emojiKeyListener = listener;
 	}
 
-	public void init(EmojiService emojiService) {
+	public void init(EmojiService emojiService, boolean isKeyboardAnimated) {
 		this.emojiService = emojiService;
+		this.isKeyboardAnimated = isKeyboardAnimated;
 
 		this.emojiPickerView = LayoutInflater.from(getContext()).inflate(R.layout.emoji_picker, this, true);
 
@@ -164,7 +166,11 @@ public class EmojiPicker extends LinearLayout implements EmojiSearchWidget.Emoji
 		this.emojiService.saveRecentEmojis();
 	}
 
-	public void onKeyboardShown() {}
+	public void onKeyboardShown() {
+		if (!isKeyboardAnimated && isShown() &&  !emojiSearchWidget.isShown()) {
+			hide();
+		}
+	}
 
 	public void onKeyboardHidden() {
 		if (emojiSearchWidget.isShown()) {

+ 134 - 119
app/src/main/java/ch/threema/app/emojis/search/EmojiSearchIndex.kt

@@ -22,6 +22,7 @@
 package ch.threema.app.emojis.search
 
 import android.content.Context
+import android.database.sqlite.SQLiteConstraintException
 import androidx.annotation.WorkerThread
 import androidx.room.Room
 import au.com.bytecode.opencsv.CSVReader
@@ -34,124 +35,138 @@ import java.io.InputStreamReader
 private val logger = LoggingUtil.getThreemaLogger("EmojiSearchIndex")
 
 class EmojiSearchIndex(
-	private val context: Context,
-	private val preferenceService: PreferenceService
+    private val context: Context,
+    private val preferenceService: PreferenceService
 ) {
-	private val db by lazy { buildDatabase() }
-	private val languageSupport = mutableMapOf<String, Boolean>()
-	private val languageVersion = mutableMapOf<String, Int>()
-	private var searchIndexVersion = preferenceService.emojiSearchIndexVersion
-
-	private companion object {
-		const val SEARCH_INDEX_VERSION = 7
-		const val INDEX_FILE_EXTENSION = ".csv"
-		const val EMOJI_ORDERS_FILE = "orders.csv"
-		const val EMOJI_DIVERSITIES_FILE = "diversities.csv"
-		const val DATABASE_NAME = "emoji-search-index.db"
-		const val CSV_SEPARATOR = '|'
-		val ASSETS_PATH = joinPath("emojis", "search-index")
-
-		private fun joinPath(vararg parts: String): String {
-			return parts.joinToString(File.separator)
-		}
-	}
-
-	fun supportsLanguage(language: String): Boolean {
-		if (!languageSupport.containsKey(language)) {
-			logger.debug("Evaluate emoji search support for language '{}'", language)
-			val searchTermsFileName = getSearchTermsFileName(language)
-			val indexFiles = context.assets.list(ASSETS_PATH) ?: arrayOf()
-			languageSupport[language] = searchTermsFileName in indexFiles
-		}
-		return languageSupport[language] == true
-	}
-
-	@WorkerThread
-	fun search(language: String, searchTerm: String): List<Emoji> {
-		prepareSearchIndex(language)
-		return db.emojiDao().search(language, searchTerm)
-	}
-
-	private fun getSearchTermsFileName(language: String): String {
-		return "${language}${INDEX_FILE_EXTENSION}"
-	}
-
-	private fun getSearchTermsFilePath(language: String): String {
-		return joinPath(ASSETS_PATH, getSearchTermsFileName(language))
-	}
-
-	@WorkerThread
-	fun prepareSearchIndex(language: String) {
-		synchronized(db) {
-			val dao = db.emojiDao()
-			if (searchIndexVersion != SEARCH_INDEX_VERSION) {
-				logger.info("Prepare emoji search index for version {}", SEARCH_INDEX_VERSION)
-				resetDatabase(dao)
-				val emojis = readEmojisOrdersFromAssets()
-				dao.insertEmojis(emojis)
-				dao.updateEmojiDiversities(readEmojiDiversitiesFromAssets())
-				preferenceService.emojiSearchIndexVersion = SEARCH_INDEX_VERSION
-				searchIndexVersion = SEARCH_INDEX_VERSION
-			}
-			prepareSearchTerms(language, dao)
-		}
-	}
-
-	private fun prepareSearchTerms(language: String, dao: EmojiDao) {
-		if (getLanguageVersion(language, dao) != SEARCH_INDEX_VERSION) {
-			logger.info("Prepare emoji search terms for language '{}' and version {}", language, SEARCH_INDEX_VERSION)
-			dao.deleteSearchTermsForLanguage(language)
-			val terms = readSearchTermsFromAssets(language)
-			dao.insertSearchTerms(terms)
-			dao.insertLanguageVersion(SearchTermsLanguageVersion(language, SEARCH_INDEX_VERSION))
-			languageVersion[language] = SEARCH_INDEX_VERSION
-		}
-	}
-
-	private fun getLanguageVersion(language: String, dao: EmojiDao): Int? {
-		if (!languageVersion.containsKey(language)) {
-			dao.getLanguageVersion(language)?.let {
-				languageVersion[language] = it
-			}
-		}
-		return languageVersion[language]
-	}
-
-	private fun resetDatabase(dao: EmojiDao) {
-		dao.deleteSearchTermLanguageVersions()
-		dao.deleteEmojis()
-	}
-
-	private fun readEmojisOrdersFromAssets(): List<EmojiOrder> {
-		return readCsvRows(joinPath(ASSETS_PATH, EMOJI_ORDERS_FILE))
-			.filter { it.size > 1 }
-			.map { EmojiOrder(it[0], it[1].toLong()) }
-	}
-
-	private fun readEmojiDiversitiesFromAssets(): List<EmojiDiversities> {
-		return readCsvRows(joinPath(ASSETS_PATH, EMOJI_DIVERSITIES_FILE))
-			.filter { it.size > 1 }
-			.map { EmojiDiversities(it[0], it.subList(1, it.size)) }
-	}
-
-	private fun readSearchTermsFromAssets(language: String): List<SearchTerm> {
-		return readCsvRows(getSearchTermsFilePath(language))
-			.filter { it.size > 1 }
-			.flatMap { it.subList(1, it.size).map { term -> SearchTerm(it[0], language, term) } }
-	}
-
-	private fun buildDatabase(): EmojiSearchIndexDatabase {
-		return Room.databaseBuilder(context, EmojiSearchIndexDatabase::class.java, DATABASE_NAME)
-			.build()
-	}
-
-	private fun readCsvRows(file: String): List<List<String>> {
-		return try {
-			val reader = InputStreamReader(context.assets.open(file))
-			CSVReader(reader, CSV_SEPARATOR).readAll().map { it.asList() }
-		} catch (e: IOException) {
-			logger.warn("Could not read search terms", e)
-			listOf()
-		}
-	}
+    private val db by lazy { buildDatabase() }
+    private val brokenSearchTermLanguages = mutableSetOf<String>()
+    private val languageSupport = mutableMapOf<String, Boolean>()
+    private val languageVersion = mutableMapOf<String, Int>()
+    private var searchIndexVersion = preferenceService.emojiSearchIndexVersion
+
+    private companion object {
+        const val SEARCH_INDEX_VERSION = 7
+        const val INDEX_FILE_EXTENSION = ".csv"
+        const val EMOJI_ORDERS_FILE = "orders.csv"
+        const val EMOJI_DIVERSITIES_FILE = "diversities.csv"
+        const val DATABASE_NAME = "emoji-search-index.db"
+        const val CSV_SEPARATOR = '|'
+        val ASSETS_PATH = joinPath("emojis", "search-index")
+
+        private fun joinPath(vararg parts: String): String {
+            return parts.joinToString(File.separator)
+        }
+    }
+
+    fun supportsLanguage(language: String): Boolean {
+        if (!languageSupport.containsKey(language)) {
+            logger.debug("Evaluate emoji search support for language '{}'", language)
+            val searchTermsFileName = getSearchTermsFileName(language)
+            val indexFiles = context.assets.list(ASSETS_PATH) ?: arrayOf()
+            languageSupport[language] = searchTermsFileName in indexFiles
+        }
+        return languageSupport[language] == true
+    }
+
+    @WorkerThread
+    fun search(language: String, searchTerm: String): List<Emoji> {
+        prepareSearchIndex(language)
+        return db.emojiDao().search(language, searchTerm)
+    }
+
+    private fun getSearchTermsFileName(language: String): String {
+        return "${language}${INDEX_FILE_EXTENSION}"
+    }
+
+    private fun getSearchTermsFilePath(language: String): String {
+        return joinPath(ASSETS_PATH, getSearchTermsFileName(language))
+    }
+
+    @WorkerThread
+    fun prepareSearchIndex(language: String) {
+        synchronized(db) {
+            val dao = db.emojiDao()
+            if (searchIndexVersion != SEARCH_INDEX_VERSION) {
+                logger.info("Prepare emoji search index for version {}", SEARCH_INDEX_VERSION)
+                resetDatabase(dao)
+                val emojis = readEmojisOrdersFromAssets()
+                dao.insertEmojis(emojis)
+                dao.updateEmojiDiversities(readEmojiDiversitiesFromAssets())
+                preferenceService.emojiSearchIndexVersion = SEARCH_INDEX_VERSION
+                searchIndexVersion = SEARCH_INDEX_VERSION
+            }
+            prepareSearchTerms(language, dao)
+        }
+    }
+
+    @WorkerThread
+    private fun prepareSearchTerms(language: String, dao: EmojiDao) {
+        if (language in brokenSearchTermLanguages) {
+            logger.warn("Search terms for language '{}' are broken", language)
+        } else if (getLanguageVersion(language, dao) != SEARCH_INDEX_VERSION) {
+            logger.info("Prepare emoji search terms for language '{}' and version {}", language, SEARCH_INDEX_VERSION)
+            dao.deleteSearchTermsForLanguage(language)
+            val terms = readSearchTermsFromAssets(language)
+            insertSearchTerms(language, terms, dao)
+        }
+    }
+
+    @WorkerThread
+    private fun insertSearchTerms(language: String, terms: List<SearchTerm>, dao: EmojiDao) {
+        try {
+            dao.insertSearchTerms(terms)
+            dao.insertLanguageVersion(SearchTermsLanguageVersion(language, SEARCH_INDEX_VERSION))
+            languageVersion[language] = SEARCH_INDEX_VERSION
+        } catch (e: SQLiteConstraintException) {
+            logger.error("Could not prepare search terms for language '{}'", language, e)
+            brokenSearchTermLanguages.add(language)
+        }
+    }
+
+    private fun getLanguageVersion(language: String, dao: EmojiDao): Int? {
+        if (!languageVersion.containsKey(language)) {
+            dao.getLanguageVersion(language)?.let {
+                languageVersion[language] = it
+            }
+        }
+        return languageVersion[language]
+    }
+
+    private fun resetDatabase(dao: EmojiDao) {
+        dao.deleteSearchTermLanguageVersions()
+        dao.deleteEmojis()
+    }
+
+    private fun readEmojisOrdersFromAssets(): List<EmojiOrder> {
+        return readCsvRows(joinPath(ASSETS_PATH, EMOJI_ORDERS_FILE))
+            .filter { it.size > 1 }
+            .map { EmojiOrder(it[0], it[1].toLong()) }
+    }
+
+    private fun readEmojiDiversitiesFromAssets(): List<EmojiDiversities> {
+        return readCsvRows(joinPath(ASSETS_PATH, EMOJI_DIVERSITIES_FILE))
+            .filter { it.size > 1 }
+            .map { EmojiDiversities(it[0], it.subList(1, it.size)) }
+    }
+
+    private fun readSearchTermsFromAssets(language: String): List<SearchTerm> {
+        return readCsvRows(getSearchTermsFilePath(language))
+            .filter { it.size > 1 }
+            .flatMap { it.subList(1, it.size).map { term -> SearchTerm(it[0], language, term) } }
+    }
+
+    private fun buildDatabase(): EmojiSearchIndexDatabase {
+        return Room.databaseBuilder(context, EmojiSearchIndexDatabase::class.java, DATABASE_NAME)
+            .build()
+    }
+
+    private fun readCsvRows(file: String): List<List<String>> {
+        return try {
+            val reader = InputStreamReader(context.assets.open(file))
+            CSVReader(reader, CSV_SEPARATOR).readAll().map { it.asList() }
+        } catch (e: IOException) {
+            logger.warn("Could not read search terms", e)
+            listOf()
+        }
+    }
 }

文件差異過大導致無法顯示
+ 551 - 147
app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java


+ 20 - 31
app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java

@@ -375,8 +375,7 @@ public class ContactsSectionFragment
 
 	private final ContactListener contactListener = new ContactListener() {
 		@Override
-		public void onModified(ContactModel modifiedContactModel) {
-			logger.debug("onModified " + modifiedContactModel.getIdentity());
+		public void onModified(final @NonNull String identity) {
 			if (resumePauseHandler != null) {
 				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_UPDATE_LIST, runIfActiveUpdateList);
 			}
@@ -384,26 +383,20 @@ public class ContactsSectionFragment
 
 		@Override
 		public void onAvatarChanged(ContactModel contactModel) {
-			logger.debug("onAvatarChanged -> onModified " + contactModel.getIdentity());
-			this.onModified(contactModel);
+			this.onModified(contactModel.getIdentity());
 		}
 
 		@Override
-		public void onNew(final ContactModel createdContactModel) {
-			if (resumePauseHandler != null) {
-				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_UPDATE_LIST, runIfActiveUpdateList);
-			}
+		public void onNew(final @NonNull String identity) {
+			this.onModified(identity);
 		}
 
 		@Override
-		public void onRemoved(ContactModel removedContactModel) {
-			RuntimeUtil.runOnUiThread(new Runnable() {
-				@Override
-				public void run() {
-					if (searchView != null && searchMenuItem != null && searchMenuItem.isActionViewExpanded()) {
-						filterQuery = null;
-						searchMenuItem.collapseActionView();
-					}
+		public void onRemoved(@NonNull String identity) {
+			RuntimeUtil.runOnUiThread(() -> {
+				if (searchView != null && searchMenuItem != null && searchMenuItem.isActionViewExpanded()) {
+					filterQuery = null;
+					searchMenuItem.collapseActionView();
 				}
 			});
 
@@ -411,11 +404,6 @@ public class ContactsSectionFragment
 				resumePauseHandler.runOnActive(RUN_ON_ACTIVE_UPDATE_LIST, runIfActiveUpdateList);
 			}
 		}
-
-		@Override
-		public boolean handle(String identity) {
-			return true;
-		}
 	};
 
 	private final PreferenceListener preferenceListener = new PreferenceListener() {
@@ -957,8 +945,8 @@ public class ContactsSectionFragment
 				@Override
 				public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
 					if (swipeRefreshLayout != null) {
-						if (view != null && view.getChildCount() > 0) {
-							swipeRefreshLayout.setEnabled(firstVisibleItem == 0 && view.getChildAt(0).getTop() == 0);
+						if (view != null && view.getChildCount() >= 0) {
+							swipeRefreshLayout.setEnabled(firstVisibleItem == 0);
 						} else {
 							swipeRefreshLayout.setEnabled(false);
 						}
@@ -1195,10 +1183,10 @@ public class ContactsSectionFragment
 
 			if (!ConfigUtils.isOnPremBuild()) {
 				if (
-					contactModel.getAndroidContactLookupKey() == null &&
+					!contactModel.isLinkedToAndroidContact() &&
 					TestUtil.empty(contactModel.getFirstName()) &&
 					TestUtil.empty(contactModel.getLastName()) &&
-					contactModel.getVerificationLevel() == VerificationLevel.UNVERIFIED
+					contactModel.verificationLevel == VerificationLevel.UNVERIFIED
 				) {
 					MessageReceiver messageReceiver = contactService.createReceiver(contactModel);
 					if (messageReceiver != null && messageReceiver.getMessagesCount() > 0) {
@@ -1262,7 +1250,7 @@ public class ContactsSectionFragment
 		}
 
 		for (ContactModel contactModel : contacts) {
-			if (contactModel.getAndroidContactLookupKey() != null) {
+			if (contactModel.isLinkedToAndroidContact()) {
 				return true;
 			}
 		}
@@ -1329,7 +1317,7 @@ public class ContactsSectionFragment
 		IdListService excludedService = serviceManager.getExcludedSyncIdentitiesService();
 		if (excludedService != null) {
 			for (ContactModel contactModel : contactModels) {
-				if (contactModel.getAndroidContactLookupKey() != null) {
+				if (contactModel.isLinkedToAndroidContact()) {
 					excludedService.add(contactModel.getIdentity());
 				}
 			}
@@ -1471,9 +1459,10 @@ public class ContactsSectionFragment
 							Toast.makeText(getContext(), R.string.spam_successfully_reported, Toast.LENGTH_LONG).show();
 						}
 
+						final String spammerIdentity = contactModel.getIdentity();
 						if (checked) {
-							ThreemaApplication.requireServiceManager().getBlackListService().add(contactModel.getIdentity());
-							ThreemaApplication.requireServiceManager().getExcludedSyncIdentitiesService().add(contactModel.getIdentity());
+							ThreemaApplication.requireServiceManager().getBlackListService().add(spammerIdentity);
+							ThreemaApplication.requireServiceManager().getExcludedSyncIdentitiesService().add(spammerIdentity);
 
 							try {
 								new EmptyOrDeleteConversationsAsyncTask(
@@ -1486,13 +1475,13 @@ public class ContactsSectionFragment
 									null,
 									() -> {
 										ListenerManager.conversationListeners.handle(ConversationListener::onModifiedAll);
-										ListenerManager.contactListeners.handle(listener -> listener.onModified(contactModel));
+										ListenerManager.contactListeners.handle(listener -> listener.onModified(spammerIdentity));
 									}).execute();
 							} catch (Exception e) {
 								logger.error("Unable to empty chat", e);
 							}
 						} else {
-							ListenerManager.contactListeners.handle(listener -> listener.onModified(contactModel));
+							ListenerManager.contactListeners.handle(listener -> listener.onModified(spammerIdentity));
 						}
 					},
 					message -> {

+ 22 - 24
app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java

@@ -385,29 +385,19 @@ public class MessageSectionFragment extends MainFragment
 
 	private final ContactListener contactListener = new ContactListener() {
 		@Override
-		public void onModified(ContactModel modifiedContactModel) {
-			logger.debug("contactListener.onModified [" + modifiedContactModel + "]");
-			refreshListEvent();
+		public void onModified(final @NonNull String identity) {
+			this.handleChange();
 		}
 
 		@Override
 		public void onAvatarChanged(ContactModel contactModel) {
-			this.onModified(contactModel); // TODO: Is this required?
-		}
-
-		@Override
-		public void onNew(ContactModel createdContactModel) {
-			//ignore
+			this.handleChange();
 		}
 
-		@Override
-		public void onRemoved(ContactModel removedContactModel) {
-			//ignore
-		}
-
-		@Override
-		public boolean handle(String identity) {
-			return currentFullSyncs <= 0;
+		public void handleChange() {
+			if (currentFullSyncs <= 0) {
+				refreshListEvent();
+			}
 		}
 	};
 
@@ -471,7 +461,7 @@ public class MessageSectionFragment extends MainFragment
 	public void onAttach(Activity activity) {
 		super.onAttach(activity);
 
-		logger.debug("onAttach");
+		logger.info("onAttach");
 
 		this.activity = activity;
 	}
@@ -480,7 +470,7 @@ public class MessageSectionFragment extends MainFragment
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
 
-		logger.debug("onCreate");
+		logger.info("onCreate");
 
 		setRetainInstance(true);
 		setHasOptionsMenu(true);
@@ -494,7 +484,7 @@ public class MessageSectionFragment extends MainFragment
 	public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
 		super.onViewCreated(view, savedInstanceState);
 
-		logger.debug("onViewCreated");
+		logger.info("onViewCreated");
 
 		try {
 			//show loading first
@@ -522,6 +512,8 @@ public class MessageSectionFragment extends MainFragment
 
 	@Override
 	public void onDestroyView() {
+		logger.info("onDestroyView");
+
 		searchView = null;
 
 		if (searchMenuItemRef != null && searchMenuItemRef.get() != null) {
@@ -1198,7 +1190,7 @@ public class MessageSectionFragment extends MainFragment
 	@Override
 	public void onPause() {
 		super.onPause();
-		logger.debug("*** onPause");
+		logger.info("*** onPause");
 
 		if (this.resumePauseHandler != null) {
 			this.resumePauseHandler.onPause();
@@ -1207,7 +1199,7 @@ public class MessageSectionFragment extends MainFragment
 
 	@Override
 	public void onResume() {
-		logger.debug("*** onResume");
+		logger.info("*** onResume");
 
 		if (this.resumePauseHandler != null) {
 			this.resumePauseHandler.onResume();
@@ -1518,9 +1510,15 @@ public class MessageSectionFragment extends MainFragment
 				final EmptyOrDeleteConversationsAsyncTask.Mode mode = tag.equals(DIALOG_TAG_REALLY_DELETE_CHAT)
 					? EmptyOrDeleteConversationsAsyncTask.Mode.DELETE
 					: EmptyOrDeleteConversationsAsyncTask.Mode.EMPTY;
+				MessageReceiver<?> receiver = conversationModel.getReceiver();
+				if (receiver != null) {
+					logger.info("{} chat with receiver {} (type={}).", mode, receiver.getUniqueIdString(), receiver.getType());
+				} else {
+					logger.warn("Cannot {} chat, receiver is null", mode);
+				}
 				new EmptyOrDeleteConversationsAsyncTask(
 					mode,
-					new MessageReceiver[]{ conversationModel.getReceiver() },
+					new MessageReceiver[]{ receiver },
 					conversationService,
 					groupService,
 					distributionListService,
@@ -1730,7 +1728,7 @@ public class MessageSectionFragment extends MainFragment
 			);
 		} else if (receiver instanceof ContactMessageReceiver) {
 			ListenerManager.contactListeners.handle(listener ->
-				listener.onModified(((ContactMessageReceiver) receiver).getContact())
+				listener.onModified(((ContactMessageReceiver) receiver).getContact().getIdentity())
 			);
 		} else if (receiver instanceof DistributionListMessageReceiver) {
 			ListenerManager.distributionListListeners.handle(listener ->

+ 1 - 1
app/src/main/java/ch/threema/app/fragments/WorkUserMemberListFragment.java

@@ -105,7 +105,7 @@ public class WorkUserMemberListFragment extends MemberListFragment {
 				}), new IPredicateNonNull<ContactModel>() {
 					@Override
 					public boolean apply(@NonNull ContactModel type) {
-						return type.isWork() && (!profilePics || !ContactUtil.isEchoEchoOrChannelContact(type));
+						return type.isWork() && (!profilePics || !ContactUtil.isEchoEchoOrGatewayContact(type));
 					}
 				});
 

+ 4 - 4
app/src/main/java/ch/threema/app/glide/ContactAvatarFetcher.kt

@@ -118,7 +118,7 @@ class ContactAvatarFetcher(
 
     private fun getProfilePicture(contactModel: ContactModel, highRes: Boolean): Bitmap? {
         try {
-            val result = fileService?.getContactPhoto(contactModel)
+            val result = fileService?.getContactPhoto(contactModel.identity)
             if (result != null && !highRes) {
                 return AvatarConverterUtil.convert(this.context.resources, result)
             }
@@ -130,7 +130,7 @@ class ContactAvatarFetcher(
 
     private fun getLocallySavedAvatar(contactModel: ContactModel, highRes: Boolean): Bitmap? {
         return try {
-            var result = fileService?.getContactAvatar(contactModel)
+            var result = fileService?.getContactAvatar(contactModel.identity)
             if (result != null && !highRes) {
                 result = AvatarConverterUtil.convert(this.context.resources, result)
             }
@@ -141,7 +141,7 @@ class ContactAvatarFetcher(
     }
 
     private fun getAndroidContactAvatar(contactModel: ContactModel, highRes: Boolean): Bitmap? {
-        if (ContactUtil.isChannelContact(contactModel) || AndroidContactUtil.getInstance().getAndroidContactUri(contactModel) == null) {
+        if (ContactUtil.isGatewayContact(contactModel) || AndroidContactUtil.getInstance().getAndroidContactUri(contactModel) == null) {
             return null
         }
         // regular contacts
@@ -159,7 +159,7 @@ class ContactAvatarFetcher(
     private fun buildDefaultAvatar(contactModel: ContactModel?, highRes: Boolean, backgroundColor: Int): Bitmap {
         val color = contactService?.getAvatarColor(contactModel)
             ?: ColorUtil.getInstance().getCurrentThemeGray(context)
-        val drawable = if (ContactUtil.isChannelContact(contactModel)) contactBusinessAvatar else contactDefaultAvatar
+        val drawable = if (ContactUtil.isGatewayContact(contactModel)) contactBusinessAvatar else contactDefaultAvatar
         return if (highRes) {
             buildDefaultAvatarHighRes(drawable, color, backgroundColor)
         } else {

+ 45 - 11
app/src/main/java/ch/threema/app/globalsearch/GlobalSearchAdapter.java

@@ -109,6 +109,7 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
 		private final TextView titleView;
 		private final TextView dateView;
 		private final TextView snippetView;
+		@Nullable private final TextView deletedPlaceholder; // will be null for layouts other than item_starred_messages
 		private final ImageView thumbnailView;
 		private final MaterialCardView messageBlock;
 		AvatarListItemHolder avatarListItemHolder;
@@ -119,6 +120,7 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
 			titleView = itemView.findViewById(R.id.name);
 			dateView = itemView.findViewById(R.id.date);
 			snippetView = itemView.findViewById(R.id.snippet);
+			deletedPlaceholder = itemView.findViewById(R.id.deleted_placeholder);
 			AvatarView avatarView = itemView.findViewById(R.id.avatar_view);
 			thumbnailView = itemView.findViewById(R.id.thumbnail_view);
 			messageBlock = itemView.findViewById(R.id.message_block);
@@ -178,6 +180,11 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
 				}
 			}
 
+			itemHolder.snippetView.setVisibility(View.VISIBLE);
+			if (itemHolder.deletedPlaceholder != null) {
+				itemHolder.deletedPlaceholder.setVisibility(View.GONE);
+			}
+
 			if (hiddenChatsListService.has(
 				current instanceof GroupMessageModel ?
 					groupService.getUniqueIdString(
@@ -193,9 +200,11 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
 				itemHolder.snippetView.setText(R.string.private_chat_subject);
 				itemHolder.avatarListItemHolder.avatarView.setVisibility(View.INVISIBLE);
 				itemHolder.avatarListItemHolder.avatarView.setBadgeVisible(false);
+			} else if (current.isDeleted()) {
+				// deleted placeholder for starred items - global search will never find deleted items
+				initDeletedViewHolder(itemHolder, current);
 			} else {
 				if (current instanceof GroupMessageModel) {
-					final ContactModel contactModel = current.isOutbox() ? this.contactService.getMe() : this.contactService.getByIdentity(current.getIdentity());
 					final GroupModel groupModel = groupService.getById(((GroupMessageModel) current).getGroupId());
 					AvatarListItemUtil.loadAvatar(
 						groupModel,
@@ -204,10 +213,7 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
 						requestManager
 					);
 
-					String groupName = NameUtil.getDisplayName(groupModel, groupService);
-					itemHolder.titleView.setText(
-						String.format("%s %s %s", NameUtil.getDisplayNameOrNickname(contactModel, true), FLOW_CHARACTER, groupName)
-					);
+					itemHolder.titleView.setText(getTitle((GroupMessageModel) current, groupModel));
 				} else {
 					final ContactModel contactModel = this.contactService.getByIdentity(current.getIdentity());
 					AvatarListItemUtil.loadAvatar(
@@ -217,12 +223,7 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
 						requestManager
 					);
 
-					String name = NameUtil.getDisplayNameOrNickname(context, current, contactService);
-					itemHolder.titleView.setText(
-						current.isOutbox() ?
-							name + " " + FLOW_CHARACTER + " " + NameUtil.getDisplayNameOrNickname(contactModel, true) :
-							name
-					);
+					itemHolder.titleView.setText(getTitle(current, contactModel));
 				}
 				itemHolder.dateView.setText(LocaleUtil.formatDateRelative(current.getCreatedAt().getTime()));
 
@@ -265,6 +266,39 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
 		}
 	}
 
+	private void initDeletedViewHolder(ItemHolder holder, AbstractMessageModel message) {
+		holder.snippetView.setVisibility(View.INVISIBLE);
+		holder.snippetView.setText("");
+
+		if (holder.deletedPlaceholder != null) {
+			holder.deletedPlaceholder.setVisibility(View.VISIBLE);
+			holder.deletedPlaceholder.setText(R.string.message_was_deleted);
+		}
+
+		holder.dateView.setText(LocaleUtil.formatDateRelative(message.getCreatedAt().getTime()));
+
+		if (message instanceof GroupMessageModel) {
+			final GroupModel groupModel = groupService.getById(((GroupMessageModel) message).getGroupId());
+			holder.titleView.setText(getTitle((GroupMessageModel) message, groupModel));
+		} else {
+			final ContactModel contactModel = this.contactService.getByIdentity(message.getIdentity());
+			holder.titleView.setText(getTitle(message, contactModel));
+		}
+	}
+
+	private String getTitle(AbstractMessageModel messageModel, ContactModel contactModel) {
+		String name = NameUtil.getDisplayNameOrNickname(context, messageModel, contactService);
+		return messageModel.isOutbox() ?
+			name + " " + FLOW_CHARACTER + " " + NameUtil.getDisplayNameOrNickname(contactModel, true) :
+			name;
+	}
+
+	private String getTitle(GroupMessageModel messageModel, GroupModel groupModel) {
+		final ContactModel contactModel = messageModel.isOutbox() ? this.contactService.getMe() : this.contactService.getByIdentity(messageModel.getIdentity());
+		String groupName = NameUtil.getDisplayName(groupModel, groupService);
+		return String.format("%s %s %s", NameUtil.getDisplayNameOrNickname(contactModel, true), FLOW_CHARACTER, groupName);
+	}
+
 	private void loadThumbnail(AbstractMessageModel messageModel, ItemHolder holder) {
 		@DrawableRes int placeholderIcon;
 

+ 5 - 11
app/src/main/java/ch/threema/app/listeners/ContactListener.java

@@ -22,18 +22,19 @@
 package ch.threema.app.listeners;
 
 import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
 import ch.threema.storage.models.ContactModel;
 
 public interface ContactListener {
 	/**
 	 * A new contact is added.
 	 */
-	@AnyThread default void onNew(final ContactModel createdContactModel) { }
+	@AnyThread default void onNew(final @NonNull String identity) { }
 
 	/**
-	 * Called when the contact is modified.
+	 * Called when the contact with the specified identity is modified.
 	 */
-	@AnyThread default void onModified(final ContactModel modifiedContactModel) { }
+	@AnyThread default void onModified(final @NonNull String identity) { }
 
 	/**
 	 * Called when the contact avatar was changed.
@@ -43,12 +44,5 @@ public interface ContactListener {
 	/**
 	 * The contact was removed.
 	 */
-	@AnyThread default void onRemoved(final ContactModel removedContactModel) { }
-
-	/**
-	 * Return true if the specified contact should be handled.
-	 */
-	@AnyThread default boolean handle(String identity) {
-		return true;
-	}
+	@AnyThread default void onRemoved(final @NonNull String identity) { }
 }

+ 6 - 9
app/src/main/java/ch/threema/app/voip/util/UnsignedHelper.java → app/src/main/java/ch/threema/app/listeners/EditMessageListener.kt

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema for Android
- * Copyright (c) 2020-2024 Threema GmbH
+ * 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,
@@ -19,13 +19,10 @@
  * along with this program. If not, see <https://www.gnu.org/licenses/>.
  */
 
-package ch.threema.app.voip.util;
+package ch.threema.app.listeners
 
-public class UnsignedHelper {
-	/**
-	 * Convert a signed int to an unsigned long.
-	 */
-	public static long getUnsignedInt(int val) {
-		return val & 0x00000000ffffffffL;
-	}
+import ch.threema.storage.models.AbstractMessageModel
+
+interface EditMessageListener {
+    fun onEdit(message: AbstractMessageModel)
 }

+ 5 - 13
domain/src/main/java/ch/threema/domain/models/IdentityType.java → app/src/main/java/ch/threema/app/listeners/MessageDeletedForAllListener.kt

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema for Android
- * Copyright (c) 2017-2024 Threema GmbH
+ * 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,
@@ -19,18 +19,10 @@
  * along with this program. If not, see <https://www.gnu.org/licenses/>.
  */
 
-package ch.threema.domain.models;
+package ch.threema.app.listeners
 
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
+import ch.threema.storage.models.AbstractMessageModel
 
-import androidx.annotation.IntDef;
-
-public class IdentityType {
-	public static final int NORMAL = 0;
-	public static final int WORK = 1;
-
-	@Retention(RetentionPolicy.SOURCE)
-	@IntDef({ NORMAL, WORK })
-	public @interface Type {}
+interface MessageDeletedForAllListener {
+    fun onDeletedForAll(message: AbstractMessageModel)
 }

+ 1 - 1
app/src/main/java/ch/threema/app/locationpicker/LocationAutocompleteActivity.java

@@ -38,7 +38,7 @@ import androidx.recyclerview.widget.LinearLayoutManager;
 
 import com.google.android.material.appbar.MaterialToolbar;
 import com.google.android.material.progressindicator.LinearProgressIndicator;
-import com.mapbox.mapboxsdk.geometry.LatLng;
+import org.maplibre.android.geometry.LatLng;
 
 import java.util.ArrayList;
 import java.util.List;

+ 40 - 36
app/src/main/java/ch/threema/app/locationpicker/LocationPickerActivity.java

@@ -59,19 +59,19 @@ import com.google.android.material.card.MaterialCardView;
 import com.google.android.material.progressindicator.CircularProgressIndicator;
 import com.google.android.material.snackbar.BaseTransientBottomBar;
 import com.mapbox.android.gestures.MoveGestureDetector;
-import com.mapbox.mapboxsdk.annotations.Marker;
-import com.mapbox.mapboxsdk.annotations.MarkerOptions;
-import com.mapbox.mapboxsdk.camera.CameraPosition;
-import com.mapbox.mapboxsdk.camera.CameraUpdate;
-import com.mapbox.mapboxsdk.camera.CameraUpdateFactory;
-import com.mapbox.mapboxsdk.geometry.LatLng;
-import com.mapbox.mapboxsdk.location.LocationComponent;
-import com.mapbox.mapboxsdk.location.LocationComponentActivationOptions;
-import com.mapbox.mapboxsdk.location.modes.CameraMode;
-import com.mapbox.mapboxsdk.location.modes.RenderMode;
-import com.mapbox.mapboxsdk.maps.MapView;
-import com.mapbox.mapboxsdk.maps.MapboxMap;
-import com.mapbox.mapboxsdk.maps.Style;
+import org.maplibre.android.annotations.Marker;
+import org.maplibre.android.annotations.MarkerOptions;
+import org.maplibre.android.camera.CameraPosition;
+import org.maplibre.android.camera.CameraUpdate;
+import org.maplibre.android.camera.CameraUpdateFactory;
+import org.maplibre.android.geometry.LatLng;
+import org.maplibre.android.location.LocationComponent;
+import org.maplibre.android.location.LocationComponentActivationOptions;
+import org.maplibre.android.location.modes.CameraMode;
+import org.maplibre.android.location.modes.RenderMode;
+import org.maplibre.android.maps.MapView;
+import org.maplibre.android.maps.MapLibreMap;
+import org.maplibre.android.maps.Style;
 
 import org.slf4j.Logger;
 
@@ -119,7 +119,7 @@ public class LocationPickerActivity extends ThreemaActivity implements
 	private PreferenceService preferenceService;
 
 	private MapView mapView;
-	private MapboxMap mapboxMap;
+	private MapLibreMap MapLibreMap;
 
 	private LocationManager locationManager;
 	private LocationComponent locationComponent;
@@ -343,9 +343,9 @@ public class LocationPickerActivity extends ThreemaActivity implements
 	}
 
 	private void initMap() {
-		mapView.getMapAsync(mapboxMap1 -> {
-			mapboxMap = mapboxMap1;
-			mapboxMap.setStyle(new Style.Builder().fromUri(MAP_STYLE_URL), style -> {
+		mapView.getMapAsync(MapLibreMap1 -> {
+			MapLibreMap = MapLibreMap1;
+			MapLibreMap.setStyle(new Style.Builder().fromUri(MAP_STYLE_URL), style -> {
 				// Map is set up and the style has loaded. Now you can add data or make other mapView adjustments
 				setupLocationComponent(style);
 				// Initialize map to world view (gets changed as soon as current location is available)
@@ -353,9 +353,9 @@ public class LocationPickerActivity extends ThreemaActivity implements
 				// hack: delay location query
 				mapView.postDelayed(this::zoomToCurrentLocation, 500);
 			});
-			mapboxMap.getUiSettings().setAttributionEnabled(false);
-			mapboxMap.getUiSettings().setLogoEnabled(false);
-			mapboxMap.setOnMarkerClickListener(marker -> {
+			MapLibreMap.getUiSettings().setAttributionEnabled(false);
+			MapLibreMap.getUiSettings().setLogoEnabled(false);
+			MapLibreMap.setOnMarkerClickListener(marker -> {
 				for (Poi poi : places) {
 					if (poi.getId() == Long.parseLong(marker.getSnippet())) {
 						returnData(poi);
@@ -364,7 +364,7 @@ public class LocationPickerActivity extends ThreemaActivity implements
 				}
 				return false;
 			});
-			mapboxMap.addOnMoveListener(new MapboxMap.OnMoveListener() {
+			MapLibreMap.addOnMoveListener(new MapLibreMap.OnMoveListener() {
 				@Override
 				public void onMoveBegin(@NonNull MoveGestureDetector detector) {}
 
@@ -376,7 +376,7 @@ public class LocationPickerActivity extends ThreemaActivity implements
 					updatePois();
 				}
 			});
-			mapboxMap.addOnCameraIdleListener(this::updatePois);
+			MapLibreMap.addOnCameraIdleListener(this::updatePois);
 		});
 	}
 
@@ -388,12 +388,16 @@ public class LocationPickerActivity extends ThreemaActivity implements
 			return;
 		}
 
-		locationComponent = mapboxMap.getLocationComponent();
+		locationComponent = MapLibreMap.getLocationComponent();
 		locationComponent.activateLocationComponent(LocationComponentActivationOptions.builder(this, style).build());
 		locationComponent.setCameraMode(CameraMode.TRACKING);
 		locationComponent.setRenderMode(RenderMode.COMPASS);
 		if (hasLocationPermission()) {
-			locationComponent.setLocationComponentEnabled(true);
+			try {
+				locationComponent.setLocationComponentEnabled(true);
+			} catch (Exception e) {
+				logger.error("Failed to obtain last location update", e);
+			}
 		}
 	}
 
@@ -416,7 +420,7 @@ public class LocationPickerActivity extends ThreemaActivity implements
 			protected void onPreExecute() {
 				startTime = System.currentTimeMillis();
 
-				markerList = mapboxMap.getMarkers();
+				markerList = MapLibreMap.getMarkers();
 			}
 
 			@Override
@@ -450,10 +454,10 @@ public class LocationPickerActivity extends ThreemaActivity implements
 				startTime = System.currentTimeMillis();
 				for (Map.Entry<Long, Marker> marker : poiMarkerMap.entrySet()) {
 					logger.debug("Remove marker {}", marker.getValue().getTitle());
-					mapboxMap.removeMarker(marker.getValue());
+					MapLibreMap.removeMarker(marker.getValue());
 				}
 				startTime = System.currentTimeMillis();
-				mapboxMap.addMarkers(markerOptionsList);
+				MapLibreMap.addMarkers(markerOptionsList);
 			}
 		}.execute();
 	}
@@ -528,7 +532,7 @@ public class LocationPickerActivity extends ThreemaActivity implements
 			getString(R.string.lp_use_this_location),
 			name,
 			latLng,
-			mapboxMap.getProjection().getVisibleRegion().latLngBounds,
+			MapLibreMap.getProjection().getVisibleRegion().latLngBounds,
 			(tag, object) -> reallyReturnData((Poi) object));
 
 		dialog.setData(poi);
@@ -549,7 +553,7 @@ public class LocationPickerActivity extends ThreemaActivity implements
 	}
 
 	private LatLng getMapCenterPosition() {
-		CameraPosition cameraPosition = mapboxMap.getCameraPosition();
+		CameraPosition cameraPosition = MapLibreMap.getCameraPosition();
 		if (cameraPosition != null && cameraPosition.target != null) {
 			return new LatLng(cameraPosition.target.getLatitude(), cameraPosition.target.getLongitude());
 		}
@@ -658,8 +662,8 @@ public class LocationPickerActivity extends ThreemaActivity implements
 				}
 				Location newLocation = IntentDataUtil.getLocation(data);
 
-				if (mapboxMap != null) {
-					int zoom = (int) (mapboxMap.getCameraPosition().zoom < 12 ? 12 : mapboxMap.getCameraPosition().zoom);
+				if (MapLibreMap != null) {
+					int zoom = (int) (MapLibreMap.getCameraPosition().zoom < 12 ? 12 : MapLibreMap.getCameraPosition().zoom);
 					moveCameraAndUpdatePOIs(new LatLng(newLocation.getLatitude(), newLocation.getLongitude()), false, zoom);
 				}
 			}
@@ -672,11 +676,11 @@ public class LocationPickerActivity extends ThreemaActivity implements
 		long time = System.currentTimeMillis();
 		logger.debug("moveCamera to {}", latLng);
 
-		mapboxMap.cancelTransitions();
-		mapboxMap.addOnCameraIdleListener(new MapboxMap.OnCameraIdleListener() {
+		MapLibreMap.cancelTransitions();
+		MapLibreMap.addOnCameraIdleListener(new MapLibreMap.OnCameraIdleListener() {
 			@Override
 			public void onCameraIdle() {
-				mapboxMap.removeOnCameraIdleListener(this);
+				MapLibreMap.removeOnCameraIdleListener(this);
 				RuntimeUtil.runOnUiThread(() -> {
 					logger.debug("camera has been moved. Time in ms = {}", (System.currentTimeMillis() - time));
 					updatePois();
@@ -693,9 +697,9 @@ public class LocationPickerActivity extends ThreemaActivity implements
 			CameraUpdateFactory.newLatLng(latLng);
 
 		if (animate) {
-			mapboxMap.animateCamera(cameraUpdate);
+			MapLibreMap.animateCamera(cameraUpdate);
 		} else {
-			mapboxMap.moveCamera(cameraUpdate);
+			MapLibreMap.moveCamera(cameraUpdate);
 		}
 	}
 

+ 2 - 2
app/src/main/java/ch/threema/app/locationpicker/LocationPickerConfirmDialog.java

@@ -28,8 +28,8 @@ import android.view.View;
 import android.widget.TextView;
 
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
-import com.mapbox.mapboxsdk.geometry.LatLng;
-import com.mapbox.mapboxsdk.geometry.LatLngBounds;
+import org.maplibre.android.geometry.LatLng;
+import org.maplibre.android.geometry.LatLngBounds;
 
 import org.slf4j.Logger;
 

+ 1 - 1
app/src/main/java/ch/threema/app/locationpicker/NearbyPoiUtil.java

@@ -21,7 +21,7 @@
 
 package ch.threema.app.locationpicker;
 
-import com.mapbox.mapboxsdk.geometry.LatLng;
+import org.maplibre.android.geometry.LatLng;
 
 import org.json.JSONArray;
 import org.json.JSONException;

+ 1 - 1
app/src/main/java/ch/threema/app/locationpicker/Poi.java

@@ -21,7 +21,7 @@
 
 package ch.threema.app.locationpicker;
 
-import com.mapbox.mapboxsdk.geometry.LatLng;
+import org.maplibre.android.geometry.LatLng;
 
 public class Poi {
 	private long id;

+ 1 - 1
app/src/main/java/ch/threema/app/locationpicker/PoiQuery.java

@@ -21,7 +21,7 @@
 
 package ch.threema.app.locationpicker;
 
-import com.mapbox.mapboxsdk.geometry.LatLng;
+import org.maplibre.android.geometry.LatLng;
 
 import androidx.annotation.Nullable;
 

+ 1 - 1
app/src/main/java/ch/threema/app/locationpicker/PoiRepository.java

@@ -26,7 +26,7 @@ import android.net.Uri;
 import android.os.AsyncTask;
 import android.widget.Toast;
 
-import com.mapbox.mapboxsdk.geometry.LatLng;
+import org.maplibre.android.geometry.LatLng;
 
 import org.json.JSONArray;
 import org.json.JSONException;

+ 77 - 0
app/src/main/java/ch/threema/app/managers/CoreServiceManager.kt

@@ -0,0 +1,77 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.managers
+
+import ch.threema.app.multidevice.MultiDeviceManagerImpl
+import ch.threema.app.stores.PreferenceStoreInterface
+import ch.threema.app.tasks.TaskArchiverImpl
+import ch.threema.app.utils.DeviceCookieManagerImpl
+import ch.threema.domain.models.AppVersion
+import ch.threema.domain.taskmanager.TaskManager
+import ch.threema.storage.DatabaseServiceNew
+
+/**
+ * The core service manager contains some core services that are used before the other services are
+ * instantiated. Note that some of the provided services must be further initialized before they can
+ * be used.
+ */
+interface CoreServiceManager {
+
+    /**
+     * The app version.
+     */
+    val version: AppVersion
+
+    /**
+     * The database service.
+     */
+    val databaseService: DatabaseServiceNew
+
+    /**
+     * The preference store
+     */
+    val preferenceStore: PreferenceStoreInterface
+
+    /**
+     * The task archiver. Note that this must only be used to load the persisted tasks when the
+     * service manager has been set.
+     */
+    val taskArchiver: TaskArchiverImpl
+
+    /**
+     * The device cookie manager. Note that this must only be used when the notification service is
+     * passed to it.
+     */
+    val deviceCookieManager: DeviceCookieManagerImpl
+
+    /**
+     * The task manager. Note that this must only be used to schedule tasks when the task archiver
+     * has access to the service manager.
+     */
+    val taskManager: TaskManager
+
+    /**
+     * The multi device manager.
+     */
+    val multiDeviceManager: MultiDeviceManagerImpl
+
+}

+ 97 - 0
app/src/main/java/ch/threema/app/managers/CoreServiceManagerImpl.kt

@@ -0,0 +1,97 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.managers
+
+import ch.threema.app.multidevice.MultiDeviceManagerImpl
+import ch.threema.app.services.ServerMessageService
+import ch.threema.app.services.ServerMessageServiceImpl
+import ch.threema.app.stores.PreferenceStoreInterface
+import ch.threema.app.tasks.TaskArchiverImpl
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.DeviceCookieManagerImpl
+import ch.threema.domain.models.AppVersion
+import ch.threema.domain.taskmanager.TaskManager
+import ch.threema.domain.taskmanager.TaskManagerConfiguration
+import ch.threema.domain.taskmanager.TaskManagerProvider
+import ch.threema.storage.DatabaseServiceNew
+
+/**
+ * The core service manager contains some core services that are used before the other services are
+ * instantiated. Note that some of the provided services must be further initialized before they can
+ * be used.
+ */
+class CoreServiceManagerImpl(
+    override val version: AppVersion,
+    override val databaseService: DatabaseServiceNew,
+    override val preferenceStore: PreferenceStoreInterface,
+) : CoreServiceManager {
+
+    /**
+     * The task archiver. Note that this must only be used to load the persisted tasks when the
+     * service manager has been set.
+     */
+    override val taskArchiver: TaskArchiverImpl by lazy {
+        TaskArchiverImpl(databaseService.taskArchiveFactory)
+    }
+
+    /**
+     * The device cookie manager. Note that this must only be used when the notification service is
+     * passed to it.
+     */
+    override val deviceCookieManager: DeviceCookieManagerImpl by lazy {
+        DeviceCookieManagerImpl(preferenceStore, databaseService)
+    }
+
+    /**
+     * The task manager. Note that this must only be used to schedule tasks when the task archiver
+     * has access to the service manager.
+     */
+    override val taskManager: TaskManager by lazy {
+        TaskManagerProvider.getTaskManager(
+            TaskManagerConfiguration(
+                { taskArchiver },
+                deviceCookieManager,
+                ConfigUtils.isDevBuild()
+            )
+        )
+    }
+
+    /**
+     * The server message service.
+     * TODO(ANDR-2604): Use this wherever server messages are used
+     */
+    private val serverMessageService: ServerMessageService by lazy {
+        ServerMessageServiceImpl(databaseService)
+    }
+
+    /**
+     * The multi device manager.
+     */
+    override val multiDeviceManager: MultiDeviceManagerImpl by lazy {
+        MultiDeviceManagerImpl(
+            preferenceStore,
+            serverMessageService,
+            version,
+        )
+    }
+
+}

+ 4 - 0
app/src/main/java/ch/threema/app/managers/ListenerManager.java

@@ -42,6 +42,8 @@ import ch.threema.app.listeners.ContactTypingListener;
 import ch.threema.app.listeners.ConversationListener;
 import ch.threema.app.listeners.DistributionListListener;
 import ch.threema.app.listeners.GroupListener;
+import ch.threema.app.listeners.MessageDeletedForAllListener;
+import ch.threema.app.listeners.EditMessageListener;
 import ch.threema.app.listeners.MessageListener;
 import ch.threema.app.listeners.MessagePlayerListener;
 import ch.threema.app.listeners.NewSyncedContactsListener;
@@ -175,6 +177,7 @@ public class ListenerManager {
 	public static final TypedListenerManager<DistributionListListener> distributionListListeners = new TypedListenerManager<DistributionListListener>();
 	public static final TypedListenerManager<GroupListener> groupListeners = new TypedListenerManager<GroupListener>();
 	public static final TypedListenerManager<MessageListener> messageListeners = new TypedListenerManager<MessageListener>();
+	public static final TypedListenerManager<MessageDeletedForAllListener> messageDeletedForAllListener = new TypedListenerManager<>();
 	public static final TypedListenerManager<PreferenceListener>  preferenceListeners = new TypedListenerManager<PreferenceListener>();
 	public static final TypedListenerManager<ServerMessageListener>  serverMessageListeners = new TypedListenerManager<ServerMessageListener>();
 	public static final TypedListenerManager<SynchronizeContactsListener>  synchronizeContactsListeners = new TypedListenerManager<SynchronizeContactsListener>();
@@ -193,4 +196,5 @@ public class ListenerManager {
 	public static final TypedListenerManager<GroupJoinResponseListener> groupJoinResponseListener = new TypedListenerManager<>();
 	public static final TypedListenerManager<IncomingGroupJoinRequestListener> incomingGroupJoinRequestListener = new TypedListenerManager<>();
 	public static final TypedListenerManager<ContactCountListener> contactCountListener = new TypedListenerManager<>();
+	public static final TypedListenerManager<EditMessageListener> editMessageListener = new TypedListenerManager<>();
 }

+ 79 - 109
app/src/main/java/ch/threema/app/managers/ServiceManager.java

@@ -23,15 +23,14 @@ package ch.threema.app.managers;
 
 import android.content.Context;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
 import org.slf4j.Logger;
 
 import java.nio.charset.StandardCharsets;
 import java.util.Locale;
 import java.util.concurrent.TimeUnit;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.WorkerThread;
 import ch.threema.app.BuildFlavor;
 import ch.threema.app.ThreemaApplication;
@@ -45,10 +44,9 @@ import ch.threema.app.emojis.EmojiService;
 import ch.threema.app.emojis.search.EmojiSearchIndex;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.exceptions.NoIdentityException;
+import ch.threema.app.multidevice.MultiDeviceManager;
 import ch.threema.app.multidevice.linking.DeviceJoinDataCollector;
 import ch.threema.app.processors.IncomingMessageProcessorImpl;
-import ch.threema.app.multidevice.MultiDeviceManager;
-import ch.threema.app.multidevice.MultiDeviceManagerImpl;
 import ch.threema.app.services.ActivityService;
 import ch.threema.app.services.ApiService;
 import ch.threema.app.services.ApiServiceImpl;
@@ -127,12 +125,10 @@ import ch.threema.app.stores.AuthTokenStore;
 import ch.threema.app.stores.DatabaseContactStore;
 import ch.threema.app.stores.IdentityStore;
 import ch.threema.app.stores.PreferenceStoreInterface;
-import ch.threema.app.tasks.TaskArchiverImpl;
 import ch.threema.app.tasks.TaskCreator;
 import ch.threema.app.threemasafe.ThreemaSafeService;
 import ch.threema.app.threemasafe.ThreemaSafeServiceImpl;
 import ch.threema.app.utils.ConfigUtils;
-import ch.threema.app.utils.DeviceCookieManagerImpl;
 import ch.threema.app.utils.DeviceIdUtil;
 import ch.threema.app.utils.ForwardSecurityStatusSender;
 import ch.threema.app.utils.LazyProperty;
@@ -148,20 +144,17 @@ import ch.threema.base.crypto.NonceFactory;
 import ch.threema.base.crypto.SymmetricEncryptionService;
 import ch.threema.base.utils.Base64;
 import ch.threema.base.utils.LoggingUtil;
-import ch.threema.domain.protocol.Version;
+import ch.threema.data.repositories.ModelRepositories;
 import ch.threema.domain.protocol.api.APIConnector;
 import ch.threema.domain.protocol.connection.ConvertibleServerConnection;
 import ch.threema.domain.protocol.connection.ServerConnection;
-import ch.threema.domain.protocol.csp.ProtocolDefines;
 import ch.threema.domain.protocol.connection.csp.DeviceCookieManager;
+import ch.threema.domain.protocol.csp.ProtocolDefines;
 import ch.threema.domain.protocol.csp.fs.ForwardSecurityMessageProcessor;
 import ch.threema.domain.stores.DHSessionStoreInterface;
-import ch.threema.domain.taskmanager.TaskArchiver;
-import ch.threema.domain.taskmanager.IncomingMessageProcessor;
 import ch.threema.domain.taskmanager.ActiveTaskCodec;
+import ch.threema.domain.taskmanager.IncomingMessageProcessor;
 import ch.threema.domain.taskmanager.TaskManager;
-import ch.threema.domain.taskmanager.TaskManagerConfiguration;
-import ch.threema.domain.taskmanager.TaskManagerProvider;
 import ch.threema.localcrypto.MasterKey;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.DatabaseNonceStore;
@@ -173,18 +166,14 @@ public class ServiceManager {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ServiceManager");
 
 	@NonNull
-	private final Version version;
+	private final CoreServiceManager coreServiceManager;
 	@NonNull
 	private final Supplier<Boolean> isIpv6Preferred;
-	@Nullable
-	private DeviceCookieManager deviceCookieManager;
 	@NonNull
 	private final IdentityStore identityStore;
 	@NonNull
 	private final MasterKey masterKey;
 	@NonNull
-	private final PreferenceStoreInterface preferenceStore;
-	@NonNull
 	private final UpdateSystemService updateSystemService;
 	@NonNull
 	private final CacheService cacheService;
@@ -265,6 +254,8 @@ public class ServiceManager {
 	private BackupChatService backupChatService;
 	@NonNull
 	private final DatabaseServiceNew databaseServiceNew;
+	@NonNull
+	private final ModelRepositories modelRepositories;
 	@Nullable
 	private SensorService sensorService;
 	@Nullable
@@ -297,46 +288,40 @@ public class ServiceManager {
 	@Nullable
 	private NonceFactory nonceFactory;
 
-	@NonNull
-	private final TaskManager taskManager;
-
 	@Nullable
 	private TaskCreator taskCreator;
 
 	@Nullable
-	TaskArchiver taskArchiver;
-
-	@Nullable
-	private MultiDeviceManager multiDeviceManager;
+	private DeviceJoinDataCollector deviceJoinDataCollector;
 
 	@NonNull
 	private final ConvertibleServerConnection connection;
 	@NonNull
 	private final LazyProperty<OkHttpClient> okHttpClient = new LazyProperty<>(this::createOkHttpClient);
 
-	// TODO(ANDR-2604): Use this wherever server messages are used
-	@NonNull
-	private final LazyProperty<ServerMessageService> serverMessageService = new LazyProperty<>(this::createServerMessageService);
-
 	public ServiceManager(
-		@NonNull Version version,
-		@NonNull DatabaseServiceNew databaseServiceNew,
+		@NonNull ModelRepositories modelRepositories,
 		@NonNull DHSessionStoreInterface dhSessionStore,
 		@NonNull IdentityStore identityStore,
-		@NonNull PreferenceStoreInterface preferenceStore,
 		@NonNull MasterKey masterKey,
-		@NonNull UpdateSystemService updateSystemService) throws ThreemaException {
+		@NonNull CoreServiceManager coreServiceManager,
+		@NonNull UpdateSystemService updateSystemService
+	) throws ThreemaException {
 		this.cacheService = new CacheService();
-		this.version = version;
-		this.preferenceStore = preferenceStore;
+		this.coreServiceManager = coreServiceManager;
 		this.isIpv6Preferred = new LazyProperty<>(() -> getPreferenceService().isIpv6Preferred());
 		this.identityStore = identityStore;
 		this.masterKey = masterKey;
-		this.databaseServiceNew = databaseServiceNew;
+		this.databaseServiceNew = coreServiceManager.getDatabaseService();
+		this.modelRepositories = modelRepositories;
 		this.dhSessionStore = dhSessionStore;
 		this.updateSystemService = updateSystemService;
-		this.taskManager = TaskManagerProvider.getTaskManager(getTaskManagerConfiguration());
+		// Finalize initialization of task archiver and device cookie manager before the connection
+		// is created.
+		coreServiceManager.getTaskArchiver().setServiceManager(this);
+		coreServiceManager.getDeviceCookieManager().setNotificationService(getNotificationService());
 		this.connection = createServerConnection();
+		coreServiceManager.getMultiDeviceManager().setReconnectHandle(connection);
 	}
 
 	@NonNull
@@ -405,7 +390,7 @@ public class ServiceManager {
 
 	@NonNull
 	public PreferenceStoreInterface getPreferenceStore() {
-		return preferenceStore;
+		return coreServiceManager.getPreferenceStore();
 	}
 
 	/**
@@ -439,7 +424,7 @@ public class ServiceManager {
 			try {
 				this.userService = new UserServiceImpl(
 						this.getContext(),
-						this.preferenceStore,
+						this.coreServiceManager.getPreferenceStore(),
 						this.getLocaleService(),
 						this.getAPIConnector(),
 						this.getIdentityStore(),
@@ -454,33 +439,32 @@ public class ServiceManager {
 		return this.userService;
 	}
 
-	@NonNull
-	public ContactService getContactService() throws MasterKeyLockedException, FileSystemNotPresentException {
+	public @NonNull ContactService getContactService() throws MasterKeyLockedException, FileSystemNotPresentException {
 		if (this.contactService == null) {
 			if(this.masterKey.isLocked()) {
 				throw new MasterKeyLockedException("master key is locked");
 			}
 			this.contactService = new ContactServiceImpl(
-					this.getContext(),
-					this.getContactStore(),
-					this.getAvatarCacheService(),
-					this.databaseServiceNew,
-					this.getDeviceService(),
-					this.getUserService(),
-					this.getNonceFactory(),
-					this.getIdentityStore(),
-					this.getPreferenceService(),
-					this.getBlackListService(),
-					this.getProfilePicRecipientsService(),
-					this.getRingtoneService(),
-					this.getMutedChatsListService(),
-					this.getHiddenChatsListService(),
-					this.getFileService(),
-					this.cacheService,
-					this.getApiService(),
-					this.getWallpaperService(),
-					this.getLicenseService(),
-					this.getAPIConnector()
+				this.getContext(),
+				this.getContactStore(),
+				this.getAvatarCacheService(),
+				this.databaseServiceNew,
+				this.getDeviceService(),
+				this.getUserService(),
+				this.getIdentityStore(),
+				this.getPreferenceService(),
+				this.getBlackListService(),
+				this.getProfilePicRecipientsService(),
+				this.getRingtoneService(),
+				this.getMutedChatsListService(),
+				this.getHiddenChatsListService(),
+				this.getFileService(),
+				this.cacheService,
+				this.getApiService(),
+				this.getWallpaperService(),
+				this.getLicenseService(),
+				this.getAPIConnector(),
+				this.getModelRepositories().getContacts()
 			);
 		}
 
@@ -517,7 +501,7 @@ public class ServiceManager {
 		if (this.preferencesService == null) {
 			this.preferencesService = new PreferenceServiceImpl(
 					this.getContext(),
-					this.preferenceStore
+					this.coreServiceManager.getPreferenceStore()
 			);
 		}
 		return this.preferencesService;
@@ -823,19 +807,20 @@ public class ServiceManager {
 	public SynchronizeContactsService getSynchronizeContactsService() throws MasterKeyLockedException, FileSystemNotPresentException {
 		if(this.synchronizeContactsService == null) {
 			this.synchronizeContactsService = new SynchronizeContactsServiceImpl(
-					this.getContext(),
-					this.getAPIConnector(),
-					this.getContactService(),
-					this.getUserService(),
-					this.getLocaleService(),
-					this.getExcludedSyncIdentitiesService(),
-					this.getPreferenceService(),
-					this.getDeviceService(),
-					this.getFileService(),
-					this.getIdentityStore(),
-					this.getBlackListService(),
-					this.getLicenseService(),
-					this.getApiService()
+				this.getContext(),
+				this.getAPIConnector(),
+				this.getContactService(),
+				this.getModelRepositories().getContacts(),
+				this.getUserService(),
+				this.getLocaleService(),
+				this.getExcludedSyncIdentitiesService(),
+				this.getPreferenceService(),
+				this.getDeviceService(),
+				this.getFileService(),
+				this.getIdentityStore(),
+				this.getBlackListService(),
+				this.getLicenseService(),
+				this.getApiService()
 			);
 		}
 
@@ -957,6 +942,7 @@ public class ServiceManager {
 				this.getProfilePicRecipientsService(),
 				this.getDatabaseServiceNew(),
 				this.getIdentityStore(),
+				this.getApiService(),
 				this.getAPIConnector(),
 				this.getHiddenChatsListService(),
 				this.getServerAddressProviderService().getServerAddressProvider(),
@@ -1080,6 +1066,11 @@ public class ServiceManager {
 		return this.databaseServiceNew;
 	}
 
+	@NonNull
+	public ModelRepositories getModelRepositories() {
+		return this.modelRepositories;
+	}
+
 	@NonNull
 	public DHSessionStoreInterface getDHSessionStore() {
 		return this.dhSessionStore;
@@ -1187,7 +1178,7 @@ public class ServiceManager {
 	}
 
 	public @NonNull TaskManager getTaskManager() {
-		return this.taskManager;
+		return this.coreServiceManager.getTaskManager();
 	}
 
 	public @NonNull TaskCreator getTaskCreator() {
@@ -1197,28 +1188,18 @@ public class ServiceManager {
 		return this.taskCreator;
 	}
 
-	public @NonNull TaskArchiver getTaskArchiver() {
-		if (this.taskArchiver == null) {
-			this.taskArchiver = new TaskArchiverImpl(this);
-		}
-		return this.taskArchiver;
+	@NonNull
+	public MultiDeviceManager getMultiDeviceManager() {
+		return this.coreServiceManager.getMultiDeviceManager();
 	}
 
 	@NonNull
-	public MultiDeviceManager getMultiDeviceManager() {
-		if (multiDeviceManager == null) {
-			DeviceJoinDataCollector dataCollector = new DeviceJoinDataCollector(this);
-			multiDeviceManager = new MultiDeviceManagerImpl(
-				// Due to a circular dependency `connection` is not initialized at this point
-				// we use a proxying lambda function.
-				this::reconnectConnection,
-				getPreferenceStore(),
-				serverMessageService.get(),
-				version,
-				dataCollector
-			);
+	public DeviceJoinDataCollector getDeviceJoinDataCollector() {
+		if (deviceJoinDataCollector == null) {
+			deviceJoinDataCollector = new DeviceJoinDataCollector(this);
+			return deviceJoinDataCollector;
 		}
-		return multiDeviceManager;
+		return deviceJoinDataCollector;
 	}
 
 	/**
@@ -1235,24 +1216,16 @@ public class ServiceManager {
 		return getTaskManager().getMigrationTaskHandle();
 	}
 
-	private @NonNull TaskManagerConfiguration getTaskManagerConfiguration() throws ThreemaException {
-		return new TaskManagerConfiguration(
-			getIncomingMessageProcessor(),
-			this::getTaskArchiver,
-			getDeviceCookieManager(),
-			ConfigUtils.isDevBuild()
-		);
-	}
-
 	@NonNull
-	private ConvertibleServerConnection createServerConnection() {
+	private ConvertibleServerConnection createServerConnection() throws ThreemaException {
 		Supplier<ServerConnection> connectionSupplier = new CspD2mDualConnectionSupplier(
 			getMultiDeviceManager(),
+			getIncomingMessageProcessor(),
 			getTaskManager(),
 			getDeviceCookieManager(),
 			getServerAddressProviderService(),
 			getIdentityStore(),
-			version,
+			coreServiceManager.getVersion(),
 			isIpv6Preferred.get(),
 			okHttpClient,
 			ConfigUtils.isDevBuild()
@@ -1262,10 +1235,7 @@ public class ServiceManager {
 
 	@NonNull
 	public DeviceCookieManager getDeviceCookieManager() {
-		if (deviceCookieManager == null) {
-			deviceCookieManager = new DeviceCookieManagerImpl(this);
-		}
-		return deviceCookieManager;
+		return coreServiceManager.getDeviceCookieManager();
 	}
 
 	@NonNull

+ 17 - 9
app/src/main/java/ch/threema/app/mediaattacher/MediaAttachActivity.java

@@ -56,7 +56,6 @@ import androidx.annotation.NonNull;
 import androidx.annotation.UiThread;
 import androidx.constraintlayout.widget.ConstraintLayout;
 import androidx.core.app.ActivityCompat;
-import androidx.core.content.ContextCompat;
 
 import com.google.android.material.bottomsheet.BottomSheetBehavior;
 
@@ -268,6 +267,11 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 			this.attachGalleryButton.setVisibility(View.GONE);
 		}
 
+		// If gl es version 3.0 or newer is not supported, we cannot send a location
+		if (ConfigUtils.getSupportedGlEsVersion(this) < 3.0) {
+			this.attachLocationButton.setVisibility(View.GONE);
+		}
+
 		if (messageReceiver instanceof DistributionListMessageReceiver ||
 			(messageReceiver instanceof GroupMessageReceiver && groupService != null && groupService.isNotesGroup(((GroupMessageReceiver) messageReceiver).getGroup()))) {
 			this.attachBallotButton.setVisibility(View.GONE);
@@ -366,18 +370,22 @@ public class MediaAttachActivity extends MediaSelectionBaseActivity implements V
 				0,
 				0,
 				0);
+			final int animDelayDiff = 25;
+			int animDelay = animDelayDiff;
 			if (attachGalleryButton.getVisibility() == View.VISIBLE) {
-				AnimationUtil.bubbleAnimate(attachGalleryButton, 25);
+				AnimationUtil.bubbleAnimate(attachGalleryButton, animDelay);
+			}
+			AnimationUtil.bubbleAnimate(attachFileButton, animDelay += animDelayDiff);
+			if (attachLocationButton.getVisibility() == View.VISIBLE) {
+				AnimationUtil.bubbleAnimate(attachLocationButton, animDelay += animDelayDiff);
 			}
-			AnimationUtil.bubbleAnimate(attachFileButton, 25);
-			AnimationUtil.bubbleAnimate(attachLocationButton, 50);
 			if (attachBallotButton.getVisibility() == View.VISIBLE) {
-				AnimationUtil.bubbleAnimate(attachBallotButton, 50);
+				AnimationUtil.bubbleAnimate(attachBallotButton, animDelay += animDelayDiff);
 			}
-			AnimationUtil.bubbleAnimate(attachContactButton, 75);
-			AnimationUtil.bubbleAnimate(attachDrawingButton, 75);
-			AnimationUtil.bubbleAnimate(attachQRButton, 75);
-			AnimationUtil.bubbleAnimate(attachFromExternalCameraButton, 100);
+			AnimationUtil.bubbleAnimate(attachContactButton, animDelay += animDelayDiff);
+			AnimationUtil.bubbleAnimate(attachDrawingButton, animDelay += animDelayDiff);
+			AnimationUtil.bubbleAnimate(attachQRButton, animDelay += animDelayDiff);
+			AnimationUtil.bubbleAnimate(attachFromExternalCameraButton, animDelay + animDelayDiff);
 		}
 	}
 

+ 15 - 4
app/src/main/java/ch/threema/app/messagereceiver/ContactMessageReceiver.java

@@ -40,6 +40,8 @@ import ch.threema.app.services.ContactService;
 import ch.threema.app.services.IdListService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.stores.IdentityStore;
+import ch.threema.app.tasks.OutgoingContactDeleteMessageTask;
+import ch.threema.app.tasks.OutgoingContactEditMessageTask;
 import ch.threema.app.tasks.OutgoingPollSetupMessageTask;
 import ch.threema.app.tasks.OutgoingPollVoteContactMessageTask;
 import ch.threema.app.tasks.OutgoingContactDeliveryReceiptMessageTask;
@@ -411,6 +413,18 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 		);
 	}
 
+	public void sendEditMessage(int messageId, @NonNull String newText, @NonNull Date editedAt) {
+		scheduleTask(
+			new OutgoingContactEditMessageTask(contactModel.getIdentity(), messageId, newText, editedAt, serviceManager)
+		);
+	}
+
+	public void sendDeleteMessage(int messageId, @NonNull Date deletedAt) {
+		scheduleTask(
+			new OutgoingContactDeleteMessageTask(contactModel.getIdentity(), messageId, deletedAt, serviceManager)
+		);
+	}
+
 	@Override
 	public List<MessageModel> loadMessages(MessageService.MessageFilter filter) {
 		return databaseServiceNew.getMessageModelFactory().find(
@@ -563,10 +577,7 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 
 	@Override
 	public void bumpLastUpdate() {
-		String identity = contactModel.getIdentity();
-		if (identity != null) {
-			contactService.bumpLastUpdate(identity);
-		}
+		contactService.bumpLastUpdate(contactModel.getIdentity());
 	}
 
 	@Override

+ 34 - 0
app/src/main/java/ch/threema/app/messagereceiver/GroupMessageReceiver.java

@@ -24,6 +24,7 @@ package ch.threema.app.messagereceiver;
 import android.content.Intent;
 import android.graphics.Bitmap;
 
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Date;
 import java.util.HashSet;
@@ -31,6 +32,7 @@ import java.util.List;
 import java.util.Objects;
 import java.util.Set;
 import java.util.UUID;
+import java.util.stream.Collectors;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -39,15 +41,20 @@ import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.tasks.OutgoingFileMessageTask;
+import ch.threema.app.tasks.OutgoingGroupDeleteMessageTask;
+import ch.threema.app.tasks.OutgoingGroupEditMessageTask;
 import ch.threema.app.tasks.OutgoingLocationMessageTask;
 import ch.threema.app.tasks.OutgoingPollSetupMessageTask;
 import ch.threema.app.tasks.OutgoingPollVoteGroupMessageTask;
 import ch.threema.app.tasks.OutgoingTextMessageTask;
+import ch.threema.app.utils.GroupUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.crypto.SymmetricEncryptionResult;
 import ch.threema.base.utils.Utils;
+import ch.threema.domain.models.Contact;
 import ch.threema.domain.models.MessageId;
+import ch.threema.domain.protocol.ThreemaFeature;
 import ch.threema.domain.protocol.csp.messages.ballot.BallotData;
 import ch.threema.domain.protocol.csp.messages.ballot.BallotId;
 import ch.threema.domain.protocol.csp.messages.ballot.BallotVote;
@@ -278,6 +285,33 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 		));
 	}
 
+	public void sendEditMessage(int messageId, @NonNull String body, @NonNull Date editedAt) {
+		taskManager.schedule(
+			new OutgoingGroupEditMessageTask(
+				messageId,
+				body,
+				editedAt,
+				GroupUtil.getRecipientIdentitiesByFeatureSupport(
+					groupService.getFeatureSupport(group, ThreemaFeature.EDIT_MESSAGES)
+				),
+				serviceManager
+			)
+		);
+	}
+
+	public void sendDeleteMessage(int messageId, @NonNull Date deletedAt) {
+		taskManager.schedule(
+			new OutgoingGroupDeleteMessageTask(
+				messageId,
+				deletedAt,
+				GroupUtil.getRecipientIdentitiesByFeatureSupport(
+					groupService.getFeatureSupport(group, ThreemaFeature.DELETE_MESSAGES)
+				),
+				serviceManager
+			)
+		);
+	}
+
 	@Override
 	public List<GroupMessageModel> loadMessages(MessageService.MessageFilter filter) {
 		return databaseServiceNew.getGroupMessageModelFactory().find(

+ 1 - 1
app/src/main/java/ch/threema/app/multidevice/LinkedDevicesActivity.kt

@@ -59,7 +59,7 @@ class LinkedDevicesActivity : ThreemaToolbarActivity() {
     private val qrScanner = QRScannerUtil.prepareScanner(this) {
         if (it?.isNotEmpty() == true) {
             logger.debug("Got device link data: {}", it)
-            viewModel.linkDevice(it)
+            viewModel.linkDevice(it, serviceManager.deviceJoinDataCollector)
         }
     }
 

+ 3 - 2
app/src/main/java/ch/threema/app/multidevice/LinkedDevicesViewModel.kt

@@ -25,6 +25,7 @@ import androidx.annotation.AnyThread
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import ch.threema.app.ThreemaApplication.requireServiceManager
+import ch.threema.app.multidevice.linking.DeviceJoinDataCollector
 import ch.threema.domain.protocol.connection.d2m.socket.D2mSocketCloseReason
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
@@ -56,9 +57,9 @@ class LinkedDevicesViewModel : ViewModel() {
     }
 
     @AnyThread
-    fun linkDevice(deviceJoinOfferUri: String) {
+    fun linkDevice(deviceJoinOfferUri: String, deviceJoinDataCollector: DeviceJoinDataCollector) {
         CoroutineScope(Dispatchers.Default).launch {
-            mdManager.linkDevice(deviceJoinOfferUri)
+            mdManager.linkDevice(deviceJoinOfferUri, deviceJoinDataCollector)
             emitStates()
         }
     }

+ 6 - 2
app/src/main/java/ch/threema/app/multidevice/MultiDeviceManager.kt

@@ -24,6 +24,7 @@ package ch.threema.app.multidevice
 
 import androidx.annotation.AnyThread
 import androidx.annotation.WorkerThread
+import ch.threema.app.multidevice.linking.DeviceJoinDataCollector
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.UserService
 import ch.threema.domain.protocol.connection.d2m.MultiDevicePropertyProvider
@@ -54,7 +55,7 @@ interface MultiDeviceManager {
         taskManager: TaskManager, // TODO(ANDR-2519): Remove
         contactService: ContactService, // TODO(ANDR-2519): remove
         userService: UserService, // TODO(ANDR-2519): remove
-        fsMessageProcessor: ForwardSecurityMessageProcessor // TODO(ANDR-2519): remove
+        fsMessageProcessor: ForwardSecurityMessageProcessor, // TODO(ANDR-2519): remove
     )
 
     @WorkerThread
@@ -68,5 +69,8 @@ interface MultiDeviceManager {
     suspend fun setDeviceLabel(deviceLabel: String)
 
     @AnyThread
-    suspend fun linkDevice(deviceJoinOfferUri: String)
+    suspend fun linkDevice(
+        deviceJoinOfferUri: String,
+        deviceJoinDataCollector: DeviceJoinDataCollector,
+    )
 }

+ 15 - 8
app/src/main/java/ch/threema/app/multidevice/MultiDeviceManagerImpl.kt

@@ -77,12 +77,12 @@ private val logger = LoggingUtil.getThreemaLogger("MultiDeviceManagerImpl")
 private const val IS_FS_SUPPORTED_WITH_MD = true // TODO(ANDR-2519): Remove when md supports fs
 
 class MultiDeviceManagerImpl(
-    private val reconnectHandle: ReconnectableServerConnection,
     private val preferenceStore: PreferenceStoreInterface,
     private val serverMessageService: ServerMessageService,
     private val version: Version,
-    private val deviceJoinDataCollector: DeviceJoinDataCollector
-    ) : MultiDeviceManager {
+) : MultiDeviceManager {
+
+    private var reconnectHandle: ReconnectableServerConnection? = null
 
     private var _persistedProperties: PersistedMultiDeviceProperties? = null
     private var _properties = MutableSharedFlow<MultiDeviceProperties?>(1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
@@ -95,6 +95,10 @@ class MultiDeviceManagerImpl(
         }
     }
 
+    fun setReconnectHandle(reconnectHandle: ReconnectableServerConnection) {
+        this.reconnectHandle = reconnectHandle
+    }
+
     /**
      * Setting [persistedProperties] with a new value will also
      *  - persist the properties
@@ -141,7 +145,7 @@ class MultiDeviceManagerImpl(
         taskManager: TaskManager,
         contactService: ContactService,
         userService: UserService,
-        fsMessageProcessor: ForwardSecurityMessageProcessor
+        fsMessageProcessor: ForwardSecurityMessageProcessor,
     ) {
         logger.info("Activate multi device")
         if (!BuildConfig.MD_ENABLED) {
@@ -205,7 +209,10 @@ class MultiDeviceManagerImpl(
     }
 
     @AnyThread
-    override suspend fun linkDevice(deviceJoinOfferUri: String) {
+    override suspend fun linkDevice(
+        deviceJoinOfferUri: String,
+        deviceJoinDataCollector: DeviceJoinDataCollector,
+    ) {
         logger.debug("Link device: {}", deviceJoinOfferUri)
 
         _linkedDevices.add(deviceJoinOfferUri)
@@ -213,7 +220,7 @@ class MultiDeviceManagerImpl(
 
         return try {
             val deviceJoinData = withContext(Dispatchers.Default) {
-                collectDeviceJoinData()
+                collectDeviceJoinData(deviceJoinDataCollector)
             }
             deviceJoinData.essentialData.toString().lines().forEach {
                 logger.debug("Essential data: {}", it)
@@ -228,7 +235,7 @@ class MultiDeviceManagerImpl(
     }
 
     @WorkerThread
-    private fun collectDeviceJoinData(): DeviceJoinData {
+    private fun collectDeviceJoinData(deviceJoinDataCollector: DeviceJoinDataCollector): DeviceJoinData {
         // TODO(ANDR-2484): Make sure the state of the data cannot change during collection:
         //  - disconnect from server
         //  - do not perform any api calls?
@@ -328,7 +335,7 @@ class MultiDeviceManagerImpl(
 
     private fun reconnect() {
         CoroutineScope(Dispatchers.Default).launch {
-            reconnectHandle.reconnect()
+            reconnectHandle?.reconnect() ?: logger.error("Reconnect handle is null")
         }
     }
 

+ 5 - 6
app/src/main/java/ch/threema/app/multidevice/linking/DeviceJoinDataCollector.kt

@@ -83,7 +83,6 @@ import ch.threema.storage.models.ContactModel
 import ch.threema.storage.models.DistributionListModel
 import ch.threema.storage.models.GroupModel
 import com.google.protobuf.ByteString
-import java.lang.IllegalStateException
 import java.nio.ByteBuffer
 
 private val logger = LoggingUtil.getThreemaLogger("DeviceJoinDataCollector")
@@ -460,7 +459,7 @@ class DeviceJoinDataCollector(
 
     private fun collectSyncState(contactModel: ContactModel): SyncState {
         // TODO(ANDR-2327): Consolidate this mechanism
-        return if (contactModel.androidContactLookupKey != null) {
+        return if (contactModel.isLinkedToAndroidContact) {
             SyncState.IMPORTED
         } else  if (contactModel.lastName.isNullOrBlank() && contactModel.firstName.isNullOrBlank()) {
             SyncState.INITIAL
@@ -502,16 +501,16 @@ class DeviceJoinDataCollector(
     }
 
     private fun collectContactDefinedProfilePicture(contactModel: ContactModel): Pair<BlobDataProvider, DeltaImage>? {
-        return if (fileService.hasContactPhotoFile(contactModel)) {
-            createJpegBlobAssets { fileService.getContactPhoto(contactModel) }
+        return if (fileService.hasContactPhotoFile(contactModel.identity)) {
+            createJpegBlobAssets { fileService.getContactPhoto(contactModel.identity) }
         } else {
             null
         }
     }
 
     private fun collectUserDefinedProfilePicture(contactModel: ContactModel): Pair<BlobDataProvider, DeltaImage>? {
-        return if (fileService.hasContactAvatarFile(contactModel)) {
-            createJpegBlobAssets { fileService.getContactAvatar(contactModel) }
+        return if (fileService.hasContactAvatarFile(contactModel.identity)) {
+            createJpegBlobAssets { fileService.getContactAvatar(contactModel.identity) }
         } else {
             null
         }

+ 16 - 0
app/src/main/java/ch/threema/app/processors/IncomingMessageProcessorImpl.kt

@@ -32,7 +32,11 @@ import ch.threema.app.processors.contactcontrol.IncomingDeleteProfilePictureTask
 import ch.threema.app.processors.contactcontrol.IncomingSetProfilePictureTask
 import ch.threema.app.processors.conversation.IncomingBallotVoteTask
 import ch.threema.app.processors.conversation.IncomingContactConversationMessageTask
+import ch.threema.app.processors.conversation.IncomingContactDeleteMessageTask
+import ch.threema.app.processors.conversation.IncomingContactEditMessageTask
 import ch.threema.app.processors.conversation.IncomingGroupConversationMessageTask
+import ch.threema.app.processors.conversation.IncomingGroupDeleteMessageTask
+import ch.threema.app.processors.conversation.IncomingGroupEditMessageTask
 import ch.threema.app.processors.fs.IncomingEmptyTask
 import ch.threema.app.processors.groupcontrol.IncomingGroupCallControlTask
 import ch.threema.app.processors.groupcontrol.IncomingGroupDeleteProfilePictureTask
@@ -68,11 +72,15 @@ import ch.threema.domain.protocol.csp.messages.AbstractGroupMessage
 import ch.threema.domain.protocol.csp.messages.AbstractMessage
 import ch.threema.domain.protocol.csp.messages.BadMessageException
 import ch.threema.domain.protocol.csp.messages.ContactRequestProfilePictureMessage
+import ch.threema.domain.protocol.csp.messages.DeleteMessage
 import ch.threema.domain.protocol.csp.messages.DeleteProfilePictureMessage
 import ch.threema.domain.protocol.csp.messages.DeliveryReceiptMessage
+import ch.threema.domain.protocol.csp.messages.EditMessage
 import ch.threema.domain.protocol.csp.messages.EmptyMessage
+import ch.threema.domain.protocol.csp.messages.GroupDeleteMessage
 import ch.threema.domain.protocol.csp.messages.GroupDeleteProfilePictureMessage
 import ch.threema.domain.protocol.csp.messages.GroupDeliveryReceiptMessage
+import ch.threema.domain.protocol.csp.messages.GroupEditMessage
 import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
 import ch.threema.domain.protocol.csp.messages.GroupNameMessage
 import ch.threema.domain.protocol.csp.messages.GroupSetProfilePictureMessage
@@ -381,6 +389,14 @@ class IncomingMessageProcessorImpl(
             is VoipCallRingingMessage -> IncomingCallRingingTask(message, serviceManager)
             is VoipCallHangupMessage -> IncomingCallHangupTask(message, serviceManager)
 
+            // Check if message is an edit message
+            is EditMessage -> IncomingContactEditMessageTask(message, serviceManager)
+            is GroupEditMessage -> IncomingGroupEditMessageTask(message, serviceManager)
+
+            // Check if message is a delete message
+            is DeleteMessage -> IncomingContactDeleteMessageTask(message, serviceManager)
+            is GroupDeleteMessage -> IncomingGroupDeleteMessageTask(message, serviceManager)
+
             // If it is a group message, process it as a group conversation message
             is AbstractGroupMessage -> IncomingGroupConversationMessageTask(message, serviceManager)
 

+ 1 - 1
app/src/main/java/ch/threema/app/processors/contactcontrol/IncomingDeleteProfilePictureTask.kt

@@ -47,7 +47,7 @@ class IncomingDeleteProfilePictureTask(
             return ReceiveStepsResult.DISCARD
         }
 
-        fileService.removeContactPhoto(contactModel)
+        fileService.removeContactPhoto(contactModel.identity)
         this.avatarCacheService.reset(contactModel)
         ListenerManager.contactListeners.handle { listener: ContactListener ->
             listener.onAvatarChanged(contactModel)

+ 1 - 1
app/src/main/java/ch/threema/app/processors/contactcontrol/IncomingSetProfilePictureTask.kt

@@ -67,7 +67,7 @@ class IncomingSetProfilePictureTask(
             message.encryptionKey,
             ProtocolDefines.CONTACT_PHOTO_NONCE
         )
-        this.fileService.writeContactPhoto(contactModel, encryptedBlob)
+        this.fileService.writeContactPhoto(contactModel.identity, encryptedBlob)
         this.avatarCacheService.reset(contactModel)
         ListenerManager.contactListeners.handle { listener: ContactListener ->
             listener.onAvatarChanged(contactModel)

+ 59 - 0
app/src/main/java/ch/threema/app/processors/conversation/IncomingContactDeleteMessageTask.kt

@@ -0,0 +1,59 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.processors.conversation
+
+import ch.threema.app.managers.ServiceManager
+import ch.threema.app.processors.IncomingCspMessageSubTask
+import ch.threema.app.processors.ReceiveStepsResult
+import ch.threema.app.tasks.runCommonDeleteMessageReceiveSteps
+import ch.threema.base.utils.LoggingUtil
+import ch.threema.domain.protocol.csp.messages.DeleteMessage
+import ch.threema.domain.taskmanager.ActiveTaskCodec
+import org.slf4j.Logger
+
+private val logger: Logger = LoggingUtil.getThreemaLogger("IncomingContactDeleteMessageTask")
+
+class IncomingContactDeleteMessageTask(
+    private val deleteMessage: DeleteMessage,
+    serviceManager: ServiceManager,
+) : IncomingCspMessageSubTask(serviceManager) {
+
+    private val messageService by lazy { serviceManager.messageService }
+    private val contactService by lazy { serviceManager.contactService }
+
+    override suspend fun run(handle: ActiveTaskCodec): ReceiveStepsResult {
+        logger.debug("IncomingContactDeleteMessageTask id: {}", deleteMessage.data.messageId)
+
+        val contactModel = contactService.getByIdentity(deleteMessage.fromIdentity)
+        if (contactModel == null) {
+            logger.warn("Incoming Delete Message: No contact found for {}", deleteMessage.fromIdentity)
+            return ReceiveStepsResult.DISCARD
+        }
+
+        val receiver = contactService.createReceiver(contactModel)
+        val message = runCommonDeleteMessageReceiveSteps(deleteMessage, receiver, messageService)
+            ?: return ReceiveStepsResult.DISCARD
+
+        messageService.deleteMessageContents(message, deleteMessage.date)
+        return ReceiveStepsResult.SUCCESS
+    }
+}

+ 59 - 0
app/src/main/java/ch/threema/app/processors/conversation/IncomingContactEditMessageTask.kt

@@ -0,0 +1,59 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.processors.conversation
+
+import ch.threema.app.managers.ServiceManager
+import ch.threema.app.processors.IncomingCspMessageSubTask
+import ch.threema.app.processors.ReceiveStepsResult
+import ch.threema.app.tasks.runCommonEditMessageReceiveSteps
+import ch.threema.base.utils.LoggingUtil
+import ch.threema.domain.protocol.csp.messages.EditMessage
+import ch.threema.domain.taskmanager.ActiveTaskCodec
+import org.slf4j.Logger
+
+private val logger: Logger = LoggingUtil.getThreemaLogger("IncomingContactEditMessageTask")
+
+class IncomingContactEditMessageTask(
+        private val editMessage: EditMessage,
+        serviceManager: ServiceManager,
+) : IncomingCspMessageSubTask(serviceManager) {
+
+    private val messageService by lazy { serviceManager.messageService }
+    private val contactService by lazy { serviceManager.contactService }
+
+    override suspend fun run(handle: ActiveTaskCodec): ReceiveStepsResult {
+        logger.debug("IncomingContactEditMessageTask id: ${editMessage.data.messageId}")
+
+        val contactModel = contactService.getByIdentity(editMessage.fromIdentity)
+        if (contactModel == null) {
+            logger.warn("Incoming Edit Message: No contact found for ${editMessage.fromIdentity}")
+            return ReceiveStepsResult.DISCARD
+        }
+
+        val receiver = contactService.createReceiver(contactModel)
+        val message = runCommonEditMessageReceiveSteps(editMessage, receiver, messageService)
+            ?: return ReceiveStepsResult.DISCARD
+
+        messageService.saveEditedMessageText(message, editMessage.data.text, editMessage.date)
+        return ReceiveStepsResult.SUCCESS
+    }
+}

+ 57 - 0
app/src/main/java/ch/threema/app/processors/conversation/IncomingGroupDeleteMessageTask.kt

@@ -0,0 +1,57 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.processors.conversation
+
+import ch.threema.app.managers.ServiceManager
+import ch.threema.app.processors.IncomingCspMessageSubTask
+import ch.threema.app.processors.ReceiveStepsResult
+import ch.threema.app.processors.groupcontrol.runCommonGroupReceiveSteps
+import ch.threema.app.tasks.runCommonDeleteMessageReceiveSteps
+import ch.threema.base.utils.LoggingUtil
+import ch.threema.domain.protocol.csp.messages.GroupDeleteMessage
+import ch.threema.domain.taskmanager.ActiveTaskCodec
+import org.slf4j.Logger
+
+private val logger: Logger = LoggingUtil.getThreemaLogger("IncomingGroupDeleteMessageTask")
+
+class IncomingGroupDeleteMessageTask(
+    private val deleteMessage: GroupDeleteMessage,
+    serviceManager: ServiceManager,
+) : IncomingCspMessageSubTask(serviceManager) {
+
+    private val messageService = serviceManager.messageService
+    private val groupService = serviceManager.groupService
+
+    override suspend fun run(handle: ActiveTaskCodec): ReceiveStepsResult {
+        logger.debug("IncomingGroupDeleteMessageTask id: {}", deleteMessage.data.messageId)
+
+        val groupModel = runCommonGroupReceiveSteps(deleteMessage, handle, serviceManager)
+            ?: return ReceiveStepsResult.DISCARD
+
+        val receiver = groupService.createReceiver(groupModel)
+        val message = runCommonDeleteMessageReceiveSteps(deleteMessage, receiver, messageService)
+            ?: return ReceiveStepsResult.DISCARD
+
+        messageService.deleteMessageContents(message, deleteMessage.date)
+        return ReceiveStepsResult.SUCCESS
+    }
+}

+ 57 - 0
app/src/main/java/ch/threema/app/processors/conversation/IncomingGroupEditMessageTask.kt

@@ -0,0 +1,57 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * 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.processors.conversation
+
+import ch.threema.app.managers.ServiceManager
+import ch.threema.app.processors.IncomingCspMessageSubTask
+import ch.threema.app.processors.ReceiveStepsResult
+import ch.threema.app.processors.groupcontrol.runCommonGroupReceiveSteps
+import ch.threema.app.tasks.runCommonEditMessageReceiveSteps
+import ch.threema.base.utils.LoggingUtil
+import ch.threema.domain.protocol.csp.messages.GroupEditMessage
+import ch.threema.domain.taskmanager.ActiveTaskCodec
+import org.slf4j.Logger
+
+private val logger: Logger = LoggingUtil.getThreemaLogger("IncomingGroupEditMessageTask")
+
+class IncomingGroupEditMessageTask(
+        private val editMessage: GroupEditMessage,
+        serviceManager: ServiceManager,
+) : IncomingCspMessageSubTask(serviceManager) {
+
+    private val messageService by lazy { serviceManager.messageService }
+    private val groupService by lazy { serviceManager.groupService }
+
+    override suspend fun run(handle: ActiveTaskCodec): ReceiveStepsResult {
+        logger.debug("IncomingGroupEditMessageTask id: ${editMessage.data.messageId}")
+
+        val groupModel = runCommonGroupReceiveSteps(editMessage, handle, serviceManager)
+                ?: return ReceiveStepsResult.DISCARD
+
+        val receiver = groupService.createReceiver(groupModel)
+        val message = runCommonEditMessageReceiveSteps(editMessage, receiver, messageService)
+            ?: return ReceiveStepsResult.DISCARD
+
+        messageService.saveEditedMessageText(message, editMessage.data.text, editMessage.date)
+        return ReceiveStepsResult.SUCCESS
+    }
+}

+ 7 - 1
app/src/main/java/ch/threema/app/processors/groupcontrol/IncomingGroupLeaveTask.kt

@@ -27,9 +27,12 @@ import ch.threema.app.processors.IncomingCspMessageSubTask
 import ch.threema.app.processors.ReceiveStepsResult
 import ch.threema.app.services.GroupService
 import ch.threema.app.tasks.OutgoingGroupSyncRequestTask
+import ch.threema.base.utils.LoggingUtil
 import ch.threema.domain.protocol.csp.messages.GroupLeaveMessage
 import ch.threema.domain.taskmanager.ActiveTaskCodec
 
+private val logger = LoggingUtil.getThreemaLogger("IncomingGroupLeaveTask")
+
 class IncomingGroupLeaveTask(
     private val groupLeaveMessage: GroupLeaveMessage,
     serviceManager: ServiceManager,
@@ -43,7 +46,10 @@ class IncomingGroupLeaveTask(
         val sender = groupLeaveMessage.fromIdentity
 
         // 1. If the sender is the creator of the group, abort these steps
-        // TODO(ANDR-2385): apply group leave messages from the creator in the transition phase
+        if (sender == creator) {
+            logger.warn("Discarding group leave message from group creator")
+            return ReceiveStepsResult.DISCARD
+        }
 
         // 2. Look up the group
         val group = groupService.getByGroupMessage(groupLeaveMessage)

+ 6 - 0
app/src/main/java/ch/threema/app/processors/groupcontrol/IncomingGroupSetupTask.kt

@@ -101,6 +101,12 @@ class IncomingGroupSetupTask(
                 groupCallManager.abortCurrentCall()
             }
 
+            // If we are not a member anyway, we do not have to do anything. Especially, we should
+            // not call the listener as this would trigger a status message each time.
+            if (!groupService.isGroupMember(group)) {
+                return ReceiveStepsResult.SUCCESS
+            }
+
             // 4.2 Mark the group as left and abort these steps.
             groupService.removeMemberFromGroup(group, myIdentity)
 

+ 0 - 1
app/src/main/java/ch/threema/app/processors/statusupdates/IncomingDeliveryReceiptTask.kt

@@ -44,7 +44,6 @@ class IncomingDeliveryReceiptTask(
             ProtocolDefines.DELIVERYRECEIPT_MSGREAD -> MessageState.READ
             ProtocolDefines.DELIVERYRECEIPT_MSGUSERACK -> MessageState.USERACK
             ProtocolDefines.DELIVERYRECEIPT_MSGUSERDEC -> MessageState.USERDEC
-            ProtocolDefines.DELIVERYRECEIPT_MSGCONSUMED -> MessageState.CONSUMED
             else -> {
                 logger.warn("Message {} error: unknown delivery receipt type", message.messageId)
                 return ReceiveStepsResult.DISCARD

+ 3 - 3
app/src/main/java/ch/threema/app/routines/SynchronizeContactsRoutine.java

@@ -271,7 +271,7 @@ public class SynchronizeContactsRoutine implements Runnable {
 				//contact does not exist, create a new one
 				if (contact == null) {
 					contact = new ContactModel(id.getKey(), id.getValue().publicKey);
-					contact.setVerificationLevel(VerificationLevel.SERVER_VERIFIED);
+					contact.verificationLevel = VerificationLevel.SERVER_VERIFIED;
 					contact.setDateCreated(new Date());
 					insertedContacts.add(contact);
 
@@ -288,8 +288,8 @@ public class SynchronizeContactsRoutine implements Runnable {
 					AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contact);
 
 					contact.setAcquaintanceLevel(AcquaintanceLevel.DIRECT);
-					if (contact.getVerificationLevel() == VerificationLevel.UNVERIFIED) {
-						contact.setVerificationLevel(VerificationLevel.SERVER_VERIFIED);
+					if (contact.verificationLevel == VerificationLevel.UNVERIFIED) {
+						contact.verificationLevel = VerificationLevel.SERVER_VERIFIED;
 					}
 
 					List<AndroidContactUtil.RawContactInfo> rawContactInfos = existingRawContacts.get(contact.getIdentity());

+ 92 - 102
app/src/main/java/ch/threema/app/routines/UpdateBusinessAvatarRoutine.java

@@ -34,14 +34,17 @@ import java.util.Map;
 
 import javax.net.ssl.HttpsURLConnection;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.WorkerThread;
 import ch.threema.app.services.ApiService;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.FileService;
+import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.FileUtil;
-import ch.threema.app.utils.TestUtil;
 import ch.threema.base.utils.LoggingUtil;
-import ch.threema.storage.models.ContactModel;
+import ch.threema.data.models.ContactModel;
+import ch.threema.data.models.ContactModelData;
 
 import static android.provider.MediaStore.MEDIA_IGNORE_FILENAME;
 
@@ -51,48 +54,52 @@ import static android.provider.MediaStore.MEDIA_IGNORE_FILENAME;
 public class UpdateBusinessAvatarRoutine implements Runnable {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("UpdateBusinessAvatarRoutine");
 
-	private final ContactService contactService;
-	private FileService fileService;
-	private ContactModel contactModel;
-	private final ApiService apiService;
+	private final @NonNull ContactService contactService;
+	private final @NonNull FileService fileService;
+	private final @NonNull ContactModel contactModel;
+	private final @NonNull ApiService apiService;
 	private boolean running = false;
 	private boolean forceUpdate = false;
 
-	protected UpdateBusinessAvatarRoutine(ContactService contactService, FileService fileService, ContactModel contactModel, ApiService apiService) {
+	protected UpdateBusinessAvatarRoutine(
+		@NonNull ContactService contactService,
+		@NonNull FileService fileService,
+		@NonNull ContactModel contactModel,
+		@NonNull ApiService apiService
+	) {
 		this.contactService = contactService;
 		this.fileService = fileService;
 		this.contactModel = contactModel;
 		this.apiService = apiService;
 	}
 
-	protected UpdateBusinessAvatarRoutine forceUpdate() {
+	private void forceUpdate() {
 		this.forceUpdate = true;
-		return this;
 	}
 
 	@Override
 	public void run() {
 		this.running = true;
 
-		//validate instances
-		if (!TestUtil.required(this.contactModel, this.contactService, this.fileService)) {
-			this.running = false;
-			logger.error(": not all required instances defined");
-			return;
-		}
-
-		if (!ContactUtil.isChannelContact(this.contactModel)) {
-			logger.error(": contact is not a business account");
+		if (!ContactUtil.isGatewayContact(this.contactModel.getIdentity())) {
+			logger.error("Contact is not a business account");
 			this.running = false;
 			return;
 
 		}
 		//validate expiry date
-		if (!this.forceUpdate
-			&& !ContactUtil.isAvatarExpired(this.contactModel)) {
-			logger.error(": avatar is not expired");
-			this.running = false;
-			return;
+		if (!this.forceUpdate) {
+			ContactModelData data = contactModel.getData().getValue();
+			if (data == null) {
+				logger.warn("Contact has been deleted");
+				this.running = false;
+				return;
+			}
+			if (!data.isAvatarExpired()) {
+				logger.error("Avatar is not expired");
+				this.running = false;
+				return;
+			}
 		}
 
 		//define default expiry date (now + 1day)
@@ -109,36 +116,40 @@ public class UpdateBusinessAvatarRoutine implements Runnable {
 			try {
 				// Warning: This may implicitly open an error stream in the 4xx/5xx case!
 				connection.connect();
-				boolean avatarModified = false;
 				int responseCode = connection.getResponseCode();
 				if (responseCode != HttpsURLConnection.HTTP_OK) {
 					if (responseCode == HttpsURLConnection.HTTP_NOT_FOUND) {
 						logger.debug("Avatar not found");
 						//remove existing avatar
-						avatarModified = this.fileService.removeContactAvatar(contactModel);
+						this.fileService.removeContactAvatar(contactModel.getIdentity());
 
 						//ok, no avatar set
 						//add expires date = now + 1day
-						this.contactModel.setAvatarExpires(tomorrow);
-
-						this.contactService.clearAvatarCache(this.contactModel);
-						this.contactService.save(this.contactModel);
+						this.contactModel.setLocalAvatarExpires(tomorrow);
+
+						this.contactService.clearAvatarCache(contactModel.getIdentity());
+					} else if (responseCode == HttpsURLConnection.HTTP_UNAUTHORIZED) {
+						 logger.warn("Unauthorized access to avatar server");
+						 if (ConfigUtils.isOnPremBuild()) {
+							 logger.info("Invalidating auth token");
+							 apiService.invalidateAuthToken();
+						 }
 					}
 				} else {
 					//cool, save avatar
 					logger.debug("Avatar found start download");
 
-					File temporaryFile = this.fileService.createTempFile(MEDIA_IGNORE_FILENAME, "avatardownload-" + String.valueOf(this.contactModel.getIdentity()).hashCode());
+					File temporaryFile = this.fileService.createTempFile(MEDIA_IGNORE_FILENAME, "avatardownload-" + this.contactModel.getIdentity().hashCode());
 					// this will be useful to display download percentage
 					// might be -1: server did not report the length
 					int fileLength = connection.getContentLength();
-					logger.debug("size: " + fileLength);
+					logger.debug("size: {}", fileLength);
 
 					// download the file
 					Date expires = new Date(connection.getHeaderFieldDate("Expires", tomorrow.getTime()));
-					logger.debug("expires " + expires);
+					logger.debug("expires {}", expires);
 
-					byte data[] = new byte[4096];
+					byte[] data = new byte[4096];
 					int count;
 					try (
 						InputStream input = connection.getInputStream();
@@ -153,17 +164,14 @@ public class UpdateBusinessAvatarRoutine implements Runnable {
 						logger.debug("Avatar downloaded");
 
 						//define avatar
-						this.contactService.setAvatar(contactModel, temporaryFile);
+						this.contactService.setAvatar(contactModel.getIdentity(), temporaryFile);
 
 						//set expires header
-						this.contactModel.setAvatarExpires(expires);
-						this.contactService.clearAvatarCache(this.contactModel);
-						this.contactService.save(this.contactModel);
+						this.contactModel.setLocalAvatarExpires(expires);
+						this.contactService.clearAvatarCache(contactModel.getIdentity());
 
 						//remove temporary file
 						FileUtil.deleteFileOrWarn(temporaryFile, "temporaryFile", logger);
-
-						avatarModified = true;
 					} catch (IOException x) {
 						//failed to download
 						//do nothing an try again later
@@ -190,10 +198,6 @@ public class UpdateBusinessAvatarRoutine implements Runnable {
 		return this.running;
 	}
 
-	/**
-	 * Static Stuff
-	 */
-
 	/**
 	 * routine states
 	 */
@@ -201,72 +205,54 @@ public class UpdateBusinessAvatarRoutine implements Runnable {
 
 	/**
 	 * Update (if necessary) a business avatar
-	 *
-	 * @param contactModel
-	 * @param fileService
-	 * @param contactService
-	 * @return
-	 */
-	public static final boolean startUpdate(ContactModel contactModel,
-											FileService fileService,
-											ContactService contactService,
-											ApiService apiService) {
-		return startUpdate(contactModel, fileService, contactService, apiService, false);
-	}
-
-	/**
-	 * Update (if necessary) a business avatar
-	 *
-	 * @param contactModel
-	 * @param fileService
-	 * @param contactService
-	 * @param forceUpdate if true, the expiry date will be ignored
-	 * @return
 	 */
-	public static final boolean startUpdate(final ContactModel contactModel,
-											FileService fileService,
-											ContactService contactService,
-											ApiService apiService,
-											boolean forceUpdate) {
-		UpdateBusinessAvatarRoutine instance = createInstance(contactModel, fileService, contactService, apiService, forceUpdate);
-		if(instance != null) {
+	public static void startUpdate(
+		@NonNull ContactModel contactModel,
+		@NonNull FileService fileService,
+		@NonNull ContactService contactService,
+		@NonNull ApiService apiService
+	) {
+		UpdateBusinessAvatarRoutine instance = createInstance(
+			contactModel,
+			fileService,
+			contactService,
+			apiService,
+			false
+		);
+		if (instance != null) {
 			//simple start thread!
 			Thread thread = new Thread(instance);
-			thread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
-				@Override
-				public void uncaughtException(Thread thread, Throwable throwable) {
-					logger.error("Uncaught exception", throwable);
-					synchronized (runningUpdates) {
-						runningUpdates.remove(contactModel.getIdentity());
-					}
+			thread.setUncaughtExceptionHandler((thread1, throwable) -> {
+				logger.error("Uncaught exception", throwable);
+				synchronized (runningUpdates) {
+					runningUpdates.remove(contactModel.getIdentity());
 				}
 			});
 			thread.start();
-
-			return thread != null;
 		}
-
-
-		return false;
 	}
 
-
 	/**
 	 * Update (if necessary) a business avatar
-	 * IMPORTANT: this method run the method in the same thread
+	 * IMPORTANT: this method runs the update routine in the same thread
 	 *
-	 * @param contactModel
-	 * @param fileService
-	 * @param contactService
 	 * @param forceUpdate if true, the expiry date will be ignored
-	 * @return
 	 */
-	public static final boolean start(ContactModel contactModel,
-											FileService fileService,
-											ContactService contactService,
-											ApiService apiService,
-											boolean forceUpdate) {
-		UpdateBusinessAvatarRoutine instance = createInstance(contactModel, fileService, contactService, apiService, forceUpdate);
+	@WorkerThread
+	public static boolean start(
+		@NonNull ContactModel contactModel,
+		@NonNull FileService fileService,
+		@NonNull ContactService contactService,
+		@NonNull ApiService apiService,
+		boolean forceUpdate
+	) {
+		UpdateBusinessAvatarRoutine instance = createInstance(
+			contactModel,
+			fileService,
+			contactService,
+			apiService,
+			forceUpdate
+		);
 		if(instance != null) {
 			instance.run();
 			return true;
@@ -274,11 +260,13 @@ public class UpdateBusinessAvatarRoutine implements Runnable {
 		return false;
 	}
 
-	private static UpdateBusinessAvatarRoutine createInstance(ContactModel contactModel,
-															  FileService fileService,
-															  ContactService contactService,
-															  ApiService apiService,
-															  boolean forceUpdate) {
+	private static UpdateBusinessAvatarRoutine createInstance(
+		@NonNull ContactModel contactModel,
+		@NonNull FileService fileService,
+		@NonNull ContactService contactService,
+		@NonNull ApiService apiService,
+		boolean forceUpdate
+	) {
 		synchronized (runningUpdates) {
 			final String key = contactModel.getIdentity();
 			//check if a update is running now
@@ -288,8 +276,10 @@ public class UpdateBusinessAvatarRoutine implements Runnable {
 
 				//check if necessary
 				if (!forceUpdate) {
-					if (ContactUtil.isAvatarExpired(contactModel)) {
-						logger.debug("do not update avatar, not expired");
+					ContactModelData data = contactModel.getData().getValue();
+					if (data == null || !data.isAvatarExpired()) {
+						logger.warn("Contact has been deleted or avatar is not expired");
+						return null;
 					}
 				}
 

+ 6 - 0
app/src/main/java/ch/threema/app/services/ApiService.java

@@ -33,5 +33,11 @@ public interface ApiService {
 	BlobUploader createUploader(byte[] data) throws ThreemaException;
 	BlobLoader createLoader(byte[] blobId);
 	String getAuthToken() throws ThreemaException;
+
+	/**
+	 * Invalidate the auth token (only used for onprem). This forces a new fetch of the auth token
+	 * the next time the token is obtained with {@link #getAuthToken()}.
+	 */
+	void invalidateAuthToken();
 	HttpsURLConnection createAvatarURLConnection(String identity) throws ThreemaException, IOException;
 }

+ 5 - 0
app/src/main/java/ch/threema/app/services/ApiServiceImpl.java

@@ -79,6 +79,11 @@ public class ApiServiceImpl implements ApiService {
 		}
 	}
 
+	@Override
+	public void invalidateAuthToken() {
+		this.authTokenStore.storeToken(null);
+	}
+
 	@Override
 	public HttpsURLConnection createAvatarURLConnection(String identity) throws ThreemaException, IOException {
 		URL url = new URL(serverAddressProvider.getAvatarServerUrl(false) + identity);

+ 1 - 1
app/src/main/java/ch/threema/app/services/AvatarCacheServiceImpl.java

@@ -355,7 +355,7 @@ final public class AvatarCacheServiceImpl implements AvatarCacheService {
 
 		@Override
 		int getHashCode() {
-			if (model != null && model.getIdentity() != null) {
+			if (model != null) {
 				return model.getIdentity().hashCode();
 			}
 			return -1;

+ 14 - 9
app/src/main/java/ch/threema/app/services/ContactService.java

@@ -26,20 +26,18 @@ import androidx.annotation.Nullable;
 import androidx.annotation.WorkerThread;
 
 import java.io.File;
-import java.util.Collections;
 import java.util.List;
 
 import ch.threema.app.exceptions.EntryAlreadyExistsException;
 import ch.threema.app.exceptions.InvalidEntryException;
 import ch.threema.app.exceptions.PolicyViolationException;
 import ch.threema.app.messagereceiver.ContactMessageReceiver;
+import ch.threema.data.models.ContactModelData;
 import ch.threema.domain.fs.DHSession;
 import ch.threema.domain.models.VerificationLevel;
 import ch.threema.domain.protocol.api.APIConnector;
 import ch.threema.domain.protocol.api.work.WorkContact;
 import ch.threema.domain.protocol.csp.messages.AbstractMessage;
-import ch.threema.domain.protocol.csp.messages.DeleteProfilePictureMessage;
-import ch.threema.domain.protocol.csp.messages.SetProfilePictureMessage;
 import ch.threema.domain.protocol.csp.messages.MissingPublicKeyException;
 import ch.threema.domain.taskmanager.ActiveTaskCodec;
 import ch.threema.storage.models.ContactModel;
@@ -388,8 +386,6 @@ public interface ContactService extends AvatarService<ContactModel> {
 
 	VerificationLevel getInitialVerificationLevel(ContactModel contactModel);
 
-	ContactModel createContactByQRResult(QRCodeService.QRCodeContentResult qrResult) throws InvalidEntryException, EntryAlreadyExistsException, PolicyViolationException;
-
 	void removeAll();
 
 	/**
@@ -407,14 +403,18 @@ public interface ContactService extends AvatarService<ContactModel> {
 	void removeAllSystemContactLinks();
 
 	@Deprecated
-	int getUniqueId(ContactModel contactModel);
+	int getUniqueId(@Nullable ContactModel contactModel);
+	@Deprecated
+	int getUniqueId(@NonNull String identity);
 	String getUniqueIdString(ContactModel contactModel);
 
 	String getUniqueIdString(String identity);
 
-	boolean setAvatar(ContactModel contactModel, File temporaryAvatarFile) throws Exception;
+	boolean setAvatar(@Nullable ContactModel contactModel, @Nullable File temporaryAvatarFile) throws Exception;
+	boolean setAvatar(@NonNull String identity, @Nullable File temporaryAvatarFile) throws Exception;
 	boolean setAvatar(ContactModel contactModel, byte[] avatar) throws Exception;
 	boolean removeAvatar(ContactModel contactModel);
+	void clearAvatarCache(@NonNull String identity);
 
 	@NonNull
 	ProfilePictureSharePolicy getProfilePictureSharePolicy();
@@ -446,12 +446,17 @@ public interface ContactService extends AvatarService<ContactModel> {
 
 	ContactModel createContactModelByIdentity(String identity) throws InvalidEntryException;
 
-	boolean showBadge(ContactModel contactModel);
+	boolean showBadge(@Nullable ContactModel contactModel);
+	boolean showBadge(@NonNull ContactModelData contactModelData);
 
-	void setName(ContactModel contact, String firstName, String lastName);
 	String getAndroidContactLookupUriString(ContactModel contactModel);
 	@Nullable ContactModel addWorkContact(@NonNull WorkContact workContact, @Nullable List<ContactModel> existingWorkContacts);
 
+	/**
+	 * Remove the specified contact from the contact cache.
+	 */
+	void removeFromCache(@NonNull String identity);
+
 	/**
 	 * Fetch contact if not available locally. There are different steps executed to get the public
 	 * key of the identity. As soon as the public key has been fetched, the steps are aborted and

+ 148 - 95
app/src/main/java/ch/threema/app/services/ContactServiceImpl.java

@@ -32,20 +32,13 @@ import android.provider.ContactsContract;
 import android.text.format.DateUtils;
 import android.widget.ImageView;
 
-import androidx.annotation.AnyThread;
-import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.UiThread;
-import androidx.annotation.WorkerThread;
-import androidx.core.content.ContextCompat;
-
 import com.bumptech.glide.RequestManager;
 import com.neilalexander.jnacl.NaCl;
 
 import org.slf4j.Logger;
 
 import java.io.File;
+import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.net.HttpURLConnection;
 import java.security.MessageDigest;
@@ -61,6 +54,13 @@ import java.util.Map;
 import java.util.Timer;
 import java.util.TimerTask;
 
+import androidx.annotation.AnyThread;
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.annotation.WorkerThread;
+import androidx.core.content.ContextCompat;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.collections.Functional;
@@ -91,9 +91,10 @@ import ch.threema.app.utils.ShortcutUtil;
 import ch.threema.app.utils.SynchronizeContactsUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.base.crypto.NonceFactory;
 import ch.threema.base.utils.Base32;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.data.models.ContactModelData;
+import ch.threema.data.repositories.ContactModelRepository;
 import ch.threema.domain.fs.DHSession;
 import ch.threema.domain.models.Contact;
 import ch.threema.domain.models.IdentityState;
@@ -135,9 +136,10 @@ public class ContactServiceImpl implements ContactService {
 	private final DatabaseServiceNew databaseServiceNew;
 	private final DeviceService deviceService;
 	private final UserService userService;
-	private final NonceFactory nonceFactory;
 	private final IdentityStore identityStore;
 	private final PreferenceService preferenceService;
+	// NOTE: The contact model cache will become unnecessary once everything uses the new data
+	// layer, since that data layer has caching built-in.
 	private final Map<String, ContactModel> contactModelCache;
 	private final IdListService blackListIdentityService, profilePicRecipientsService;
 	private final DeadlineListService mutedChatsListService;
@@ -151,6 +153,9 @@ public class ContactServiceImpl implements ContactService {
 	private final Timer typingTimer;
 	private final Map<String,TimerTask> typingTimerTasks;
 
+	@NonNull
+	private final ContactModelRepository contactModelRepository;
+
 	private final List<String> typingIdentities = new ArrayList<>();
 
 	private ContactModel me;
@@ -178,7 +183,6 @@ public class ContactServiceImpl implements ContactService {
 		DatabaseServiceNew databaseServiceNew,
 		DeviceService deviceService,
 		UserService userService,
-		NonceFactory nonceFactory,
 		IdentityStore identityStore,
 		PreferenceService preferenceService,
 		IdListService blackListIdentityService,
@@ -191,7 +195,8 @@ public class ContactServiceImpl implements ContactService {
 		ApiService apiService,
 		WallpaperService wallpaperService,
 		LicenseService licenseService,
-		APIConnector apiConnector
+		APIConnector apiConnector,
+		@NonNull ContactModelRepository contactModelRepository
 	) {
 
 		this.context = context;
@@ -200,7 +205,6 @@ public class ContactServiceImpl implements ContactService {
 		this.databaseServiceNew = databaseServiceNew;
 		this.deviceService = deviceService;
 		this.userService = userService;
-		this.nonceFactory = nonceFactory;
 		this.identityStore = identityStore;
 		this.preferenceService = preferenceService;
 		this.blackListIdentityService = blackListIdentityService;
@@ -213,6 +217,7 @@ public class ContactServiceImpl implements ContactService {
 		this.wallpaperService = wallpaperService;
 		this.licenseService = licenseService;
 		this.apiConnector = apiConnector;
+		this.contactModelRepository = contactModelRepository;
 		this.typingTimer = new Timer();
 		this.typingTimerTasks = new HashMap<>();
 		this.contactModelCache = cacheService.getContactModelCache();
@@ -228,7 +233,7 @@ public class ContactServiceImpl implements ContactService {
 			this.me.setPublicNickName(this.userService.getPublicNickname());
 			this.me.setState(ContactModel.State.ACTIVE);
 			this.me.setFirstName(context.getString(R.string.me_myself_and_i));
-			this.me.setVerificationLevel(VerificationLevel.FULLY_VERIFIED);
+			this.me.verificationLevel = VerificationLevel.FULLY_VERIFIED;
 			this.me.setFeatureMask(-1);
 		}
 
@@ -311,7 +316,7 @@ public class ContactServiceImpl implements ContactService {
 			}
 
 			if (!filter.includeHidden()) {
-				queryBuilder.appendWhere(ContactModel.COLUMN_HAS_ACQUAINTANCE_LEVEL_GROUP + "=0");
+				queryBuilder.appendWhere(ContactModel.COLUMN_ACQUAINTANCE_LEVEL + "=0");
 			}
 
 			if (!filter.includeMyself() && getMe() != null) {
@@ -483,7 +488,7 @@ public class ContactServiceImpl implements ContactService {
 		Cursor c = this.databaseServiceNew.getReadableDatabase().rawQuery(
 			"SELECT COUNT(*) FROM contacts " +
 			"WHERE " + ContactModel.COLUMN_IS_WORK + " = 1 " +
-			"AND " + ContactModel.COLUMN_HAS_ACQUAINTANCE_LEVEL_GROUP + " = 0", null);
+			"AND " + ContactModel.COLUMN_ACQUAINTANCE_LEVEL + " = 0", null);
 
 		if (c != null) {
 			if(c.moveToFirst()) {
@@ -533,7 +538,7 @@ public class ContactServiceImpl implements ContactService {
 
 			@Override
 			public boolean apply(@NonNull ContactModel type) {
-				return !ContactUtil.isEchoEchoOrChannelContact(type);
+				return !ContactUtil.isEchoEchoOrGatewayContact(type);
 			}
 		});
 	}
@@ -706,10 +711,7 @@ public class ContactServiceImpl implements ContactService {
 		final ContactModel contact = this.getByIdentity(identity);
 
 		if (contact != null && contact.isHidden() != hide) {
-			//remove from cache
-			synchronized (this.contactModelCache) {
-				this.contactModelCache.remove(identity);
-			}
+			this.removeFromCache(identity);
 			this.contactStore.hideContact(contact, hide);
 		}
 	}
@@ -736,10 +738,16 @@ public class ContactServiceImpl implements ContactService {
 
 	@Override
 	public void bumpLastUpdate(@NonNull String identity) {
+		logger.info("Bump last update for contact with identity {}", identity);
 		final ContactModel contact = this.getByIdentity(identity);
 		if (contact != null) {
 			contact.setLastUpdate(new Date());
 			save(contact); // listeners will be fired by save()
+		} else {
+			logger.warn(
+				"Could not bump last update because the contact with identity {} is null",
+				identity
+			);
 		}
 	}
 
@@ -754,7 +762,7 @@ public class ContactServiceImpl implements ContactService {
 
 	@Override
 	public void save(@NonNull ContactModel contactModel) {
-		this.contactStore.addContact(contactModel, contactModel.isHidden());
+		this.contactStore.addContact(contactModel);
 	}
 
 	@Override
@@ -791,17 +799,14 @@ public class ContactServiceImpl implements ContactService {
 			// remove
 			this.contactStore.removeContact(model);
 
-			//remove from cache
-			synchronized (this.contactModelCache) {
-				this.contactModelCache.remove(model.getIdentity());
-			}
+			this.removeFromCache(model.getIdentity());
 
 			this.ringtoneService.removeCustomRingtone(uniqueIdString);
 			this.mutedChatsListService.remove(uniqueIdString);
 			this.hiddenChatsListService.remove(uniqueIdString);
 			this.profilePicRecipientsService.remove(model.getIdentity());
 			this.wallpaperService.removeWallpaper(uniqueIdString);
-			this.fileService.removeAndroidContactAvatar(model);
+			this.fileService.removeAndroidContactAvatar(model.getIdentity());
 			ShortcutUtil.deleteShareTargetShortcut(uniqueIdString);
 			ShortcutUtil.deletePinnedShortcut(uniqueIdString);
 
@@ -922,8 +927,8 @@ public class ContactServiceImpl implements ContactService {
 
 		if (c != null) {
 			if (Arrays.equals(c.getPublicKey(), publicKey)) {
-				if (c.getVerificationLevel() != VerificationLevel.FULLY_VERIFIED) {
-					c.setVerificationLevel(VerificationLevel.FULLY_VERIFIED);
+				if (c.verificationLevel != VerificationLevel.FULLY_VERIFIED) {
+					c.verificationLevel = VerificationLevel.FULLY_VERIFIED;
 					this.save(c);
 					return ContactVerificationResult_VERIFIED;
 				} else {
@@ -938,6 +943,29 @@ public class ContactServiceImpl implements ContactService {
 	@AnyThread
 	@Override
 	public Bitmap getAvatar(@Nullable ContactModel contact, @NonNull AvatarOptions options) {
+		if (contact == null) {
+			return null;
+		}
+
+		// Check whether we should update the business avatar
+		ch.threema.data.models.ContactModel contactModel = contactModelRepository.getByIdentity(contact.getIdentity());
+		if (contactModel != null) {
+			ContactModelData data = contactModel.getData().getValue();
+			//check if a business avatar update is necessary
+			if (data != null && data.isGatewayContact() && data.isAvatarExpired()) {
+				//simple start
+				UpdateBusinessAvatarRoutine.startUpdate(
+					contactModel,
+					this.fileService,
+					this,
+					apiService
+				);
+			}
+		}
+		// Note that we should not abort if no new contact model can be found as the new model does
+		// not exist for the user itself whereas the old model may refer to the user. Therefore, we
+		// may still get an avatar for the provided (old) model.
+
 		// If the custom avatar is requested without default fallback and there is no avatar for
 		// this contact, we can return null directly. Important: This is necessary to prevent glide
 		// from logging an unnecessary error stack trace.
@@ -945,15 +973,7 @@ public class ContactServiceImpl implements ContactService {
 			return null;
 		}
 
-		Bitmap b = this.avatarCacheService.getContactAvatar(contact, options);
-
-		//check if a business avatar update is necessary
-		if (ContactUtil.isChannelContact(contact) && ContactUtil.isAvatarExpired(contact)) {
-			//simple start
-			UpdateBusinessAvatarRoutine.startUpdate(contact, this.fileService, this, apiService);
-		}
-
-		return b;
+		return this.avatarCacheService.getContactAvatar(contact, options);
 	}
 
 	private boolean hasAvatarOrContactPhoto(@Nullable ContactModel contact) {
@@ -961,7 +981,7 @@ public class ContactServiceImpl implements ContactService {
 			return false;
 		}
 
-		return fileService.hasContactAvatarFile(contact) || fileService.hasContactPhotoFile(contact);
+		return fileService.hasContactAvatarFile(contact.getIdentity()) || fileService.hasContactPhotoFile(contact.getIdentity());
 	}
 
 	@Override
@@ -1022,7 +1042,7 @@ public class ContactServiceImpl implements ContactService {
 		}
 
 		newContact.setAcquaintanceLevel(acquaintanceLevel);
-		newContact.setVerificationLevel(getInitialVerificationLevel(newContact));
+		newContact.verificationLevel = getInitialVerificationLevel(newContact);
 
 		this.save(newContact);
 
@@ -1055,7 +1075,8 @@ public class ContactServiceImpl implements ContactService {
 			for (APIConnector.FetchIdentityResult result : apiConnector.fetchIdentities(newIdentities)) {
 				ContactModel contactModel = createContactByFetchIdentityResult(result);
 				if (contactModel != null) {
-					contactStore.addContact(contactModel, true);
+					contactModel.setAcquaintanceLevel(AcquaintanceLevel.GROUP);
+					contactStore.addContact(contactModel);
 				}
 			}
 		} catch (Exception e) {
@@ -1078,23 +1099,6 @@ public class ContactServiceImpl implements ContactService {
 		return isTrusted ? VerificationLevel.FULLY_VERIFIED : VerificationLevel.UNVERIFIED;
 	}
 
-	@Override
-	public ContactModel createContactByQRResult(QRCodeService.QRCodeContentResult qrResult) throws InvalidEntryException, EntryAlreadyExistsException, PolicyViolationException {
-		ContactModel newContact = this.createContactByIdentity(qrResult.getIdentity(), false);
-
-		if (newContact == null || !Arrays.equals(newContact.getPublicKey(), qrResult.getPublicKey())) {
-			//remove CONTACT!
-			this.remove(newContact);
-			throw new InvalidEntryException(R.string.invalid_threema_qr_code);
-		}
-
-		newContact.setVerificationLevel(VerificationLevel.FULLY_VERIFIED);
-
-		this.save(newContact);
-
-		return newContact;
-	}
-
 	@Override
 	public void removeAll() {
 		for(ContactModel model: this.find(null)) {
@@ -1153,7 +1157,7 @@ public class ContactServiceImpl implements ContactService {
 
 		List<ContactModel> contactModels = this.getAll();
 		for (ContactModel contactModel: contactModels) {
-			if (!TestUtil.empty(contactModel.getAndroidContactLookupKey())) {
+			if (contactModel.isLinkedToAndroidContact()) {
 				try {
 					AndroidContactUtil.getInstance().updateNameByAndroidContact(contactModel);
 				} catch (ThreemaException e) {
@@ -1169,7 +1173,7 @@ public class ContactServiceImpl implements ContactService {
 	@Override
 	public void removeAllSystemContactLinks() {
 		for(ContactModel c: this.find(null)) {
-			if (c.getAndroidContactLookupKey() != null) {
+			if (c.isLinkedToAndroidContact()) {
 				c.setAndroidContactLookupKey(null);
 				this.save(c);
 			}
@@ -1178,14 +1182,20 @@ public class ContactServiceImpl implements ContactService {
 
 	@Override
 	@Deprecated
-	public int getUniqueId(ContactModel contactModel) {
+	public int getUniqueId(@Nullable ContactModel contactModel) {
 		if (contactModel != null) {
-			return (CONTACT_UID_PREFIX + contactModel.getIdentity()).hashCode();
+			return getUniqueId(contactModel.getIdentity());
 		} else {
 			return 0;
 		}
 	}
 
+	@Override
+	@Deprecated
+	public int getUniqueId(@NonNull String identity) {
+		return (CONTACT_UID_PREFIX + identity).hashCode();
+	}
+
 	@Override
 	public String getUniqueIdString(ContactModel contactModel) {
 		if (contactModel != null) {
@@ -1211,17 +1221,23 @@ public class ContactServiceImpl implements ContactService {
 	@Override
 	public boolean setAvatar(final ContactModel contactModel, File temporaryAvatarFile) throws Exception {
 		if (contactModel != null && temporaryAvatarFile != null) {
-			if (this.fileService.writeContactAvatar(contactModel, temporaryAvatarFile)) {
+			if (this.fileService.writeContactAvatar(contactModel.getIdentity(), temporaryAvatarFile)) {
 				return this.onAvatarSet(contactModel);
 			}
 		}
 		return false;
 	}
 
+	@Override
+	public boolean setAvatar(@NonNull String identity, @Nullable File temporaryAvatarFile) throws Exception {
+		ContactModel contactModel = getByIdentity(identity);
+		return setAvatar(contactModel, temporaryAvatarFile);
+	}
+
 	@Override
 	public boolean setAvatar(final ContactModel contactModel, byte[] avatar) throws Exception {
 		if (contactModel != null && avatar != null) {
-			if (this.fileService.writeContactAvatar(contactModel, avatar)) {
+			if (this.fileService.writeContactAvatar(contactModel.getIdentity(), avatar)) {
 				return this.onAvatarSet(contactModel);
 			}
 		}
@@ -1248,7 +1264,7 @@ public class ContactServiceImpl implements ContactService {
 	@Override
 	public boolean removeAvatar(final ContactModel contactModel) {
 		if(contactModel != null) {
-			if(this.fileService.removeContactAvatar(contactModel)) {
+			if(this.fileService.removeContactAvatar(contactModel.getIdentity())) {
 				this.clearAvatarCache(contactModel);
 
 				// Notify listeners
@@ -1263,6 +1279,14 @@ public class ContactServiceImpl implements ContactService {
 		return false;
 	}
 
+	@Override
+	public void clearAvatarCache(@NonNull String identity) {
+		ContactModel contactModel = getByIdentity(identity);
+		if (contactModel != null) {
+			clearAvatarCache(contactModel);
+		}
+	}
+
 	@Override
 	@WorkerThread
 	@NonNull
@@ -1319,7 +1343,7 @@ public class ContactServiceImpl implements ContactService {
 	private Bitmap getMyProfilePicture() throws ThreemaException {
 		ContactModel myContactModel = getMe();
 		Bitmap myProfilePicture = getAvatar(myContactModel, true, false);
-		if (myProfilePicture == null && fileService.hasContactAvatarFile(myContactModel)) {
+		if (myProfilePicture == null && fileService.hasContactAvatarFile(myContactModel.getIdentity())) {
 			throw new ThreemaException("Could not load profile picture despite having set one");
 		}
 		return myProfilePicture;
@@ -1340,6 +1364,12 @@ public class ContactServiceImpl implements ContactService {
 			data.blobId = blobUploader.upload();
 		} catch (ThreemaException | IOException e) {
 			logger.error("Could not upload contact photo", e);
+
+			if (e instanceof FileNotFoundException && ConfigUtils.isOnPremBuild()) {
+				logger.info("Invalidating auth token");
+				apiService.invalidateAuthToken();
+			}
+
 			return null;
 		}
 		data.size = imageData.length;
@@ -1425,10 +1455,6 @@ public class ContactServiceImpl implements ContactService {
 			throw new InvalidEntryException(R.string.invalid_threema_id);
 		}
 
-		if (contact.getPublicKey() == null) {
-			throw new InvalidEntryException(R.string.connection_error);
-		}
-
 		save(contact);
 
 		return contact;
@@ -1441,7 +1467,7 @@ public class ContactServiceImpl implements ContactService {
 				if (userService.isMe(contactModel.getIdentity())) {
 					return false;
 				}
-				return contactModel.getIdentityType() == IdentityType.NORMAL && !ContactUtil.isEchoEchoOrChannelContact(contactModel);
+				return contactModel.getIdentityType() == IdentityType.NORMAL && !ContactUtil.isEchoEchoOrGatewayContact(contactModel);
 			} else {
 				return contactModel.getIdentityType() == IdentityType.WORK;
 			}
@@ -1450,18 +1476,16 @@ public class ContactServiceImpl implements ContactService {
 	}
 
 	@Override
-	public void setName(ContactModel contact, String firstName, String lastName) {
-		contact.setFirstName(firstName);
-		contact.setLastName(lastName);
-
-		synchronized (this.contactModelCache) {
-			this.contactModelCache.remove(contact.getIdentity());
+	public boolean showBadge(@NonNull ContactModelData contactModelData) {
+		if (ConfigUtils.isWorkBuild()) {
+			if (userService.isMe(contactModelData.identity)) {
+				return false;
+			}
+			return contactModelData.identityType == IdentityType.NORMAL
+				&& !ContactUtil.isEchoEchoOrGatewayContact(contactModelData.identity);
+		} else {
+			return contactModelData.identityType == IdentityType.WORK;
 		}
-
-		save(contact);
-
-		// delete share target shortcut as name is different
-		ShortcutUtil.deleteShareTargetShortcut(getUniqueIdString(contact));
 	}
 
 	/**
@@ -1474,10 +1498,13 @@ public class ContactServiceImpl implements ContactService {
 		String contactLookupUri = null;
 		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
 			if (ContextCompat.checkSelfPermission(ThreemaApplication.getAppContext(), Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) {
-				if (contactModel != null && contactModel.getAndroidContactLookupKey() != null) {
-					Uri lookupUri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_LOOKUP_URI, contactModel.getAndroidContactLookupKey());
-					if (lookupUri != null) {
-						contactLookupUri = lookupUri.toString();
+				if (contactModel != null) {
+					final String androidContactLookupKey = contactModel.getAndroidContactLookupKey();
+					if (androidContactLookupKey != null) {
+						Uri lookupUri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_LOOKUP_URI, androidContactLookupKey);
+						if (lookupUri != null) {
+							contactLookupUri = lookupUri.toString();
+						}
 					}
 				}
 			}
@@ -1523,22 +1550,30 @@ public class ContactServiceImpl implements ContactService {
 			}
 		}
 
-		if (!ContactUtil.isLinked(contactModel)
-			&& (workContact.firstName != null
-			|| workContact.lastName != null)) {
+		if (
+			!contactModel.isLinkedToAndroidContact()
+			&& (workContact.firstName != null || workContact.lastName != null)
+		) {
 			contactModel.setFirstName(workContact.firstName);
 			contactModel.setLastName(workContact.lastName);
 		}
 		contactModel.setIsWork(true);
 		contactModel.setAcquaintanceLevel(AcquaintanceLevel.DIRECT);
-		if (contactModel.getVerificationLevel() != VerificationLevel.FULLY_VERIFIED) {
-			contactModel.setVerificationLevel(VerificationLevel.SERVER_VERIFIED);
+		if (contactModel.verificationLevel != VerificationLevel.FULLY_VERIFIED) {
+			contactModel.verificationLevel = VerificationLevel.SERVER_VERIFIED;
 		}
 		this.save(contactModel);
 
 		return contactModel;
 	}
 
+	@Override
+	public void removeFromCache(@NonNull String identity) {
+		synchronized (this.contactModelCache) {
+			this.contactModelCache.remove(identity);
+		}
+	}
+
 	@Override
 	@WorkerThread
 	public void fetchAndCacheContact(@NonNull String identity) throws APIConnector.HttpConnectionException, APIConnector.NetworkException, MissingPublicKeyException {
@@ -1621,9 +1656,18 @@ public class ContactServiceImpl implements ContactService {
 
 		ContactModel contact = new ContactModel(result.identity, b);
 		contact.setFeatureMask(result.featureMask);
-		contact.setVerificationLevel(VerificationLevel.UNVERIFIED);
+		contact.verificationLevel = VerificationLevel.UNVERIFIED;
 		contact.setDateCreated(new Date());
-		contact.setIdentityType(result.type);
+		switch (result.type) {
+			case 0:
+				contact.setIdentityType(IdentityType.NORMAL);
+				break;
+			case 1:
+				contact.setIdentityType(IdentityType.WORK);
+				break;
+			default:
+				logger.warn("Identity fetch returned invalid identity type: {}", result.type);
+		}
 		switch (result.state) {
 			case IdentityState.ACTIVE:
 				contact.setState(ContactModel.State.ACTIVE);
@@ -1766,9 +1810,18 @@ public class ContactServiceImpl implements ContactService {
 
 		ContactModel contact = new ContactModel(identity, result.publicKey);
 		contact.setFeatureMask(result.featureMask);
-		contact.setVerificationLevel(VerificationLevel.UNVERIFIED);
+		contact.verificationLevel = VerificationLevel.UNVERIFIED;
 		contact.setDateCreated(new Date());
-		contact.setIdentityType(result.type);
+		switch (result.type) {
+			case 0:
+				contact.setIdentityType(IdentityType.NORMAL);
+				break;
+			case 1:
+				contact.setIdentityType(IdentityType.WORK);
+				break;
+			default:
+				logger.warn("Identity fetch returned invalid identity type: {}", result.type);
+		}
 		switch (result.state) {
 			case IdentityState.ACTIVE:
 				contact.setState(ContactModel.State.ACTIVE);

+ 18 - 0
app/src/main/java/ch/threema/app/services/ConversationService.java

@@ -167,6 +167,15 @@ public interface ConversationService {
 	 */
 	int empty(@NonNull ContactModel contactModel);
 
+	/**
+	 * Empty associated conversation (remove all messages).
+	 *
+	 * The message listener onRemoved method will *not* be called for removed messages.
+	 *
+	 * @return the number of removed messages.
+	 */
+	int empty(@NonNull String identity);
+
 	/**
 	 * Empty associated conversation (remove all messages).
 	 *
@@ -193,6 +202,15 @@ public interface ConversationService {
 	 */
 	int delete(@NonNull ContactModel contactModel);
 
+	/**
+	 * Delete the contact conversation by removing all messages, setting `lastUpdate` to null and
+	 * removing it from the cache.
+	 *
+	 * @param identity the identity of the contact
+	 * @return the number of removed messages.
+	 */
+	int delete(@NonNull String identity);
+
 	/**
 	 * Remove the group conversation from the cache, and thus hide it from the conversation list.
 	 *

+ 39 - 10
app/src/main/java/ch/threema/app/services/ConversationServiceImpl.java

@@ -21,6 +21,9 @@
 
 package ch.threema.app.services;
 
+import static ch.threema.app.services.ConversationTagServiceImpl.FIXED_TAG_PIN;
+import static ch.threema.app.services.ConversationTagServiceImpl.FIXED_TAG_UNREAD;
+
 import android.content.Context;
 import android.database.Cursor;
 
@@ -136,7 +139,7 @@ public class ConversationServiceImpl implements ConversationService {
 		this.conversationCache = cacheService.getConversationModelCache();
 		this.conversationTagService = conversationTagService;
 
-		TagModel pinTagModel = conversationTagService.getTagModel(ConversationTagServiceImpl.FIXED_TAG_PIN);
+		TagModel pinTagModel = conversationTagService.getTagModel(FIXED_TAG_PIN);
 		this.pinTag = pinTagModel != null ? pinTagModel.getTag() : "";
 
 		this.unreadTagModel = conversationTagService.getTagModel(ConversationTagServiceImpl.FIXED_TAG_UNREAD);
@@ -402,7 +405,7 @@ public class ConversationServiceImpl implements ConversationService {
 						logger.warn("No result for updating identity {}", contactModel.getIdentity());
 						return;
 					}
-					ConversationModel updatedModel = conversationModelParser.parseResult(result.get(0), null, false);
+					ConversationModel updatedModel = conversationModelParser.parseResult(result.get(0), conversationModel, false);
 					if (updatedModel != null) {
 						// persist tags from original model
 						updatedModel.setIsPinTagged(conversationModel.isPinTagged());
@@ -438,7 +441,18 @@ public class ConversationServiceImpl implements ConversationService {
 	public synchronized ConversationModel refresh(@NonNull MessageReceiver receiver) {
 		switch (receiver.getType()) {
 			case MessageReceiver.Type_CONTACT:
-				return this.refresh(((ContactMessageReceiver)receiver).getContact());
+				// TODO(ANDR-3139): This comparison should be removed once we use the new contact
+				//  model in the webclient. This will allow us to use the new model in the message
+				//  receiver as well.
+				ContactModel providedModel = ((ContactMessageReceiver) receiver).getContact();
+				String identity = providedModel.getIdentity();
+				ContactModel cachedModel = contactService.getByIdentity(identity);
+				if (providedModel != cachedModel) {
+					logger.warn("Several contact model instances are in use. Resetting cache.");
+					contactService.removeFromCache(identity);
+					cachedModel = contactService.getByIdentity(identity);
+				}
+				return this.refresh(cachedModel);
 			case MessageReceiver.Type_GROUP:
 				return this.refresh(((GroupMessageReceiver)receiver).getGroup());
 			case MessageReceiver.Type_DISTRIBUTION_LIST:
@@ -530,8 +544,11 @@ public class ConversationServiceImpl implements ConversationService {
 			this.messageService.remove(m, silentMessageUpdate);
 		}
 
-		// Remove tags
-		this.conversationTagService.removeAll(conversation);
+		// Remove unread tag but not the pinned tag
+		TagModel unreadTagModel = conversationTagService.getTagModel(FIXED_TAG_UNREAD);
+		if (unreadTagModel != null) {
+			this.conversationTagService.removeTag(conversation.getUid(), unreadTagModel);
+		}
 
 		// Update conversation
 		conversation.setLatestMessage(null);
@@ -545,7 +562,12 @@ public class ConversationServiceImpl implements ConversationService {
 
 	@Override
 	public synchronized int empty(@NonNull ContactModel contactModel) {
-		final ConversationModel conversationModel = new ContactConversationModelParser().getCached(contactModel);
+		return empty(contactModel.getIdentity());
+	}
+
+	@Override
+	public int empty(@NonNull String identity) {
+		final ConversationModel conversationModel = new ContactConversationModelParser().getCached(identity);
 		if (conversationModel != null) {
 			return this.empty(conversationModel, true);
 		}
@@ -572,15 +594,22 @@ public class ConversationServiceImpl implements ConversationService {
 
 	@Override
 	public synchronized int delete(@NonNull ContactModel contactModel) {
+		return delete(contactModel.getIdentity());
+	}
+
+	@Override
+	public synchronized int delete(@NonNull String identity) {
 		// Empty chat (if it isn't already empty)
-		final int removedCount = this.empty(contactModel);
+		final int removedCount = this.empty(identity);
 
 		// Clear lastUpdate
-		this.contactService.clearLastUpdate(contactModel.getIdentity());
+		this.contactService.clearLastUpdate(identity);
 
 		// Remove from cache and notify listeners
-		final ConversationModel conversationModel = new ContactConversationModelParser().getCached(contactModel);
+		final ConversationModel conversationModel = new ContactConversationModelParser().getCached(identity);
 		if (conversationModel != null) {
+			// Remove tags
+			this.conversationTagService.removeAll(conversationModel);
 			this.removeFromCache(conversationModel);
 		}
 
@@ -1009,7 +1038,7 @@ public class ConversationServiceImpl implements ConversationService {
 				"SELECT c.identity, IFNULL(m.messageCount, 0) AS messageCount, c.lastUpdate, m.latestMessageId " +
 				"FROM contacts c " +
 				"LEFT JOIN message_info m ON c.identity = m.identity " +
-				"WHERE c.lastUpdate IS NOT NULL AND c.isHidden != 1 AND c.isArchived = " + (archived ? "1" : "0")
+				"WHERE c.lastUpdate IS NOT NULL AND c.acquaintanceLevel != 1 AND c.isArchived = " + (archived ? "1" : "0")
 			);
 		}
 

+ 25 - 20
app/src/main/java/ch/threema/app/services/FileService.java

@@ -128,14 +128,9 @@ public interface FileService {
 	 */
 	File createWallpaperFile(MessageReceiver messageReceiver) throws IOException;
 
-	/**
-	 *
-	 * @param contactModel
-	 * @return true if avatar file exists
-	 */
-	boolean hasContactAvatarFile(ContactModel contactModel);
+	boolean hasContactAvatarFile(@NonNull String identity);
 
-	boolean hasContactPhotoFile(ContactModel contactModel);
+	boolean hasContactPhotoFile(@NonNull String identity);
 
 	/**
 	 * decrypt a file and save into a new one
@@ -224,65 +219,65 @@ public interface FileService {
 	/**
 	 * write the contact avatar
 	 */
-	boolean writeContactAvatar(ContactModel contactModel, File file) throws Exception;
+	boolean writeContactAvatar(@NonNull String identity, File file) throws Exception;
 
 	/**
 	 * write the contact avatar
 	 */
-	boolean writeContactAvatar(ContactModel contactModel, byte[] avatarFile) throws Exception;
+	boolean writeContactAvatar(@NonNull String identity, byte[] avatarFile) throws Exception;
 
 	/**
 	 * write the contact photo received by the contact
 	 */
-	boolean writeContactPhoto(ContactModel contactModel, byte[] encryptedBlob) throws Exception;
+	boolean writeContactPhoto(@NonNull String identity, byte[] encryptedBlob) throws Exception;
 
 	/**
 	 * write the contact avatar from Android's address book
 	 */
-	boolean writeAndroidContactAvatar(ContactModel contactModel, byte[] avatarFile) throws Exception;
+	boolean writeAndroidContactAvatar(@NonNull String identity, byte[] avatarFile) throws Exception;
 
 	/**
 	 * return the decrypted bitmap of a contact avatar
 	 * if no file exists, null will be returned
 	 */
-	Bitmap getContactAvatar(ContactModel contactModel) throws Exception;
+	Bitmap getContactAvatar(@NonNull String identity) throws Exception;
 
-	Bitmap getAndroidContactAvatar(ContactModel contactModel) throws Exception;
+	Bitmap getAndroidContactAvatar(@NonNull ContactModel contactModel) throws Exception;
 
 	/**
 	 * Return a input stream of a local saved contact avatar
 	 */
-	InputStream getContactAvatarStream(ContactModel contactModel) throws IOException, MasterKeyLockedException;
+	InputStream getContactAvatarStream(@NonNull String identity) throws IOException, MasterKeyLockedException;
 
 	/**
 	 * Return a input stream of a contact photo
 	 */
-	InputStream getContactPhotoStream(ContactModel contactModel) throws IOException, MasterKeyLockedException;
+	InputStream getContactPhotoStream(@NonNull String identity) throws IOException, MasterKeyLockedException;
 
 	/**
 	 * return the decrypted bitmap of a contact-provided profile picture
 	 * returns null if no file exists
 	 */
-	Bitmap getContactPhoto(ContactModel contactModel) throws Exception;
+	Bitmap getContactPhoto(@NonNull String identity) throws Exception;
 
 	/**
 	 * remove the saved avatar
 	 * return true if the avatar was deleted, false if the remove failed or no avatar file exists
 	 */
-	boolean removeContactAvatar(ContactModel contactModel);
+	boolean removeContactAvatar(@NonNull String identity);
 
 	/**
 	 * remove the saved profile pic for this contact
-	 * @param contactModel
+	 * @param identity the identity of the contact
 	 * @return true if avatar was deleted, false if the remove failed or no avatar file exists
 	 */
-	boolean removeContactPhoto(ContactModel contactModel);
+	boolean removeContactPhoto(@NonNull String identity);
 
 	/**
 	 * remove the saved avatar from Android's address book
 	 * return true if the avatar was deleted, false if the remove failed or no avatar file exists
 	 */
-	boolean removeAndroidContactAvatar(ContactModel contactModel);
+	boolean removeAndroidContactAvatar(@NonNull String identity);
 
 	/**
 	 * remove all avatars in the respective directory
@@ -363,6 +358,16 @@ public interface FileService {
 	@NonNull
 	Uri getTempShareFileUri(@NonNull Bitmap bitmap) throws IOException;
 
+	/**
+	 * Copy the decrypted thumbnail to a temporary file accessible through our FileProvider and return the Uri of the temporary file
+	 * @param messageModel Message Model used as the source for the thumbnail
+	 * @param maxSize Maximum size of the thumbnail in bytes. Set to Integer.MAX_VALUE if no limit
+	 * @return Uri of the temporary file or null if the thumbnail does not exist, is too large or an error occurred
+	 */
+	@WorkerThread
+	@Nullable
+	Uri getThumbnailShareFileUri(AbstractMessageModel messageModel, int maxSize);
+
 	interface OnDecryptedFileComplete {
 		void complete(File decryptedFile);
 		void error(String message);

+ 83 - 59
app/src/main/java/ch/threema/app/services/FileServiceImpl.java

@@ -491,15 +491,15 @@ public class FileServiceImpl implements FileService {
 	}
 
 	@Override
-	public boolean hasContactAvatarFile(ContactModel contactModel) {
-		File avatar = getContactAvatarFile(contactModel);
+	public boolean hasContactAvatarFile(@NonNull String identity) {
+		File avatar = getContactAvatarFile(identity);
 
 		return avatar != null && avatar.exists();
 	}
 
 	@Override
-	public boolean hasContactPhotoFile(ContactModel contactModel) {
-		File avatar = getContactPhotoFile(contactModel);
+	public boolean hasContactPhotoFile(@NonNull String identity) {
+		File avatar = getContactPhotoFile(identity);
 
 		return avatar != null && avatar.exists();
 	}
@@ -516,16 +516,16 @@ public class FileServiceImpl implements FileService {
 		return null;
 	}
 
-	private File getContactAvatarFile(ContactModel contactModel) {
-		return getPictureFile(getAvatarDirPath(), ".c-", contactModel.getIdentity());
+	private File getContactAvatarFile(@NonNull String identity) {
+		return getPictureFile(getAvatarDirPath(), ".c-", identity);
 	}
 
-	private File getContactPhotoFile(ContactModel contactModel) {
-		return getPictureFile(getAvatarDirPath(), ".p-", contactModel.getIdentity());
+	private File getContactPhotoFile(@NonNull String identity) {
+		return getPictureFile(getAvatarDirPath(), ".p-", identity);
 	}
 
-	private File getAndroidContactAvatarFile(ContactModel contactModel) {
-		return getPictureFile(getAvatarDirPath(), ".a-", contactModel.getIdentity());
+	private File getAndroidContactAvatarFile(@NonNull String identity) {
+		return getPictureFile(getAvatarDirPath(), ".a-", identity);
 	}
 
 	@Override
@@ -852,6 +852,7 @@ public class FileServiceImpl implements FileService {
 		return new File(getAppDataPathAbsolute(), "." + uid);
 	}
 
+	@Nullable
 	private File getMessageThumbnail(@Nullable AbstractMessageModel messageModel) {
 		// locations do not have a file, do not check for existing!
 		if (messageModel == null) {
@@ -966,102 +967,94 @@ public class FileServiceImpl implements FileService {
 	}
 
 	@Override
-	public boolean writeContactAvatar(ContactModel contactModel, File file) throws Exception {
-		return this.decryptFileToFile(file, this.getContactAvatarFile(contactModel));
+	public boolean writeContactAvatar(@NonNull String identity, File file) throws Exception {
+		return this.decryptFileToFile(file, this.getContactAvatarFile(identity));
 	}
 
 	@Override
-	public boolean writeContactAvatar(ContactModel contactModel, byte[] avatarFile) throws Exception {
-		return this.writeFile(avatarFile, this.getContactAvatarFile(contactModel));
+	public boolean writeContactAvatar(@NonNull String identity, byte[] avatarFile) throws Exception {
+		return this.writeFile(avatarFile, this.getContactAvatarFile(identity));
 	}
 
 	@Override
-	public boolean writeContactPhoto(ContactModel contactModel, byte[] encryptedBlob) throws Exception {
-		return this.writeFile(encryptedBlob, this.getContactPhotoFile(contactModel));
+	public boolean writeContactPhoto(@NonNull String identity, byte[] encryptedBlob) throws Exception {
+		return this.writeFile(encryptedBlob, this.getContactPhotoFile(identity));
 	}
 
 	@Override
-	public boolean writeAndroidContactAvatar(ContactModel contactModel, byte[] avatarFile) throws Exception {
-		return this.writeFile(avatarFile, this.getAndroidContactAvatarFile(contactModel));
+	public boolean writeAndroidContactAvatar(@NonNull String identity, byte[] avatarFile) throws Exception {
+		return this.writeFile(avatarFile, this.getAndroidContactAvatarFile(identity));
 	}
 
 	@Override
-	public Bitmap getContactAvatar(ContactModel contactModel) throws Exception {
+	public Bitmap getContactAvatar(@NonNull String identity) throws Exception {
 		if (this.masterKey.isLocked()) {
 			throw new Exception("no masterkey or locked");
 		}
 
-		return decryptBitmapFromFile(this.getContactAvatarFile(contactModel));
+		return decryptBitmapFromFile(this.getContactAvatarFile(identity));
 	}
 
 	@Override
-	public Bitmap getAndroidContactAvatar(ContactModel contactModel) throws Exception {
+	public Bitmap getAndroidContactAvatar(@NonNull ContactModel contactModel) throws Exception {
 		if (this.masterKey.isLocked()) {
 			throw new Exception("no masterkey or locked");
 		}
 
 		long now = System.currentTimeMillis();
-		long expiration = contactModel.getAvatarExpires() != null ? contactModel.getAvatarExpires().getTime() : 0;
+		long expiration = contactModel.getLocalAvatarExpires() != null ? contactModel.getLocalAvatarExpires().getTime() : 0;
 		if (expiration < now) {
 			ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 			if (serviceManager != null) {
-					if (AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contactModel)) {
-						ContactService contactService = serviceManager.getContactService();
-						if (contactService != null) {
-							contactService.save(contactModel);
-						}
-					}
+				if (AndroidContactUtil.getInstance().updateAvatarByAndroidContact(contactModel)) {
+					ContactService contactService = serviceManager.getContactService();
+					contactService.save(contactModel);
+				}
 			}
 		}
 
-		return decryptBitmapFromFile(this.getAndroidContactAvatarFile(contactModel));
+		return decryptBitmapFromFile(this.getAndroidContactAvatarFile(contactModel.getIdentity()));
 	}
 
 	@Override
-	public InputStream getContactAvatarStream(ContactModel contactModel) throws IOException, MasterKeyLockedException {
-		if (contactModel != null) {
-			File f = this.getContactAvatarFile(contactModel);
-			if (f != null && f.exists() && f.length() > 0) {
-				return masterKey.getCipherInputStream(new FileInputStream(f));
-			}
+	public InputStream getContactAvatarStream(@NonNull String identity) throws IOException, MasterKeyLockedException {
+		File f = this.getContactAvatarFile(identity);
+		if (f != null && f.exists() && f.length() > 0) {
+			return masterKey.getCipherInputStream(new FileInputStream(f));
 		}
 
 		return null;
 	}
 
 	@Override
-	public InputStream getContactPhotoStream(ContactModel contactModel) throws IOException, MasterKeyLockedException {
-		if (contactModel != null) {
-			File f = this.getContactPhotoFile(contactModel);
-			if (f != null && f.exists() && f.length() > 0) {
-				return masterKey.getCipherInputStream(new FileInputStream(f));
-			}
+	public InputStream getContactPhotoStream(@NonNull String identity) throws IOException, MasterKeyLockedException {
+		File f = this.getContactPhotoFile(identity);
+		if (f != null && f.exists() && f.length() > 0) {
+			return masterKey.getCipherInputStream(new FileInputStream(f));
 		}
 		return null;
 	}
 
 	@Override
-	public Bitmap getContactPhoto(ContactModel contactModel) throws Exception {
+	public Bitmap getContactPhoto(@NonNull String identity) throws Exception {
 		if (this.masterKey.isLocked()) {
 			throw new Exception("no masterkey or locked");
 		}
 
 		if (this.preferenceService.getProfilePicReceive()) {
-			return decryptBitmapFromFile(this.getContactPhotoFile(contactModel));
+			return decryptBitmapFromFile(this.getContactPhotoFile(identity));
 		}
 		return null;
 	}
 
-	private Bitmap decryptBitmapFromFile(File file) throws Exception {
-		if (file.exists()) {
+	private Bitmap decryptBitmapFromFile(@Nullable File file) throws Exception {
+		if (file != null && file.exists()) {
 			InputStream inputStream = masterKey.getCipherInputStream(new FileInputStream(file));
 			if (inputStream != null) {
-				try {
+				try (inputStream) {
 					return BitmapFactory.decodeStream(inputStream);
 				} catch (Exception e) {
 					logger.error("Exception", e);
-				} finally {
-					inputStream.close();
 				}
 			}
 		}
@@ -1069,20 +1062,20 @@ public class FileServiceImpl implements FileService {
 	}
 
 	@Override
-	public boolean removeContactAvatar(ContactModel contactModel) {
-		File f = this.getContactAvatarFile(contactModel);
+	public boolean removeContactAvatar(@NonNull String identity) {
+		File f = this.getContactAvatarFile(identity);
 		return f != null && f.exists() && f.delete();
 	}
 
 	@Override
-	public boolean removeContactPhoto(ContactModel contactModel) {
-		File f = this.getContactPhotoFile(contactModel);
+	public boolean removeContactPhoto(@NonNull String identity) {
+		File f = this.getContactPhotoFile(identity);
 		return f != null && f.exists() && f.delete();
 	}
 
 	@Override
-	public boolean removeAndroidContactAvatar(ContactModel contactModel) {
-		File f = this.getAndroidContactAvatarFile(contactModel);
+	public boolean removeAndroidContactAvatar(@NonNull String identity) {
+		File f = this.getAndroidContactAvatarFile(identity);
 		return f != null && f.exists() && f.delete();
 	}
 
@@ -1339,13 +1332,13 @@ public class FileServiceImpl implements FileService {
 	}
 
 	/**
-	 * Get an Uri for the destination file that can be shared to other apps. On Android 5+ our own content provider will be used to serve the file.
+	 * Get an Uri for the destination file that can be shared to other apps. Our own content provider will be used to serve the file.
 	 * @param destFile File to get an Uri for
 	 * @param filename Desired filename for this file. Can be different from the filename of destFile
-	 * @return Uri (Content Uri on Android 5+, File Uri otherwise)
+	 * @return Uri (Content Uri)
 	 */
 	@Override
-	public Uri getShareFileUri(@NonNull File destFile, @Nullable String filename) {
+	public Uri getShareFileUri(@Nullable File destFile, @Nullable String filename) {
 		if (destFile != null) {
 			// see https://code.google.com/p/android/issues/detail?id=76683
 			return NamedFileProvider.getUriForFile(ThreemaApplication.getAppContext(), ThreemaApplication.getAppContext().getPackageName() + ".fileprovider", destFile, filename);
@@ -1575,9 +1568,9 @@ public class FileServiceImpl implements FileService {
 		return new File(getAppDataPathAbsolute(),"appicon_" + key + ".png");
 	}
 
-	@NonNull
 	@Override
-	public Uri getTempShareFileUri(Bitmap bitmap) throws IOException {
+	@NonNull
+	public Uri getTempShareFileUri(@NonNull Bitmap bitmap) throws IOException {
 		File tempQrCodeFile = createTempFile(FileUtil.getMediaFilenamePrefix(), ".png");
 		try (FileOutputStream fos = new FileOutputStream(tempQrCodeFile)) {
 			ByteArrayOutputStream bos = new ByteArrayOutputStream();
@@ -1588,4 +1581,35 @@ public class FileServiceImpl implements FileService {
 		return getShareFileUri(tempQrCodeFile, null);
 	}
 
+	@Override
+	@Nullable
+	@WorkerThread
+	public Uri getThumbnailShareFileUri(AbstractMessageModel messageModel, int maxSize) {
+		try {
+			final File inputFile = getMessageThumbnail(messageModel);
+			if (inputFile != null && inputFile.exists()) {
+				String thumbnailMimeType = messageModel.getFileData().getThumbnailMimeType();
+				if (thumbnailMimeType != null) {
+					String prefix = FileUtil.getMediaFilenamePrefix(messageModel);
+					final File outputFile = createTempFile(prefix, MimeUtil.MIME_TYPE_IMAGE_PNG.equals(thumbnailMimeType) ? ".png" : ".jpg", false);
+
+					try (CipherInputStream inputStream = getDecryptedMessageThumbnailStream(messageModel)) {
+						if (inputStream != null) {
+							try (OutputStream outputStream = context.getContentResolver().openOutputStream(Uri.fromFile(outputFile))) {
+								int numBytes = IOUtils.copy(inputStream, outputStream);
+								if (numBytes > 0 && numBytes <= maxSize) {
+									return getShareFileUri(outputFile, messageModel.getFileData().getFileName());
+								}
+							}
+						}
+					}
+				}
+			}
+		} catch (Exception e) {
+			logger.error("Exception fetching thumbnail", e);
+		}
+
+		logger.debug("Could not fetch thumbnail");
+		return null;
+	}
 }

部分文件因文件數量過多而無法顯示