Threema 2 жил өмнө
parent
commit
5d1b9a58f3
100 өөрчлөгдсөн 2346 нэмэгдсэн , 1280 устгасан
  1. 5 5
      app/build.gradle
  2. 160 109
      app/src/androidTest/java/ch/threema/app/voip/SdpTest.java
  3. BIN
      app/src/foss_based/assets/emojis/activity-0.png
  4. BIN
      app/src/foss_based/assets/emojis/activity-1.png
  5. BIN
      app/src/foss_based/assets/emojis/flags-0.png
  6. BIN
      app/src/foss_based/assets/emojis/food-0.png
  7. BIN
      app/src/foss_based/assets/emojis/nature-0.png
  8. BIN
      app/src/foss_based/assets/emojis/objects-0.png
  9. BIN
      app/src/foss_based/assets/emojis/people-0.png
  10. BIN
      app/src/foss_based/assets/emojis/people-1.png
  11. BIN
      app/src/foss_based/assets/emojis/people-2.png
  12. BIN
      app/src/foss_based/assets/emojis/people-3.png
  13. BIN
      app/src/foss_based/assets/emojis/people-4.png
  14. BIN
      app/src/foss_based/assets/emojis/people-5.png
  15. BIN
      app/src/foss_based/assets/emojis/people-6.png
  16. BIN
      app/src/foss_based/assets/emojis/people-7.png
  17. BIN
      app/src/foss_based/assets/emojis/people-8.png
  18. BIN
      app/src/foss_based/assets/emojis/people-9.png
  19. BIN
      app/src/foss_based/assets/emojis/symbols-0.png
  20. BIN
      app/src/foss_based/assets/emojis/travel-0.png
  21. 3 0
      app/src/foss_based/assets/license.html
  22. 1 1
      app/src/main/AndroidManifest.xml
  23. 3 18
      app/src/main/java/ch/threema/app/BuildFlavor.java
  24. 5 1
      app/src/main/java/ch/threema/app/ThreemaApplication.java
  25. 26 11
      app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java
  26. 202 194
      app/src/main/java/ch/threema/app/activities/HomeActivity.java
  27. 49 33
      app/src/main/java/ch/threema/app/activities/ImagePaintActivity.java
  28. 57 15
      app/src/main/java/ch/threema/app/activities/ServerMessageActivity.java
  29. 13 10
      app/src/main/java/ch/threema/app/activities/StorageManagementActivity.java
  30. 18 26
      app/src/main/java/ch/threema/app/activities/wizard/WizardBaseActivity.java
  31. 45 12
      app/src/main/java/ch/threema/app/activities/wizard/WizardIDRestoreActivity.java
  32. 42 10
      app/src/main/java/ch/threema/app/activities/wizard/WizardSafeRestoreActivity.java
  33. 22 17
      app/src/main/java/ch/threema/app/adapters/MessageListAdapter.java
  34. 166 194
      app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java
  35. 18 17
      app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java
  36. 2 2
      app/src/main/java/ch/threema/app/fragments/mediaviews/VideoViewFragment.java
  37. 3 1
      app/src/main/java/ch/threema/app/managers/ServiceManager.java
  38. 7 2
      app/src/main/java/ch/threema/app/motionviews/widget/FaceEntity.java
  39. 6 1
      app/src/main/java/ch/threema/app/motionviews/widget/ImageEntity.java
  40. 17 1
      app/src/main/java/ch/threema/app/motionviews/widget/MotionEntity.java
  41. 3 3
      app/src/main/java/ch/threema/app/motionviews/widget/MotionView.java
  42. 6 1
      app/src/main/java/ch/threema/app/motionviews/widget/PathEntity.java
  43. 15 2
      app/src/main/java/ch/threema/app/motionviews/widget/TextEntity.java
  44. 33 39
      app/src/main/java/ch/threema/app/preference/SettingsAppearanceFragment.kt
  45. 2 2
      app/src/main/java/ch/threema/app/processors/MessageProcessor.java
  46. 15 6
      app/src/main/java/ch/threema/app/services/GroupService.java
  47. 32 19
      app/src/main/java/ch/threema/app/services/GroupServiceImpl.java
  48. 5 3
      app/src/main/java/ch/threema/app/services/MessageServiceImpl.java
  49. 0 1
      app/src/main/java/ch/threema/app/services/NotificationServiceImpl.java
  50. 19 0
      app/src/main/java/ch/threema/app/services/PreferenceService.java
  51. 27 13
      app/src/main/java/ch/threema/app/services/PreferenceServiceImpl.java
  52. 13 3
      app/src/main/java/ch/threema/app/services/messageplayer/AudioMessagePlayer.java
  53. 45 0
      app/src/main/java/ch/threema/app/services/systemupdate/SystemUpdateToVersion81.kt
  54. 30 2
      app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeService.java
  55. 25 40
      app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeServiceImpl.java
  56. 105 32
      app/src/main/java/ch/threema/app/ui/OngoingCallNoticeView.kt
  57. 260 155
      app/src/main/java/ch/threema/app/ui/OpenBallotNoticeView.java
  58. 103 40
      app/src/main/java/ch/threema/app/ui/PaintSelectionPopup.java
  59. 55 0
      app/src/main/java/ch/threema/app/ui/ServerMessageViewModel.kt
  60. 6 3
      app/src/main/java/ch/threema/app/utils/BitmapUtil.java
  61. 3 8
      app/src/main/java/ch/threema/app/utils/ConfigUtils.java
  62. 6 2
      app/src/main/java/ch/threema/app/utils/DeviceCookieManagerImpl.java
  63. 0 17
      app/src/main/java/ch/threema/app/utils/IntentDataUtil.java
  64. 8 4
      app/src/main/java/ch/threema/app/utils/MediaAdapterManager.kt
  65. 5 0
      app/src/main/java/ch/threema/app/utils/MediaPlayerStateWrapper.java
  66. 3 4
      app/src/main/java/ch/threema/app/utils/VideoUtil.java
  67. 14 5
      app/src/main/java/ch/threema/app/voip/activities/GroupCallActivity.kt
  68. 17 5
      app/src/main/java/ch/threema/app/voip/groupcall/GroupCallManager.kt
  69. 63 25
      app/src/main/java/ch/threema/app/voip/groupcall/GroupCallManagerImpl.kt
  70. 9 6
      app/src/main/java/ch/threema/app/voip/groupcall/GroupCallObserver.kt
  71. 11 1
      app/src/main/java/ch/threema/app/voip/groupcall/service/GroupCallControllerImpl.kt
  72. 26 14
      app/src/main/java/ch/threema/app/voip/groupcall/service/GroupCallService.kt
  73. 3 6
      app/src/main/java/ch/threema/app/voip/groupcall/sfu/GroupCall.kt
  74. 2 0
      app/src/main/java/ch/threema/app/voip/groupcall/sfu/GroupCallController.kt
  75. 12 4
      app/src/main/java/ch/threema/app/voip/groupcall/sfu/connection/Connecting.kt
  76. 1 1
      app/src/main/java/ch/threema/app/voip/groupcall/sfu/connection/Failed.kt
  77. 2 5
      app/src/main/java/ch/threema/app/voip/groupcall/sfu/webrtc/ConnectionCtx.kt
  78. 8 3
      app/src/main/java/ch/threema/app/voip/services/VoipStateService.java
  79. 7 12
      app/src/main/java/ch/threema/app/voip/viewmodel/GroupCallViewModel.kt
  80. 2 2
      app/src/main/java/ch/threema/app/webclient/services/instance/message/receiver/TextMessageCreateHandler.java
  81. 11 5
      app/src/main/java/ch/threema/app/workers/ThreemaSafeUploadWorker.kt
  82. 34 0
      app/src/main/java/ch/threema/app/workers/WorkSyncWorker.kt
  83. 39 4
      app/src/main/java/ch/threema/storage/DatabaseServiceNew.java
  84. 98 0
      app/src/main/java/ch/threema/storage/factories/ServerMessageModelFactory.kt
  85. 22 6
      app/src/main/java/ch/threema/storage/models/ServerMessageModel.java
  86. 5 0
      app/src/main/res/drawable/ic_bring_to_front.xml
  87. 15 14
      app/src/main/res/layout/item_message_list.xml
  88. 92 70
      app/src/main/res/layout/popup_paint_selection.xml
  89. 24 7
      app/src/main/res/layout/view_ongoing_call_notice.xml
  90. 1 1
      app/src/main/res/menu/action_compose_message.xml
  91. 100 1
      app/src/main/res/values-be-rBY/strings.xml
  92. 2 0
      app/src/main/res/values-be-rBY/voip_strings.xml
  93. 1 0
      app/src/main/res/values-be-rBY/webclient_strings.xml
  94. 1 0
      app/src/main/res/values-it/strings.xml
  95. 7 1
      app/src/main/res/values-ja/strings.xml
  96. 2 0
      app/src/main/res/values/preferences_strings.xml
  97. 4 0
      app/src/main/res/xml/preference_developers.xml
  98. 7 0
      app/src/onprem/res/xml/app_restrictions.xml
  99. 7 0
      app/src/red/res/xml/app_restrictions.xml
  100. 3 3
      app/src/test/java/ch/threema/architecture/StorageLayerTest.java

+ 5 - 5
app/build.gradle

@@ -17,7 +17,7 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 }
 
 // version codes
-def app_version = "5.0.4.2"
+def app_version = "5.0.5"
 def beta_suffix = "" // with leading dash
 
 /**
@@ -96,7 +96,7 @@ android {
         vectorDrawables.useSupportLibrary = true
         applicationId "ch.threema.app"
         testApplicationId 'ch.threema.app.test'
-        versionCode 794
+        versionCode 799
         versionName "${app_version}${beta_suffix}"
         resValue "string", "app_name", "Threema"
         // package name used for sync adapter - needs to match mime types below
@@ -723,8 +723,8 @@ dependencies {
     kapt 'androidx.room:room-compiler:2.4.3'
 
     implementation 'com.google.android.material:material:1.7.0'
-    implementation 'com.google.android.exoplayer:exoplayer-core:2.18.2'
-    implementation 'com.google.android.exoplayer:exoplayer-ui:2.18.2'
+    implementation 'com.google.android.exoplayer:exoplayer-core:2.18.5'
+    implementation 'com.google.android.exoplayer:exoplayer-ui:2.18.5'
     implementation 'com.google.zxing:core:3.3.3' // zxing 3.4 crashes on API < 24
     implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.57' // make sure to update this in domain's build.gradle as well
 
@@ -741,7 +741,7 @@ dependencies {
     }
 
     implementation 'org.saltyrtc:chunked-dc:1.0.1'
-    implementation 'ch.threema:webrtc-android:108.0.0'
+    implementation 'ch.threema:webrtc-android:110.0.0'
     implementation('org.saltyrtc:saltyrtc-task-webrtc:0.18.1') {
         exclude module: 'saltyrtc-client'
     }

+ 160 - 109
app/src/androidTest/java/ch/threema/app/voip/SdpTest.java

@@ -191,7 +191,7 @@ public class SdpTest {
 				"a=ssrc:3148626149 msid:3MACALL 3MACALLa0\r\n" +
 				"a=ssrc:3148626149 mslabel:3MACALL\r\n" +
 				"a=ssrc:3148626149 label:3MACALLa0\r\n" +
-				"m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 127 123 125\r\n" +
+				"m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 35 36 127 123 125 37\r\n" +
 				"c=IN IP4 0.0.0.0\r\n" +
 				"a=rtcp:9 IN IP4 0.0.0.0\r\n" +
 				"a=ice-ufrag:f30j\r\n" +
@@ -253,10 +253,22 @@ public class SdpTest {
 				"a=fmtp:100 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r\n" +
 				"a=rtpmap:101 rtx/90000\r\n" +
 				"a=fmtp:101 apt=100\r\n" +
+				"a=rtpmap:35 AV1/90000\r\n" +
+				"a=rtcp-fb:35 goog-remb\r\n" +
+				"a=rtcp-fb:35 transport-cc\r\n" +
+				"a=rtcp-fb:35 ccm fir\r\n" +
+				"a=rtcp-fb:35 nack\r\n" +
+				"a=rtcp-fb:35 nack pli\r\n" +
+				"a=rtpmap:36 rtx/90000\r\n" +
+				"a=fmtp:36 apt=35\r\n" +
 				"a=rtpmap:127 red/90000\r\n" +
 				"a=rtpmap:123 rtx/90000\r\n" +
 				"a=fmtp:123 apt=127\r\n" +
 				"a=rtpmap:125 ulpfec/90000\r\n" +
+				"a=rtpmap:37 flexfec-03/90000\r\n" +
+				"a=rtcp-fb:37 goog-remb\r\n" +
+				"a=rtcp-fb:37 transport-cc\r\n" +
+				"a=fmtp:37 repair-window=10000000\r\n" +
 				"a=ssrc-group:FID 2961420724 927121398\r\n" +
 				"a=ssrc:2961420724 cname:xmp2nT2LrKeffKAn\r\n" +
 				"a=ssrc:2961420724 msid:3MACALL 3MACALLv0\r\n" +
@@ -332,149 +344,188 @@ public class SdpTest {
 	}
 
 	private void validateDescription(@NonNull SessionDescription sdp, boolean videoEnabled, boolean isOffer) {
-		// Part 1: Header, audio, first part of video (without rtpmap)
-		final List<String> expectedMatchesPart1 = new ArrayList<>();
-		expectedMatchesPart1.add("^v=0$");
-		expectedMatchesPart1.add("^o=- \\d+ \\d IN IP4 127.0.0.1$");
-		expectedMatchesPart1.add("^s=-$");
-		expectedMatchesPart1.add("^t=0 0$");
-		expectedMatchesPart1.add("^a=group:BUNDLE( \\d+)+");
+		final List<String> actualLines = Arrays.asList(sdp.description.split("\r\n"));
+		Log.d(TAG, "SDP:\n" + sdp.description);
+		final List<String> matches = new ArrayList<>();
+		int lineOffset = 0;
+
+		// Session lines
+		matches.add("^v=0$");
+		matches.add("^o=- \\d+ \\d IN IP4 127.0.0.1$");
+		matches.add("^s=-$");
+		matches.add("^t=0 0$");
+		matches.add("^a=group:BUNDLE( \\d+)+");
 		if (videoEnabled) {
-			expectedMatchesPart1.add("^a=extmap-allow-mixed$");
+			matches.add("^a=extmap-allow-mixed$");
 		}
-		expectedMatchesPart1.add("^a=msid-semantic: WMS 3MACALL$");
-		expectedMatchesPart1.add("^m=audio 9 UDP/TLS/RTP/SAVPF \\d+$");
-		expectedMatchesPart1.add("^c=IN IP4 0.0.0.0$");
-		expectedMatchesPart1.add("^a=rtcp:9 IN IP4 0.0.0.0$");
-		expectedMatchesPart1.add("^a=ice-ufrag:[^ ]+$");
-		expectedMatchesPart1.add("^a=ice-pwd:[^ ]+$");
-		expectedMatchesPart1.add("^a=ice-options:trickle renomination$");
-		expectedMatchesPart1.add("^a=fingerprint:sha-256 [^ ]+$");
-		expectedMatchesPart1.add("^a=setup:(actpass|active)");
-		expectedMatchesPart1.add("^a=mid:0");
+		matches.add("^a=msid-semantic: WMS 3MACALL$");
+		lineOffset += matchEachLine(matches, actualLines, lineOffset);
+
+		// Audio lines
+		matches.add("^m=audio 9 UDP/TLS/RTP/SAVPF \\d+$");
+		matches.add("^c=IN IP4 0.0.0.0$");
+		matches.add("^a=rtcp:9 IN IP4 0.0.0.0$");
+		matches.add("^a=ice-ufrag:[^ ]+$");
+		matches.add("^a=ice-pwd:[^ ]+$");
+		matches.add("^a=ice-options:trickle renomination$");
+		matches.add("^a=fingerprint:sha-256 [^ ]+$");
+		matches.add("^a=setup:(actpass|active)");
+		matches.add("^a=mid:0");
 		if (videoEnabled) {
 			if (isOffer) {
-				expectedMatchesPart1.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time$");
-				expectedMatchesPart1.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01$");
-				expectedMatchesPart1.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid$");
+				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time$");
+				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01$");
+				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid$");
 			} else {
-				expectedMatchesPart1.add("^a=extmap:15 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time$");
-				expectedMatchesPart1.add("^a=extmap:16 urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01$");
-				expectedMatchesPart1.add("^a=extmap:17 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid$");
+				matches.add("^a=extmap:15 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time$");
+				matches.add("^a=extmap:16 urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01$");
+				matches.add("^a=extmap:17 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid$");
 			}
 		}
-		expectedMatchesPart1.add("^a=sendrecv$");
-		expectedMatchesPart1.add("^a=msid:3MACALL 3MACALLa0");
-		expectedMatchesPart1.add("^a=rtcp-mux$");
-		expectedMatchesPart1.add("^a=rtpmap:\\d+ opus/48000/2$");
-		expectedMatchesPart1.add("^a=rtcp-fb:\\d+ transport-cc$");
-		expectedMatchesPart1.add("^a=fmtp:\\d+ minptime=10;useinbandfec=1;stereo=0;sprop-stereo=0;cbr=1$");
-		expectedMatchesPart1.add("^a=ssrc:\\d+ cname:[^ ]+$");
+		matches.add("^a=sendrecv$");
+		matches.add("^a=msid:3MACALL 3MACALLa0");
+		matches.add("^a=rtcp-mux$");
+		matches.add("^a=rtpmap:\\d+ opus/48000/2$");
+		matches.add("^a=rtcp-fb:\\d+ transport-cc$");
+		matches.add("^a=fmtp:\\d+ minptime=10;useinbandfec=1;stereo=0;sprop-stereo=0;cbr=1$");
+		matches.add("^a=ssrc:\\d+ cname:[^ ]+$");
 		if (isOffer) {
-			expectedMatchesPart1.add("^a=ssrc:\\d+ msid:3MACALL 3MACALLa0$");
+			matches.add("^a=ssrc:\\d+ msid:3MACALL 3MACALLa0$");
 		}
+		lineOffset += matchEachLine(matches, actualLines, lineOffset);
+
+		// Video lines
 		if (videoEnabled) {
-			expectedMatchesPart1.add("^m=video 9 UDP/TLS/RTP/SAVPF( \\d+)+$");
-			expectedMatchesPart1.add("^c=IN IP4 0.0.0.0$");
-			expectedMatchesPart1.add("^a=rtcp:9 IN IP4 0.0.0.0$");
-			expectedMatchesPart1.add("^a=ice-ufrag:[^ ]+$");
-			expectedMatchesPart1.add("^a=ice-pwd:[^ ]+$");
-			expectedMatchesPart1.add("^a=ice-options:trickle renomination$");
-			expectedMatchesPart1.add("^a=fingerprint:sha-256 [^ ]+$");
-			expectedMatchesPart1.add("^a=setup:(actpass|active)");
-			expectedMatchesPart1.add("^a=mid:1$");
+			matches.add("^m=video 9 UDP/TLS/RTP/SAVPF( \\d+)+$");
+			matches.add("^c=IN IP4 0.0.0.0$");
+			matches.add("^a=rtcp:9 IN IP4 0.0.0.0$");
+			matches.add("^a=ice-ufrag:[^ ]+$");
+			matches.add("^a=ice-pwd:[^ ]+$");
+			matches.add("^a=ice-options:trickle renomination$");
+			matches.add("^a=fingerprint:sha-256 [^ ]+$");
+			matches.add("^a=setup:(actpass|active)");
+			matches.add("^a=mid:1$");
 			if (isOffer) {
-				expectedMatchesPart1.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:toffset$");
-				expectedMatchesPart1.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time$");
-				expectedMatchesPart1.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:3gpp:video-orientation$");
-				expectedMatchesPart1.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01$");
-				expectedMatchesPart1.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/playout-delay$");
-				expectedMatchesPart1.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-content-type$");
-				expectedMatchesPart1.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-timing$");
-				expectedMatchesPart1.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/color-space$");
-				expectedMatchesPart1.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid$");
-				expectedMatchesPart1.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id$");
-				expectedMatchesPart1.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id$");
+				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:toffset$");
+				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time$");
+				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:3gpp:video-orientation$");
+				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01$");
+				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/playout-delay$");
+				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-content-type$");
+				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-timing$");
+				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/color-space$");
+				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid$");
+				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id$");
+				matches.add("^a=extmap:[0-9]+ urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id$");
 			} else {
-				expectedMatchesPart1.add("^a=extmap:20 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:toffset$");
-				expectedMatchesPart1.add("^a=extmap:15 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time$");
-				expectedMatchesPart1.add("^a=extmap:21 urn:ietf:params:rtp-hdrext:encrypt urn:3gpp:video-orientation$");
-				expectedMatchesPart1.add("^a=extmap:16 urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01$");
-				expectedMatchesPart1.add("^a=extmap:22 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/playout-delay$");
-				expectedMatchesPart1.add("^a=extmap:23 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-content-type$");
-				expectedMatchesPart1.add("^a=extmap:24 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-timing$");
-				expectedMatchesPart1.add("^a=extmap:26 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/color-space$");
-				expectedMatchesPart1.add("^a=extmap:17 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid$");
-				expectedMatchesPart1.add("^a=extmap:18 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id$");
-				expectedMatchesPart1.add("^a=extmap:19 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id$");
+				matches.add("^a=extmap:20 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:toffset$");
+				matches.add("^a=extmap:15 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time$");
+				matches.add("^a=extmap:21 urn:ietf:params:rtp-hdrext:encrypt urn:3gpp:video-orientation$");
+				matches.add("^a=extmap:16 urn:ietf:params:rtp-hdrext:encrypt http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01$");
+				matches.add("^a=extmap:22 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/playout-delay$");
+				matches.add("^a=extmap:23 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-content-type$");
+				matches.add("^a=extmap:24 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/video-timing$");
+				matches.add("^a=extmap:26 urn:ietf:params:rtp-hdrext:encrypt http://www.webrtc.org/experiments/rtp-hdrext/color-space$");
+				matches.add("^a=extmap:17 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:mid$");
+				matches.add("^a=extmap:18 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id$");
+				matches.add("^a=extmap:19 urn:ietf:params:rtp-hdrext:encrypt urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id$");
 			}
 			// TODO(SE-63): Ehh, dirty hack... it should create a transceiver instead
-			expectedMatchesPart1.add("^a=recvonly");
+			matches.add("^a=recvonly");
 //			expectedMatchesPart1.add("^a=sendrecv");
 //			expectedMatchesPart1.add("^a=msid:3MACALL 3MACALLv0");
-			expectedMatchesPart1.add("^a=rtcp-mux$");
-			expectedMatchesPart1.add("^a=rtcp-rsize$");
-		}
+			matches.add("^a=rtcp-mux$");
+			matches.add("^a=rtcp-rsize$");
+
+			matches.add("^a=rtpmap:\\d+ VP8/90000$");
+			matches.add("^a=rtcp-fb:\\d+ goog-remb$");
+			matches.add("^a=rtcp-fb:\\d+ transport-cc$");
+			matches.add("^a=rtcp-fb:\\d+ ccm fir$");
+			matches.add("^a=rtcp-fb:\\d+ nack$");
+			matches.add("^a=rtcp-fb:\\d+ nack pli$");
+			matches.add("^a=rtpmap:\\d+ rtx/90000$");
+			matches.add("^a=fmtp:\\d+ apt=\\d+$");
+
+			// Since M110 we will generate a bunch of different VP9 profiles.
+			// For now, we're lenient and just accept these even though it likely makes no sense
+			// for our use case.
+			lineOffset += matchEachLine(matches, actualLines, lineOffset);
+			for (int i = 0; i < 4; ++i) {
+				String line = actualLines.get(lineOffset);
+				if (line == null || !line.matches("^a=rtpmap:\\d+ VP9/90000$")) {
+					assertTrue("At least one VP9 codec profile is expected", i > 0);
+					break;
+				}
+
+				matches.add("^a=rtpmap:\\d+ VP9/90000$");
+				matches.add("^a=rtcp-fb:\\d+ goog-remb$");
+				matches.add("^a=rtcp-fb:\\d+ transport-cc$");
+				matches.add("^a=rtcp-fb:\\d+ ccm fir$");
+				matches.add("^a=rtcp-fb:\\d+ nack$");
+				matches.add("^a=rtcp-fb:\\d+ nack pli$");
+				matches.add("^a=fmtp:\\d+ profile-id=\\d$");
+				matches.add("^a=rtpmap:\\d+ rtx/90000$");
+				matches.add("^a=fmtp:\\d+ apt=\\d+$");
+				lineOffset += matchEachLine(matches, actualLines, lineOffset);
+			}
 
-		// Part 2: Data channel
-		final List<String> expectedMatchesPart2 = new ArrayList<>();
-		if (isOffer || videoEnabled) {
-			expectedMatchesPart2.add("^m=application 9 UDP/DTLS/SCTP webrtc-datachannel$");
-			expectedMatchesPart2.add("^c=IN IP4 0.0.0.0$");
-			expectedMatchesPart2.add("^a=ice-ufrag:[^ ]+$");
-			expectedMatchesPart2.add("^a=ice-pwd:[^ ]+$");
-			expectedMatchesPart2.add("^a=ice-options:trickle renomination$");
-			expectedMatchesPart2.add("^a=fingerprint:sha-256 [^ ]+$");
-			expectedMatchesPart2.add("^a=setup:(actpass|active)$");
-			expectedMatchesPart2.add("^a=mid:[^ ]+$");
-			expectedMatchesPart2.add("^a=sctp-port:5000$");
-			expectedMatchesPart2.add("^a=max-message-size:262144$");
-		}
+			// Other video codec lines (dynamically detected HW codec support, e.g. H264)
+			lineOffset += matchEachLine(matches, actualLines, lineOffset);
+			for (;;) {
+				String line = actualLines.get(lineOffset);
+				if (line == null || line.matches("^a=rtpmap:\\d+ red/90000")) {
+					break;
+				}
+				lineOffset++;
+			}
 
-		final List<String> actualLines = Arrays.asList(sdp.description.split("\r\n"));
-		Log.d(TAG, "SDP:\n" + sdp.description);
+			matches.add("^a=rtpmap:\\d+ red/90000");
+			matches.add("^a=rtpmap:\\d+ rtx/90000");
+			matches.add("^a=fmtp:\\d+ apt=\\d+$");
 
-		// Verify part 1
-		int offset = 0;
-		matchEachLine(expectedMatchesPart1, actualLines, offset);
-		offset += expectedMatchesPart1.size();
+			matches.add("^a=rtpmap:\\d+ ulpfec/90000");
 
-		// Skip codec lines
-		if (videoEnabled) {
-			do {
-				Log.d(TAG, "Skipping line \"" + actualLines.get(offset) + "\"");
-				offset += 1;
-			} while (isRtpmapLine(actualLines.get(offset)));
+			matches.add("^a=rtpmap:\\d+ flexfec-03/90000");
+			matches.add("^a=rtcp-fb:\\d+ goog-remb$");
+			matches.add("^a=rtcp-fb:\\d+ transport-cc$");
+			matches.add("^a=fmtp:\\d+ repair-window=\\d+$");
+
+			lineOffset += matchEachLine(matches, actualLines, lineOffset);
 		}
 
-		// Verify part 2
-		matchEachLine(expectedMatchesPart2, actualLines, offset);
-		offset += expectedMatchesPart2.size();
+		if (isOffer || videoEnabled) {
+			// Data channel lines
+			matches.add("^m=application 9 UDP/DTLS/SCTP webrtc-datachannel$");
+			matches.add("^c=IN IP4 0.0.0.0$");
+			matches.add("^a=ice-ufrag:[^ ]+$");
+			matches.add("^a=ice-pwd:[^ ]+$");
+			matches.add("^a=ice-options:trickle renomination$");
+			matches.add("^a=fingerprint:sha-256 [^ ]+$");
+			matches.add("^a=setup:(actpass|active)$");
+			matches.add("^a=mid:[^ ]+$");
+			matches.add("^a=sctp-port:5000$");
+			matches.add("^a=max-message-size:262144$");
+			lineOffset += matchEachLine(matches, actualLines, lineOffset);
+		}
 
 		// Lines must be equal
-		assertEquals(offset, actualLines.size());
-	}
-
-	/**
-	 * Helper for validateDescription
-	 */
-	private boolean isRtpmapLine(String line) {
-		return line.startsWith("a=rtpmap:")
-			|| line.startsWith("a=rtcp-fb:")
-			|| line.startsWith("a=fmtp:");
+		assertEquals(lineOffset, actualLines.size());
 	}
 
 	/**
 	 * Helper for validateDescription
 	 */
-	private void matchEachLine(List<String> expectedMatches, List<String> actualLines, int offset) {
-		for (int i = 0; i < expectedMatches.size(); ++i) {
+	private int matchEachLine(List<String> expectedMatches, List<String> actualLines, int offset) {
+		int expectedLength = expectedMatches.size();
+		for (int i = 0; i < expectedLength; ++i) {
 			final String expected = expectedMatches.get(i);
 			final String actual = i < actualLines.size() ? actualLines.get(i + offset) : null;
 			Log.d(TAG, "Validating \"" + actual + "\" against \"" + expected + "\"");
 			assertNotNull(actual);
 			assertTrue("Line \"" + actual + "\" did not match \"" + expected + "\"", actual.matches(expected));
 		}
+		expectedMatches.clear();
+		return expectedLength;
 	}
 
 	public void testOffer(boolean videoEnabled) throws InterruptedException, ExecutionException {

BIN
app/src/foss_based/assets/emojis/activity-0.png


BIN
app/src/foss_based/assets/emojis/activity-1.png


BIN
app/src/foss_based/assets/emojis/flags-0.png


BIN
app/src/foss_based/assets/emojis/food-0.png


BIN
app/src/foss_based/assets/emojis/nature-0.png


BIN
app/src/foss_based/assets/emojis/objects-0.png


BIN
app/src/foss_based/assets/emojis/people-0.png


BIN
app/src/foss_based/assets/emojis/people-1.png


BIN
app/src/foss_based/assets/emojis/people-2.png


BIN
app/src/foss_based/assets/emojis/people-3.png


BIN
app/src/foss_based/assets/emojis/people-4.png


BIN
app/src/foss_based/assets/emojis/people-5.png


BIN
app/src/foss_based/assets/emojis/people-6.png


BIN
app/src/foss_based/assets/emojis/people-7.png


BIN
app/src/foss_based/assets/emojis/people-8.png


BIN
app/src/foss_based/assets/emojis/people-9.png


BIN
app/src/foss_based/assets/emojis/symbols-0.png


BIN
app/src/foss_based/assets/emojis/travel-0.png


+ 3 - 0
app/src/foss_based/assets/license.html

@@ -174,6 +174,9 @@ SUCH DAMAGE.</p>
 
 <p>Licensed under Creative Commons License (CC-BY 4.0).</p>
 
+<h2>Emoji supplied by <a href="https://github.com/twitter/twemoji">Twitter Emoji (Twemoji)</a></h2>
+
+<p>Licensed under Creative Commons License (CC-BY 4.0).</p>
 
 <h2>ExoPlayer</h2>
 

+ 1 - 1
app/src/main/AndroidManifest.xml

@@ -168,7 +168,7 @@
 		android:theme="@style/AppBaseTheme"
 		android:usesCleartextTraffic="false"
 		android:networkSecurityConfig="@xml/network_security_config"
-		android:manageSpaceActivity=".activities.StorageManagementActivity"
+		android:manageSpaceActivity="ch.threema.app.activities.StorageManagementActivity"
 		android:allowAudioPlaybackCapture="false"
 		android:appCategory="social"
 		android:hasFragileUserData="true"

+ 3 - 18
app/src/main/java/ch/threema/app/BuildFlavor.java

@@ -63,12 +63,7 @@ public class BuildFlavor {
 	 */
 	@SuppressWarnings("ConstantConditions")
 	public static boolean maySelfUpdate() {
-		switch (BuildConfig.FLAVOR) {
-			case FLAVOR_STORE_THREEMA:
-				return true;
-			default:
-				return false;
-		}
+		return FLAVOR_STORE_THREEMA.equals(BuildConfig.FLAVOR);
 	}
 
 	/**
@@ -76,12 +71,7 @@ public class BuildFlavor {
 	 */
 	@SuppressWarnings("ConstantConditions")
 	public static boolean forceThreemaPush() {
-		switch (BuildConfig.FLAVOR) {
-			case FLAVOR_LIBRE:
-				return true;
-			default:
-				return false;
-		}
+		return FLAVOR_LIBRE.equals(BuildConfig.FLAVOR);
 	}
 
 	/**
@@ -90,12 +80,7 @@ public class BuildFlavor {
 	 */
 	@SuppressWarnings("ConstantConditions")
 	public static boolean isLibre() {
-		switch (BuildConfig.FLAVOR) {
-			case FLAVOR_LIBRE:
-				return true;
-			default:
-				return false;
-		}
+		return FLAVOR_LIBRE.equals(BuildConfig.FLAVOR);
 	}
 
 	@SuppressWarnings("ConstantConditions")

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

@@ -1249,8 +1249,12 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 					try {
 						MessageReceiver messageReceiver = serviceManager.getGroupService().createReceiver(groupModel);
 						serviceManager.getConversationService().refresh(groupModel);
+						String groupName = groupModel.getName();
+						if (groupName == null) {
+							groupName = "";
+						}
 						serviceManager.getMessageService().createStatusMessage(
-							serviceManager.getContext().getString(R.string.status_rename_group, groupModel.getName()),
+							serviceManager.getContext().getString(R.string.status_rename_group, groupName),
 							messageReceiver);
 						ShortcutUtil.updatePinnedShortcut(messageReceiver);
 					} catch (ThreemaException e) {

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

@@ -31,6 +31,7 @@ import android.graphics.Color;
 import android.graphics.PorterDuff;
 import android.os.AsyncTask;
 import android.os.Bundle;
+import android.text.Editable;
 import android.text.Html;
 import android.view.Menu;
 import android.view.MenuItem;
@@ -169,7 +170,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	private String myIdentity;
 	private int operationMode;
 	private int groupId;
-	private boolean hasChanges = false;
+	private boolean hasMemberChanges = false;
 
 	private final ResumePauseHandler.RunIfActive runIfActiveUpdate = new ResumePauseHandler.RunIfActive() {
 		@Override
@@ -470,11 +471,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	}
 
 	private void setTitle() {
-		if (TestUtil.empty(groupDetailViewModel.getGroupName())) {
-			this.groupNameEditText.setText(groupService.getMembersString(this.groupModel));
-		} else {
-			this.groupNameEditText.setText(groupDetailViewModel.getGroupName());
-		}
+		this.groupNameEditText.setText(groupDetailViewModel.getGroupName());
 	}
 
 	private void launchContactDetail(View view, String identity) {
@@ -499,7 +496,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	private void removeMemberFromGroup(final ContactModel contactModel) {
 		if (contactModel != null) {
 			this.groupDetailViewModel.removeGroupContact(contactModel);
-			this.hasChanges = true;
+			this.hasMemberChanges = true;
 		}
 	}
 
@@ -535,7 +532,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
 		if (groupModel != null) {
 			GroupCallDescription call = groupCallManager.getCurrentChosenCall(groupModel);
-			groupCallMenu.setVisible(GroupCallUtilKt.qualifiesForGroupCalls(groupService, groupModel) && !hasChanges && call == null);
+			groupCallMenu.setVisible(GroupCallUtilKt.qualifiesForGroupCalls(groupService, groupModel) && !hasChanges() && call == null);
 			leaveGroupMenu.setVisible(true);
 			deleteGroupMenu.setVisible(true);
 			if (groupService.isGroupOwner(this.groupModel)) {
@@ -749,12 +746,17 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 					return null;
 				}
 
+				@NonNull String newGroupName = groupDetailViewModel.getGroupName() != null ?
+					groupDetailViewModel.getGroupName() : "";
+				@NonNull String oldGroupName = groupModel.getName() != null ?
+					groupModel.getName() : "";
+
 				try {
 					Bitmap avatar = groupDetailViewModel.getAvatarFile() != null ? BitmapFactory.decodeFile(groupDetailViewModel.getAvatarFile().getPath()) : null;
 
 					model = groupService.updateGroup(
 						groupModel,
-						groupDetailViewModel.getGroupName(),
+						oldGroupName.equals(newGroupName) ? null : newGroupName,
 						groupDetailViewModel.getGroupDesc(),
 						groupDetailViewModel.getGroupIdentities(),
 						avatar,
@@ -788,7 +790,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				// some users were added
 				groupDetailViewModel.addGroupContacts(IntentDataUtil.getContactIdentities(data));
 				sortGroupMembers();
-				this.hasChanges = true;
+				this.hasMemberChanges = true;
 			}
 			else if (this.groupService.isGroupOwner(this.groupModel) && requestCode == ThreemaActivity.ACTIVITY_ID_MANAGE_GROUP_LINKS) {
 				// make sure we reset the default link switch if the default link was deleted
@@ -923,7 +925,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
 	@Override
 	public void onBackPressed() {
-		if (this.operationMode == MODE_EDIT && this.hasChanges) {
+		if (this.operationMode == MODE_EDIT && hasChanges()) {
 			GenericAlertDialog.newInstance(
 					R.string.save_changes,
 					R.string.save_group_changes,
@@ -947,6 +949,19 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		}
 	}
 
+	private boolean hasChanges() {
+		return hasMemberChanges || hasGroupNameChanges();
+	}
+
+	private boolean hasGroupNameChanges() {
+		Editable groupNameEditable = groupNameEditText.getText();
+		String editedGroupNameText = groupNameEditable != null ? groupNameEditable.toString() : "";
+
+		String currentGroupName = groupModel.getName() != null ? groupModel.getName() : "";
+
+		return !editedGroupNameText.equals(currentGroupName);
+	}
+
 	private void updateFloatingActionButton() {
 		if (this.floatingActionButton == null ||
 			this.groupService == null ||

+ 202 - 194
app/src/main/java/ch/threema/app/activities/HomeActivity.java

@@ -27,6 +27,7 @@ import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
+import android.database.sqlite.SQLiteException;
 import android.graphics.Bitmap;
 import android.graphics.PorterDuff;
 import android.graphics.drawable.BitmapDrawable;
@@ -64,7 +65,6 @@ import org.slf4j.Logger;
 
 import java.io.File;
 import java.lang.ref.WeakReference;
-import java.net.InetSocketAddress;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.Iterator;
@@ -72,7 +72,6 @@ import java.util.List;
 import java.util.Objects;
 import java.util.concurrent.RejectedExecutionException;
 
-import ch.threema.app.BuildConfig;
 import ch.threema.app.BuildFlavor;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -123,7 +122,7 @@ import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
 import ch.threema.app.threemasafe.ThreemaSafeService;
 import ch.threema.app.ui.IdentityPopup;
-import ch.threema.app.ui.OngoingCallNoticeModes;
+import ch.threema.app.ui.OngoingCallNoticeMode;
 import ch.threema.app.ui.OngoingCallNoticeView;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.AppRestrictionUtil;
@@ -135,7 +134,10 @@ import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.StateBitmapUtil;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.app.voip.activities.CallActivity;
+import ch.threema.app.voip.groupcall.GroupCallDescription;
+import ch.threema.app.voip.groupcall.GroupCallManager;
+import ch.threema.app.voip.groupcall.GroupCallObserver;
+import ch.threema.app.voip.groupcall.sfu.GroupCallController;
 import ch.threema.app.voip.services.VoipCallService;
 import ch.threema.app.webclient.activities.SessionsActivity;
 import ch.threema.base.utils.LoggingUtil;
@@ -144,15 +146,12 @@ import ch.threema.domain.protocol.csp.connection.ConnectionState;
 import ch.threema.domain.protocol.csp.connection.ConnectionStateListener;
 import ch.threema.domain.protocol.csp.connection.ThreemaConnection;
 import ch.threema.localcrypto.MasterKey;
+import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ConversationModel;
 
 import static ch.threema.app.services.ConversationTagServiceImpl.FIXED_TAG_UNREAD;
-import static ch.threema.app.voip.services.VoipCallService.ACTION_HANGUP;
-import static ch.threema.app.voip.services.VoipCallService.EXTRA_ACTIVITY_MODE;
-import static ch.threema.app.voip.services.VoipCallService.EXTRA_CONTACT_IDENTITY;
-import static ch.threema.app.voip.services.VoipCallService.EXTRA_START_TIME;
 
 public class HomeActivity extends ThreemaAppCompatActivity implements
 	SMSVerificationDialog.SMSVerificationDialogCallback,
@@ -192,7 +191,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	private Toolbar toolbar;
 	private View connectionIndicator;
 	private LinearLayout noticeLayout;
-	OngoingCallNoticeView ongoingCallNoticeLayout;
+	OngoingCallNoticeView ongoingCallNotice;
 
 	private ServiceManager serviceManager;
 	private NotificationService notificationService;
@@ -201,6 +200,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	private LockAppService lockAppService;
 	private PreferenceService preferenceService;
 	private ConversationService conversationService;
+	private GroupCallManager groupCallManager;
 
 	private final ArrayList<AbstractMessageModel> unsentMessages = new ArrayList<>();
 
@@ -208,29 +208,23 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	private final BroadcastReceiver currentCheckAppReceiver = new BroadcastReceiver() {
 		@Override
 		public void onReceive(Context context, final Intent intent) {
-			RuntimeUtil.runOnUiThread(new Runnable() {
-				@Override
-				public void run() {
-					if (intent.getAction().equals(IntentDataUtil.ACTION_LICENSE_NOT_ALLOWED)) {
-						if (BuildFlavor.getLicenseType() == BuildFlavor.LicenseType.SERIAL ||
-							BuildFlavor.getLicenseType() == BuildFlavor.LicenseType.GOOGLE_WORK ||
-							BuildFlavor.getLicenseType() == BuildFlavor.LicenseType.HMS_WORK ||
-							BuildFlavor.getLicenseType() == BuildFlavor.LicenseType.ONPREM) {
-							//show enter serial stuff
-							startActivityForResult(new Intent(HomeActivity.this, EnterSerialActivity.class), ThreemaActivity.ACTIVITY_ID_ENTER_SERIAL);
-						} else {
-							showErrorTextAndExit(IntentDataUtil.getMessage(intent));
-						}
-					} else if (intent.getAction().equals(IntentDataUtil.ACTION_UPDATE_AVAILABLE) && !ConfigUtils.isWorkBuild() && userService != null && userService.hasIdentity()) {
-						new Handler().postDelayed(new Runnable() {
-							@Override
-							public void run() {
-								Intent dialogIntent = new Intent(intent);
-								dialogIntent.setClass(HomeActivity.this, DownloadApkActivity.class);
-								startActivity(dialogIntent);
-							}
-						}, DateUtils.SECOND_IN_MILLIS * 5);
+			RuntimeUtil.runOnUiThread(() -> {
+				if (intent.getAction().equals(IntentDataUtil.ACTION_LICENSE_NOT_ALLOWED)) {
+					if (BuildFlavor.getLicenseType() == BuildFlavor.LicenseType.SERIAL ||
+						BuildFlavor.getLicenseType() == BuildFlavor.LicenseType.GOOGLE_WORK ||
+						BuildFlavor.getLicenseType() == BuildFlavor.LicenseType.HMS_WORK ||
+						BuildFlavor.getLicenseType() == BuildFlavor.LicenseType.ONPREM) {
+						//show enter serial stuff
+						startActivityForResult(new Intent(HomeActivity.this, EnterSerialActivity.class), ThreemaActivity.ACTIVITY_ID_ENTER_SERIAL);
+					} else {
+						showErrorTextAndExit(IntentDataUtil.getMessage(intent));
 					}
+				} else if (intent.getAction().equals(IntentDataUtil.ACTION_UPDATE_AVAILABLE) && !ConfigUtils.isWorkBuild() && userService != null && userService.hasIdentity()) {
+					new Handler().postDelayed(() -> {
+						Intent dialogIntent = new Intent(intent);
+						dialogIntent.setClass(HomeActivity.this, DownloadApkActivity.class);
+						startActivity(dialogIntent);
+					}, DateUtils.SECOND_IN_MILLIS * 5);
 				}
 			});
 		}
@@ -318,12 +312,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		}
 	}
 
-	private final ConnectionStateListener connectionStateListener = new ConnectionStateListener() {
-		@Override
-		public void updateConnectionState(final ConnectionState connectionState, InetSocketAddress address) {
-			updateConnectionIndicator(connectionState);
-		}
-	};
+	private final ConnectionStateListener connectionStateListener = (connectionState, address) -> updateConnectionIndicator(connectionState);
 
 	private void updateUnsentMessagesList(AbstractMessageModel modifiedMessageModel, boolean add) {
 		int numCurrentUnsent = unsentMessages.size();
@@ -367,24 +356,18 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	private final SMSVerificationListener smsVerificationListener = new SMSVerificationListener() {
 		@Override
 		public void onVerified() {
-		 	RuntimeUtil.runOnUiThread(new Runnable() {
-				@Override
-				public void run() {
-					if (noticeLayout != null) {
-						AnimationUtil.collapse(noticeLayout);
-					}
+			RuntimeUtil.runOnUiThread(() -> {
+				if (noticeLayout != null) {
+					AnimationUtil.collapse(noticeLayout);
 				}
 			});
 		}
 
 		@Override
 		public void onVerificationStarted() {
-		 	RuntimeUtil.runOnUiThread(new Runnable() {
-				@Override
-				public void run() {
-					if (noticeLayout != null) {
-						AnimationUtil.expand(noticeLayout);
-					}
+			RuntimeUtil.runOnUiThread(() -> {
+				if (noticeLayout != null) {
+					AnimationUtil.expand(noticeLayout);
 				}
 			});
 		}
@@ -394,10 +377,10 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		if (preferenceService.getShowUnreadBadge()) {
 			RuntimeUtil.runOnUiThread(() -> {
 				try {
-					new UpdateBottomNavigationBadgeTask(HomeActivity.this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+					new UpdateBottomNavigationBadgeTask(this).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
 				} catch (RejectedExecutionException e) {
 					try {
-						new UpdateBottomNavigationBadgeTask(HomeActivity.this).executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
+						new UpdateBottomNavigationBadgeTask(this).executeOnExecutor(AsyncTask.SERIAL_EXECUTOR);
 					} catch (RejectedExecutionException ignored) {
 					}
 				}
@@ -438,7 +421,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			for (AbstractMessageModel modifiedMessageModel : modifiedMessageModels) {
 
 				if (!modifiedMessageModel.isStatusMessage()
-						&& modifiedMessageModel.isOutbox()) {
+					&& modifiedMessageModel.isOutbox()) {
 
 					switch (modifiedMessageModel.getState()) {
 						case SENDFAILED:
@@ -475,17 +458,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		}
 	};
 
-	private final AppIconListener appIconListener = new AppIconListener() {
-		@Override
-		public void onChanged() {
-		 	RuntimeUtil.runOnUiThread(new Runnable() {
-				@Override
-				public void run() {
-					updateAppLogo();
-				}
-			});
-		}
-	};
+	private final AppIconListener appIconListener = () -> RuntimeUtil.runOnUiThread(this::updateAppLogo);
 
 	private final ProfileListener profileListener = new ProfileListener() {
 		@Override
@@ -505,23 +478,17 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	private final VoipCallListener voipCallListener = new VoipCallListener() {
 		@Override
 		public void onStart(String contact, long elpasedTimeMs) {
-			RuntimeUtil.runOnUiThread(() -> {
-				if (ongoingCallNoticeLayout != null) {
-					ongoingCallNoticeLayout.show(VoipCallService.getStartTime(), OngoingCallNoticeModes.MODE_VOIP, 0);
-				}
-			});
+			updateOngoingCallNotice();
 		}
 
 		@Override
 		public void onEnd() {
-			RuntimeUtil.runOnUiThread(() -> {
-				if (ongoingCallNoticeLayout != null) {
-					ongoingCallNoticeLayout.hide();
-				}
-			});
+			hideOngoingVoipCallNotice();
 		}
 	};
 
+	private final GroupCallObserver groupCallObserver = call -> updateOngoingCallNotice();
+
 	private final ContactCountListener contactCountListener = new ContactCountListener() {
 		@Override
 		public void onNewContactsCountUpdated(int last24hoursCount) {
@@ -630,6 +597,24 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		}
 	}
 
+	@Override
+	protected void onStart() {
+		super.onStart();
+
+		// Check if there are any server messages to display
+		if (serviceManager != null) {
+			DatabaseServiceNew databaseService = serviceManager.getDatabaseServiceNew();
+			try {
+				if (databaseService.getServerMessageModelFactory().count() > 0) {
+					Intent intent = new Intent(this, ServerMessageActivity.class);
+					startActivity(intent);
+				}
+			} catch (SQLiteException e) {
+				logger.error("Could not get server message model count", e);
+			}
+		}
+	}
+
 	@UiThread
 	private void showMainContent() {
 		if (mainContent != null) {
@@ -704,9 +689,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		IdentityPopup identityPopup = new IdentityPopup(this);
 		identityPopup.show(this, toolbar, location, () -> {
 			// show profile fragment
-			bottomNavigationView.post(() -> {
-				bottomNavigationView.findViewById(R.id.my_profile).performClick();
-			});
+			bottomNavigationView.post(() -> bottomNavigationView.findViewById(R.id.my_profile).performClick());
 		});
 	}
 
@@ -715,8 +698,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		toolbar.getLocationInWindow(location);
 
 		location[0] += toolbar.getContentInsetLeft() +
-				((getResources().getDimensionPixelSize(R.dimen.navigation_icon_padding) +
-						getResources().getDimensionPixelSize(R.dimen.navigation_icon_size)) / 2);
+			((getResources().getDimensionPixelSize(R.dimen.navigation_icon_padding) +
+				getResources().getDimensionPixelSize(R.dimen.navigation_icon_size)) / 2);
 		location[1] += toolbar.getHeight() / 2;
 
 		return location;
@@ -744,9 +727,9 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		this.checkLicense();
 	}
 
-	private boolean checkLicense() {
+	private void checkLicense() {
 		if (this.isLicenseCheckStarted) {
-			return true;
+			return;
 		}
 
 		if (serviceManager != null) {
@@ -754,19 +737,19 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 			if (deviceService != null && deviceService.isOnline()) {
 				//start check directly
-				CheckLicenseRoutine check = null;
+				CheckLicenseRoutine check;
 				try {
 					check = new CheckLicenseRoutine(
-							this,
-							serviceManager.getAPIConnector(),
-							serviceManager.getUserService(),
-							deviceService,
-							serviceManager.getLicenseService(),
-							serviceManager.getIdentityStore()
+						this,
+						serviceManager.getAPIConnector(),
+						serviceManager.getUserService(),
+						deviceService,
+						serviceManager.getLicenseService(),
+						serviceManager.getIdentityStore()
 					);
 				} catch (FileSystemNotPresentException e) {
 					logger.error("Exception", e);
-					return false;
+					return;
 				}
 
 				new Thread(check).start();
@@ -779,7 +762,6 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 						logger.error("Exception", e);
 					}
 				}
-				return true;
 			} else {
 				if (this.checkLicenseBroadcastReceiver == null) {
 					this.checkLicenseBroadcastReceiver = new BroadcastReceiver() {
@@ -790,15 +772,13 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 						}
 					};
 					this.registerReceiver(
-							this.checkLicenseBroadcastReceiver,
-							new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
+						this.checkLicenseBroadcastReceiver,
+						new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
 					);
 				}
 
 			}
 		}
-
-		return false;
 	}
 
 	@Override
@@ -825,6 +805,9 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		ListenerManager.appIconListeners.remove(this.appIconListener);
 		ListenerManager.profileListeners.remove(this.profileListener);
 		ListenerManager.voipCallListeners.remove(this.voipCallListener);
+		if (groupCallManager != null) {
+			groupCallManager.removeGeneralGroupCallObserver(groupCallObserver);
+		}
 		ListenerManager.conversationListeners.remove(this.conversationListener);
 		ListenerManager.contactCountListener.remove(this.contactCountListener);
 
@@ -908,6 +891,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			try {
 				this.conversationService = serviceManager.getConversationService();
 				this.contactService = serviceManager.getContactService();
+				this.groupCallManager = serviceManager.getGroupCallManager();
 			} catch (Exception e) {
 				//
 			}
@@ -928,18 +912,21 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			ListenerManager.appIconListeners.add(this.appIconListener);
 			ListenerManager.profileListeners.add(this.profileListener);
 			ListenerManager.voipCallListeners.add(this.voipCallListener);
+			if (groupCallManager != null) {
+				groupCallManager.addGeneralGroupCallObserver(groupCallObserver);
+			}
 			ListenerManager.conversationListeners.add(this.conversationListener);
 			ListenerManager.contactCountListener.add(this.contactCountListener);
 
 			UpdateSystemService updateSystemService = serviceManager.getUpdateSystemService();
 			if (updateSystemService.hasUpdates()) {
-					//runASync updates FIRST!!
-					this.runUpdates(updateSystemService);
-				} else {
-					this.initMainActivity(savedInstanceState);
-				}
+				//runASync updates FIRST!!
+				this.runUpdates(updateSystemService);
+			} else {
+				this.initMainActivity(savedInstanceState);
+			}
 		} else {
-		 	RuntimeUtil.runOnUiThread(() -> showErrorTextAndExit(getString(R.string.service_manager_not_available)));
+			RuntimeUtil.runOnUiThread(() -> showErrorTextAndExit(getString(R.string.service_manager_not_available)));
 		}
 	}
 
@@ -1043,33 +1030,12 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		this.noticeLayout = findViewById(R.id.notice_layout);
 		findViewById(R.id.notice_button_enter_code).setOnClickListener(v -> SMSVerificationDialog.newInstance(userService.getLinkedMobile(true)).show(getSupportFragmentManager(), DIALOG_TAG_VERIFY_CODE));
 		findViewById(R.id.notice_button_cancel).setOnClickListener(v -> GenericAlertDialog.newInstance(R.string.verify_title, R.string.really_cancel_verify, R.string.yes, R.string.no)
-				.show(getSupportFragmentManager(), DIALOG_TAG_CANCEL_VERIFY));
+			.show(getSupportFragmentManager(), DIALOG_TAG_CANCEL_VERIFY));
 		this.noticeLayout.setVisibility(
-				userService.getMobileLinkingState() == UserService.LinkingState_PENDING ?
-						View.VISIBLE : View.GONE);
-
-		this.ongoingCallNoticeLayout = findViewById(R.id.ongoing_call_notice);
-		if (ongoingCallNoticeLayout != null) {
-			ongoingCallNoticeLayout.setContainerAction(() -> {
-				if (VoipCallService.isRunning()) {
-					final Intent openIntent = new Intent(HomeActivity.this, CallActivity.class);
-					openIntent.putExtra(EXTRA_ACTIVITY_MODE, CallActivity.MODE_ACTIVE_CALL);
-					openIntent.putExtra(EXTRA_CONTACT_IDENTITY, VoipCallService.getOtherPartysIdentity());
-					openIntent.putExtra(EXTRA_START_TIME, VoipCallService.getStartTime());
-					startActivity(openIntent);
-				}
-			});
-			ongoingCallNoticeLayout.setButtonAction(() -> {
-				final Intent hangupIntent = new Intent(HomeActivity.this, VoipCallService.class);
-				hangupIntent.setAction(ACTION_HANGUP);
-				startService(hangupIntent);
-			});
-			if (VoipCallService.isRunning()) {
-				ongoingCallNoticeLayout.show(VoipCallService.getStartTime(), OngoingCallNoticeModes.MODE_VOIP, 0);
-			} else {
-				ongoingCallNoticeLayout.hide();
-			}
-		}
+			userService.getMobileLinkingState() == UserService.LinkingState_PENDING ?
+				View.VISIBLE : View.GONE);
+
+		initOngoingCallNotice();
 
 		/*
 		 * setup fragments
@@ -1185,9 +1151,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			}
 			return false;
 		});
-		this.bottomNavigationView.post(() -> {
-			bottomNavigationView.setSelectedItemId(initialItemId);
-		});
+		this.bottomNavigationView.post(() -> bottomNavigationView.setSelectedItemId(initialItemId));
 
 		updateBottomNavigation();
 
@@ -1208,7 +1172,63 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	}
 
 	private void initOngoingCallNotice() {
+		this.ongoingCallNotice = findViewById(R.id.ongoing_call_notice);
+		updateOngoingCallNotice();
+	}
+
+	private void updateOngoingCallNotice() {
+		logger.debug("Update ongoing call notice");
+
+		GroupCallController groupCallController = groupCallManager != null
+			? groupCallManager.getCurrentGroupCallController()
+			: null;
+
+		boolean hasRunningOOCall = VoipCallService.isRunning();
+		boolean hasRunningGroupCall = groupCallController != null;
 
+		if (hasRunningOOCall && hasRunningGroupCall) {
+			logger.warn("Invalid state: joined 1:1 AND group call, not showing call notice");
+			hideOngoingCallNotice();
+		} else if (hasRunningOOCall) {
+			showOngoingVoipCallNotice();
+		} else if (hasRunningGroupCall) {
+			showOngoingGroupCallNotice(groupCallController.getDescription());
+		} else {
+			logger.debug("No ongoing calls, hide notice");
+			hideOngoingCallNotice();
+		}
+	}
+
+	/**
+	 * Hides the ongoing call notice not matter what type of called was displayed before
+	 */
+	private void hideOngoingCallNotice() {
+		if (ongoingCallNotice != null) {
+			ongoingCallNotice.hide();
+		}
+	}
+
+	private void hideOngoingVoipCallNotice() {
+		if (ongoingCallNotice != null) {
+			ongoingCallNotice.hideVoip();
+		}
+	}
+
+	@AnyThread
+	private void showOngoingVoipCallNotice() {
+		if (ongoingCallNotice != null) {
+			ongoingCallNotice.showVoip();
+		}
+	}
+
+	@AnyThread
+	private void showOngoingGroupCallNotice(@NonNull GroupCallDescription call) {
+		if (!ConfigUtils.isGroupCallsEnabled()) {
+			return;
+		}
+		if (ongoingCallNotice != null && groupCallManager != null && groupCallManager.isJoinedCall(call)) {
+			ongoingCallNotice.showGroupCall(call, OngoingCallNoticeMode.MODE_GROUP_CALL_JOINED);
+		}
 	}
 
 	/**
@@ -1300,31 +1320,31 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 				intent = new Intent(this, OutgoingGroupRequestActivity.class);
 				break;
 			case R.id.my_backups:
-				intent = new Intent(HomeActivity.this, BackupAdminActivity.class);
+				intent = new Intent(this, BackupAdminActivity.class);
 				break;
 			case R.id.webclient:
-				intent = new Intent(HomeActivity.this, SessionsActivity.class);
+				intent = new Intent(this, SessionsActivity.class);
 				break;
 			case R.id.scanner:
-				intent = new Intent(HomeActivity.this, BaseQrScannerActivity.class);
+				intent = new Intent(this, BaseQrScannerActivity.class);
 				break;
 			case R.id.help:
-				intent = new Intent(HomeActivity.this, SupportActivity.class);
+				intent = new Intent(this, SupportActivity.class);
 				break;
 			case R.id.settings:
-				AnimationUtil.startActivityForResult(this, null, new Intent(HomeActivity.this, SettingsActivity.class), ThreemaActivity.ACTIVITY_ID_SETTINGS);
+				AnimationUtil.startActivityForResult(this, null, new Intent(this, SettingsActivity.class), ThreemaActivity.ACTIVITY_ID_SETTINGS);
 				break;
 			case R.id.directory:
-				intent = new Intent(HomeActivity.this, DirectoryActivity.class);
+				intent = new Intent(this, DirectoryActivity.class);
 				break;
 			case R.id.threema_channel:
 				confirmThreemaChannel();
 				break;
 			case R.id.archived:
-				intent = new Intent(HomeActivity.this, ArchiveActivity.class);
+				intent = new Intent(this, ArchiveActivity.class);
 				break;
 			case R.id.globalsearch:
-				intent = new Intent(HomeActivity.this, GlobalSearchActivity.class);
+				intent = new Intent(this, GlobalSearchActivity.class);
 			default:
 				break;
 		}
@@ -1336,7 +1356,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		return super.onOptionsItemSelected(item);
 	}
 
-	private int initActionBar() {
+	private void initActionBar() {
 		toolbar = findViewById(R.id.main_toolbar);
 		setSupportActionBar(toolbar);
 		actionBar = getSupportActionBar();
@@ -1349,27 +1369,22 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		toolbarLogoMain.setColorFilter(ConfigUtils.getColorFromAttribute(this, android.R.attr.textColorSecondary),
 			PorterDuff.Mode.SRC_IN);
 		toolbarLogoMain.setContentDescription(getString(R.string.logo));
-		toolbarLogoMain.setOnClickListener(new View.OnClickListener() {
-			@Override
-			public void onClick(View v) {
-				if (currentFragmentTag != null) {
-					Fragment currentFragment = getSupportFragmentManager().findFragmentByTag(currentFragmentTag);
-					if (currentFragment != null && currentFragment.isAdded() && !currentFragment.isHidden()) {
-						if (currentFragment instanceof ContactsSectionFragment) {
-							((ContactsSectionFragment) currentFragment).onLogoClicked();
-						} else if (currentFragment instanceof MessageSectionFragment) {
-							((MessageSectionFragment) currentFragment).onLogoClicked();
-						} else if (currentFragment instanceof MyIDFragment) {
-							((MyIDFragment) currentFragment).onLogoClicked();
-						}
+		toolbarLogoMain.setOnClickListener(v -> {
+			if (currentFragmentTag != null) {
+				Fragment currentFragment = getSupportFragmentManager().findFragmentByTag(currentFragmentTag);
+				if (currentFragment != null && currentFragment.isAdded() && !currentFragment.isHidden()) {
+					if (currentFragment instanceof ContactsSectionFragment) {
+						((ContactsSectionFragment) currentFragment).onLogoClicked();
+					} else if (currentFragment instanceof MessageSectionFragment) {
+						((MessageSectionFragment) currentFragment).onLogoClicked();
+					} else if (currentFragment instanceof MyIDFragment) {
+						((MyIDFragment) currentFragment).onLogoClicked();
 					}
 				}
 			}
 		});
 
 		updateDrawerImage();
-
-		return toolbar.getMinimumHeight();
 	}
 
 	@Override
@@ -1381,7 +1396,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			MenuItem lockMenuItem = menu.findItem(R.id.menu_lock);
 			if (lockMenuItem != null) {
 				lockMenuItem.setVisible(
-						lockAppService.isLockingEnabled()
+					lockAppService.isLockingEnabled()
 				);
 			}
 
@@ -1412,7 +1427,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 				webDisabled = AppRestrictionUtil.isWebDisabled(this);
 			} else {
 				addDisabled = this.contactService != null &&
-						this.contactService.getByIdentity(THREEMA_CHANNEL_IDENTITY) != null;
+					this.contactService.getByIdentity(THREEMA_CHANNEL_IDENTITY) != null;
 			}
 
 			if (ConfigUtils.isWorkBuild()) {
@@ -1537,10 +1552,10 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 				reallyCancelVerify();
 				break;
 			case DIALOG_TAG_MASTERKEY_LOCKED:
-				startActivityForResult(new Intent(HomeActivity.this, UnlockMasterKeyActivity.class), ThreemaActivity.ACTIVITY_ID_UNLOCK_MASTER_KEY);
+				startActivityForResult(new Intent(this, UnlockMasterKeyActivity.class), ThreemaActivity.ACTIVITY_ID_UNLOCK_MASTER_KEY);
 				break;
 			case DIALOG_TAG_SERIAL_LOCKED:
-				startActivityForResult(new Intent(HomeActivity.this, EnterSerialActivity.class), ThreemaActivity.ACTIVITY_ID_ENTER_SERIAL);
+				startActivityForResult(new Intent(this, EnterSerialActivity.class), ThreemaActivity.ACTIVITY_ID_ENTER_SERIAL);
 				finish();
 				break;
 			case DIALOG_TAG_FINISH_UP:
@@ -1562,8 +1577,6 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	public void onNo(String tag, Object data) {
 		switch (tag) {
 			case DIALOG_TAG_MASTERKEY_LOCKED:
-				finish();
-				break;
 			case DIALOG_TAG_SERIAL_LOCKED:
 				finish();
 				break;
@@ -1590,17 +1603,14 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		}
 
 		if (serviceManager != null) {
-			new Thread(new Runnable() {
-				@Override
-				public void run() {
-					try {
-						FileService fileService = serviceManager.getFileService();
-						if (fileService != null) {
-							fileService.cleanTempDirs();
-						}
-					} catch (FileSystemNotPresentException e) {
-						logger.error("Exception", e);
+			new Thread(() -> {
+				try {
+					FileService fileService = serviceManager.getFileService();
+					if (fileService != null) {
+						fileService.cleanTempDirs();
 					}
+				} catch (FileSystemNotPresentException e) {
+					logger.error("Exception", e);
 				}
 			}).start();
 
@@ -1625,8 +1635,10 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		super.onUserInteraction();
 	}
 
-	protected void onActivityResult(int requestCode, int resultCode,
-									Intent data) {
+	@Override
+	protected void onActivityResult(
+		int requestCode, int resultCode,
+		Intent data) {
 
 		// http://www.androiddesignpatterns.com/2013/08/fragment-transaction-commit-state-loss.html
 		super.onActivityResult(requestCode, resultCode, data);
@@ -1649,7 +1661,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 					GenericAlertDialog.newInstance(R.string.master_key_locked,
 							R.string.master_key_locked_want_exit,
 							R.string.try_again, R.string.cancel)
-							.show(getSupportFragmentManager(), DIALOG_TAG_MASTERKEY_LOCKED);
+						.show(getSupportFragmentManager(), DIALOG_TAG_MASTERKEY_LOCKED);
 				} else {
 					//hide after unlock
 					if (IntentDataUtil.hideAfterUnlock(this.getIntent())) {
@@ -1672,7 +1684,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 						GenericAlertDialog.newInstance(R.string.enter_serial_title,
 								R.string.serial_required_want_exit,
 								R.string.try_again, R.string.cancel)
-								.show(getSupportFragmentManager(), DIALOG_TAG_SERIAL_LOCKED);
+							.show(getSupportFragmentManager(), DIALOG_TAG_SERIAL_LOCKED);
 					} else {
 						this.startMainActivity(null);
 					}
@@ -1688,13 +1700,12 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 					startActivity(i);
 				}
 				break;
-			case ThreemaActivity.ACTIVITY_ID_CONTACT_DETAIL:
-			case ThreemaActivity.ACTIVITY_ID_GROUP_DETAIL:
-			case ThreemaActivity.ACTIVITY_ID_COMPOSE_MESSAGE:
-				break;
 			case REQUEST_CODE_WHATSNEW:
 				showMainContent();
 				break;
+			case ThreemaActivity.ACTIVITY_ID_CONTACT_DETAIL:
+			case ThreemaActivity.ACTIVITY_ID_GROUP_DETAIL:
+			case ThreemaActivity.ACTIVITY_ID_COMPOSE_MESSAGE:
 			default:
 				break;
 		}
@@ -1708,7 +1719,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		File customAppIcon = null;
 		try {
 			customAppIcon = serviceManager.getFileService()
-					.getAppLogo(ConfigUtils.getAppTheme(this));
+				.getAppLogo(ConfigUtils.getAppTheme(this));
 		} catch (FileSystemNotPresentException e) {
 			logger.error("Exception", e);
 		}
@@ -1760,25 +1771,22 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 					launchThreemaChannelChat();
 
 					if (exception == null) {
-						new Thread(new Runnable() {
-							@Override
-							public void run() {
-								try {
-									MessageReceiver receiver = contactService.createReceiver(newContactModel);
-									if (!getResources().getConfiguration().locale.getLanguage().startsWith("de")) {
-										Thread.sleep(1000);
-										messageService.sendText("en", receiver);
-										Thread.sleep(500);
-									}
+						new Thread(() -> {
+							try {
+								MessageReceiver receiver = contactService.createReceiver(newContactModel);
+								if (!getResources().getConfiguration().locale.getLanguage().startsWith("de")) {
 									Thread.sleep(1000);
-									messageService.sendText(THREEMA_CHANNEL_START_NEWS_COMMAND, receiver);
-									Thread.sleep(1500);
-									messageService.sendText(ConfigUtils.isWorkBuild() ? THREEMA_CHANNEL_WORK_COMMAND : THREEMA_CHANNEL_START_ANDROID_COMMAND, receiver);
-									Thread.sleep(1500);
-									messageService.sendText(THREEMA_CHANNEL_INFO_COMMAND, receiver);
-								} catch (Exception e) {
-									//
+									messageService.sendText("en", receiver);
+									Thread.sleep(500);
 								}
+								Thread.sleep(1000);
+								messageService.sendText(THREEMA_CHANNEL_START_NEWS_COMMAND, receiver);
+								Thread.sleep(1500);
+								messageService.sendText(ConfigUtils.isWorkBuild() ? THREEMA_CHANNEL_WORK_COMMAND : THREEMA_CHANNEL_START_ANDROID_COMMAND, receiver);
+								Thread.sleep(1500);
+								messageService.sendText(THREEMA_CHANNEL_INFO_COMMAND, receiver);
+							} catch (Exception e) {
+								//
 							}
 						}).start();
 					}

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

@@ -32,6 +32,7 @@ import android.graphics.Color;
 import android.graphics.Matrix;
 import android.graphics.PointF;
 import android.graphics.Typeface;
+import android.graphics.drawable.Drawable;
 import android.media.FaceDetector;
 import android.net.Uri;
 import android.os.AsyncTask;
@@ -74,6 +75,7 @@ import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
 import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.content.res.AppCompatResources;
 import androidx.core.content.ContextCompat;
 import androidx.core.view.ViewCompat;
 import ch.threema.app.R;
@@ -177,7 +179,8 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 
 	@ColorInt private int penColor, backgroundColor;
 
-	private MenuItem undoItem, paletteItem, paintItem, pencilItem, blurFacesItem;
+	private MenuItem undoItem, drawParentItem, paintItem, pencilItem, blurFacesItem;
+	private Drawable brushIcon, pencilIcon;
 	private PaintSelectionPopup paintSelectionPopup;
 	private final ArrayList<MotionEntity> undoHistory = new ArrayList<>();
 	private boolean saveSemaphore = false;
@@ -345,6 +348,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 
 		TextEntity textEntity = new TextEntity(textLayer, motionView.getWidth(),
 				motionView.getHeight());
+		textEntity.setColor(penColor);
 		motionView.addEntityAndPosition(textEntity);
 	}
 
@@ -394,6 +398,9 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		this.imageView = findViewById(R.id.preview_image);
 		this.motionView = findViewById(R.id.motion_view);
 
+		this.brushIcon = AppCompatResources.getDrawable(this, R.drawable.ic_brush);
+		this.pencilIcon = AppCompatResources.getDrawable(this, R.drawable.ic_pencil_outline);
+
 		this.penColor = getResources().getColor(R.color.material_red);
 		this.backgroundColor = Color.WHITE;
 		if (savedInstanceState != null) {
@@ -442,8 +449,8 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 			}
 
 			@Override
-			public void onLongClick(MotionEntity entity, int x, int y) {
-				paintSelectionPopup.show((int) motionView.getX() + x, (int) motionView.getY() + y, !entity.hasFixedPositionAndSize());
+			public void onLongClick(@NonNull MotionEntity entity, int x, int y) {
+				paintSelectionPopup.show((int) motionView.getX() + x, (int) motionView.getY() + y, entity);
 			}
 
 			@Override
@@ -472,20 +479,23 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		this.paintSelectionPopup = new PaintSelectionPopup(this, this.motionView);
 		this.paintSelectionPopup.setListener(new PaintSelectionPopup.PaintSelectPopupListener() {
 			@Override
-			public void onItemSelected(int tag) {
-				switch (tag) {
-					case PaintSelectionPopup.TAG_REMOVE:
-						deleteEntity();
-						break;
-					case PaintSelectionPopup.TAG_FLIP:
-						flipEntity();
-						break;
-					case PaintSelectionPopup.TAG_TO_FRONT:
-						bringToFrontEntity();
-						break;
-					default:
-						break;
-				}
+			public void onRemoveClicked() {
+				deleteEntity();
+			}
+
+			@Override
+			public void onFlipClicked() {
+				flipEntity();
+			}
+
+			@Override
+			public void onBringToFrontClicked() {
+				bringToFrontEntity();
+			}
+
+			@Override
+			public void onColorClicked() {
+				colorEntity();
 			}
 
 			@Override
@@ -812,7 +822,13 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 	public boolean onPrepareOptionsMenu(Menu menu) {
 		super.onPrepareOptionsMenu(menu);
 
-		ConfigUtils.themeMenuItem(paletteItem, Color.WHITE);
+		if (this.strokeMode == STROKE_MODE_PENCIL) {
+			drawParentItem.setIcon(pencilIcon);
+		} else {
+			drawParentItem.setIcon(brushIcon);
+		}
+
+		ConfigUtils.themeMenuItem(drawParentItem, Color.WHITE);
 		ConfigUtils.themeMenuItem(paintItem, Color.WHITE);
 		ConfigUtils.themeMenuItem(pencilItem, Color.WHITE);
 
@@ -821,8 +837,12 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 			if (paintView.getActive()) {
 				if (this.strokeMode == STROKE_MODE_PENCIL) {
 					ConfigUtils.themeMenuItem(pencilItem, this.penColor);
+					drawParentItem.setIcon(pencilIcon);
+					ConfigUtils.themeMenuItem(drawParentItem, this.penColor);
 				} else {
 					ConfigUtils.themeMenuItem(paintItem, this.penColor);
+					drawParentItem.setIcon(brushIcon);
+					ConfigUtils.themeMenuItem(drawParentItem, this.penColor);
 				}
 			}
 		}
@@ -838,7 +858,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		getMenuInflater().inflate(R.menu.activity_image_paint, menu);
 
 		undoItem = menu.findItem(R.id.item_undo);
-		paletteItem = menu.findItem(R.id.item_palette);
+		drawParentItem = menu.findItem(R.id.item_draw_parent);
 		paintItem = menu.findItem(R.id.item_draw);
 		pencilItem = menu.findItem(R.id.item_pencil);
 		blurFacesItem = menu.findItem(R.id.item_face);
@@ -954,6 +974,15 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		invalidateOptionsMenu();
 	}
 
+	private void colorEntity() {
+		final MotionEntity selectedEntity = motionView.getSelectedEntity();
+		if (selectedEntity == null) {
+			logger.warn("Cannot change entity color when no entity is selected");
+			return;
+		}
+		chooseColor(selectedEntity::setColor, selectedEntity.getColor());
+	}
+
 	private void undo() {
 		if (undoHistory.size() > 0) {
 			MotionEntity entity = undoHistory.get(undoHistory.size() - 1);
@@ -1025,20 +1054,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		chooseColor(color -> {
 			paintView.setColor(color);
 			penColor = color;
-
-			ConfigUtils.themeMenuItem(paletteItem, penColor);
-			if (motionView.getSelectedEntity() != null) {
-				if (motionView.getSelectedEntity() instanceof TextEntity) {
-					TextEntity textEntity = (TextEntity) motionView.getSelectedEntity();
-					textEntity.getLayer().getFont().setColor(penColor);
-					textEntity.updateEntity();
-					motionView.invalidate();
-				} else {
-					// ignore color selection for stickers
-				}
-			} else {
-				setDrawMode(true);
-			}
+			setDrawMode(true);
 		}, penColor);
 	}
 

+ 57 - 15
app/src/main/java/ch/threema/app/activities/ServerMessageActivity.java

@@ -22,18 +22,32 @@
 package ch.threema.app.activities;
 
 import android.os.Bundle;
-import android.view.View;
+import android.view.MenuItem;
 import android.widget.TextView;
 
+import org.slf4j.Logger;
+
+import androidx.annotation.NonNull;
 import androidx.appcompat.app.ActionBar;
+import androidx.lifecycle.ViewModelProvider;
 import ch.threema.app.R;
+import ch.threema.app.ThreemaApplication;
+import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.services.NotificationService;
+import ch.threema.app.ui.ServerMessageViewModel;
 import ch.threema.app.utils.ConfigUtils;
-import ch.threema.app.utils.IntentDataUtil;
-import ch.threema.storage.models.ServerMessageModel;
+import ch.threema.base.utils.LoggingUtil;
 
 public class ServerMessageActivity extends ThreemaActivity {
-	ServerMessageModel serverMessageModel;
+	private final static Logger logger = LoggingUtil.getThreemaLogger("ServerMessageActivity");
+
+	private NotificationService notificationService = null;
+
+	private ServerMessageViewModel viewModel;
 
+	private TextView serverMessageTextView;
+
+	@Override
 	public void onCreate(Bundle savedInstanceState) {
 		ConfigUtils.configureActivityTheme(this);
 
@@ -46,26 +60,54 @@ public class ServerMessageActivity extends ThreemaActivity {
 		}
 
 		setContentView(R.layout.activity_server_message);
-		this.serverMessageModel = IntentDataUtil.getServerMessageModel(this.getIntent());
-
-		String message = this.serverMessageModel.getMessage();
 
-		if (message == null) {
+		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+		if (serviceManager == null) {
+			logger.error("Service manager is null");
 			finish();
 			return;
 		}
 
-		if (message.startsWith("Another connection")) {
-			message = getString(R.string.another_connection_instructions, getString(R.string.app_name));
-		}
+		this.serverMessageTextView = findViewById(R.id.server_message_text);
+
+		notificationService = serviceManager.getNotificationService();
+
+		viewModel = new ViewModelProvider(this).get(ServerMessageViewModel.class);
+
+		findViewById(R.id.close_button).setOnClickListener(v -> viewModel.markServerMessageAsRead());
 
-		((TextView)findViewById(R.id.server_message_text)).setText(message);
-		findViewById(R.id.close_button).setOnClickListener(new View.OnClickListener() {
-			@Override
-			public void onClick(View v) {
+		viewModel.getServerMessage().observe(this, serverMessage -> {
+			if (serverMessage == null) {
+				// Cancel the server message notification as the "Another connection..." message
+				// may be received several times. This would open another notification. Because the
+				// message is the same, it is shown only once and therefore has been deleted at this
+				// point.
+				cancelServerMessageNotification();
 				finish();
+				return;
 			}
+			showServerMessage(serverMessage);
 		});
 	}
 
+	@Override
+	public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+		if (item.getItemId() == android.R.id.home) {
+			viewModel.markServerMessageAsRead();
+			return true;
+		}
+		return super.onOptionsItemSelected(item);
+	}
+
+	private void showServerMessage(@NonNull String message) {
+		if (message.startsWith("Another connection")) {
+			message = getString(R.string.another_connection_instructions, getString(R.string.app_name));
+		}
+		serverMessageTextView.setText(message);
+	}
+
+	private void cancelServerMessageNotification() {
+		notificationService.cancel(ThreemaApplication.SERVER_MESSAGE_NOTIFICATION_ID);
+	}
+
 }

+ 13 - 10
app/src/main/java/ch/threema/app/activities/StorageManagementActivity.java

@@ -60,6 +60,7 @@ import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.UserService;
+import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.base.utils.LoggingUtil;
@@ -156,16 +157,18 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 		});
 
 		Button deleteAllButton = findViewById(R.id.delete_everything_button);
-		deleteAllButton.setOnClickListener(new View.OnClickListener() {
-			@Override
-			public void onClick(View v) {
-				GenericAlertDialog.newInstance(
-					R.string.delete_id_title,
-					R.string.delete_id_message,
-					R.string.delete_everything,
-					R.string.cancel).show(getSupportFragmentManager(), DIALOG_TAG_DELETE_ID);
-			}
-		});
+
+		if (ConfigUtils.isWorkBuild() && AppRestrictionUtil.isReadonlyProfile(this)) {
+			// In readonly profile the user should not be able to delete its ID
+			deleteAllButton.setVisibility(View.GONE);
+		} else {
+			deleteAllButton.setOnClickListener(v -> GenericAlertDialog.newInstance(
+				R.string.delete_id_title,
+				R.string.delete_id_message,
+				R.string.delete_everything,
+				R.string.cancel
+			).show(getSupportFragmentManager(), DIALOG_TAG_DELETE_ID));
+		}
 
 		final ArrayAdapter<CharSequence> adapter = ArrayAdapter.createFromResource(this, R.array.storagemanager_timeout, android.R.layout.simple_spinner_dropdown_item);
 		timeSpinner.setAdapter(adapter);

+ 18 - 26
app/src/main/java/ch/threema/app/activities/wizard/WizardBaseActivity.java

@@ -339,36 +339,28 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements
 	 * Perform an early synchronous fetch2. In case of failure due to rate-limiting, do not allow user to continue
 	 */
 	private void performWorkSync() {
-		final String workerTag = "WorkSyncWorker";
-
 		GenericProgressDialog.newInstance(R.string.work_data_sync_desc,
 			R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC);
 
-		OneTimeWorkRequest workRequest = WorkSyncWorker.Companion.buildOneTimeWorkRequest(false, true, workerTag);
-		WorkManager workManager = WorkManager.getInstance(ThreemaApplication.getAppContext());
-		workManager.getWorkInfosByTagLiveData(workerTag).observe(this, workInfos -> {
-			if (workInfos != null) {
-				for (WorkInfo workInfo : workInfos) {
-					if (workInfo.getState().isFinished()) {
-						DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC, true);
-					}
-
-					if (workInfo.getState() == WorkInfo.State.SUCCEEDED) {
-						setupConfig();
-					} else if (workInfo.getState() == WorkInfo.State.FAILED) {
-						RuntimeUtil.runOnUiThread(() -> Toast.makeText(WizardBaseActivity.this, R.string.unable_to_fetch_configuration, Toast.LENGTH_LONG).show());
-						logger.info("Unable to post work request for fetch2");
-						try {
-							userService.removeIdentity();
-						} catch (Exception e) {
-							logger.error("Unable to remove identity", e);
-						}
-						finishAndRemoveTask();
-					}
+		WorkSyncWorker.Companion.performOneTimeWorkSync(
+			this,
+			() -> {
+				// On success
+				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC, true);
+				setupConfig();
+			},
+			() -> {
+				// On fail
+				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC, true);
+				RuntimeUtil.runOnUiThread(() -> Toast.makeText(WizardBaseActivity.this, R.string.unable_to_fetch_configuration, Toast.LENGTH_LONG).show());
+				logger.info("Unable to post work request for fetch2");
+				try {
+					userService.removeIdentity();
+				} catch (Exception e) {
+					logger.error("Unable to remove identity", e);
 				}
-			}
-		});
-		workManager.enqueueUniqueWork(WORKER_WORK_SYNC, ExistingWorkPolicy.REPLACE, workRequest);
+				finishAndRemoveTask();
+			});
 	}
 
 	private void splitMobile(String phoneNumber) {

+ 45 - 12
app/src/main/java/ch/threema/app/activities/wizard/WizardIDRestoreActivity.java

@@ -42,6 +42,7 @@ import android.widget.EditText;
 import org.slf4j.Logger;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericProgressDialog;
@@ -52,6 +53,7 @@ import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.EditTextUtil;
 import ch.threema.app.utils.QRScannerUtil;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.domain.protocol.api.FetchIdentityException;
 import ch.threema.domain.protocol.csp.connection.ThreemaConnection;
 
 public class WizardIDRestoreActivity extends WizardBackgroundActivity {
@@ -79,25 +81,31 @@ public class WizardIDRestoreActivity extends WizardBackgroundActivity {
 		backupIdText.setImeOptions(EditorInfo.IME_ACTION_SEND);
 		backupIdText.setRawInputType(InputType.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_FLAG_CAP_CHARACTERS);
 		backupIdText.addTextChangedListener(new TextWatcher() {
+			@Override
 			public void afterTextChanged(Editable s) {
 				idOK = s.length() > 0 && s.toString().trim().length() == BACKUP_STRING_LENGTH;
 				updateMenu();
 			}
 
+			@Override
 			public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
 
+			@Override
 			public void onTextChanged(CharSequence s, int start, int before, int count) {}
 		});
 
 		passwordEditText = findViewById(R.id.restore_password);
 		passwordEditText.addTextChangedListener(new TextWatcher() {
+			@Override
 			public void afterTextChanged(Editable s) {
 				passwordOK = s.length() >= ThreemaApplication.MIN_PW_LENGTH_ID_EXPORT_LEGACY;
 				updateMenu();
 			}
 
+			@Override
 			public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
 
+			@Override
 			public void onTextChanged(CharSequence s, int start, int before, int count) {}
 		});
 
@@ -124,11 +132,7 @@ public class WizardIDRestoreActivity extends WizardBackgroundActivity {
 	private void updateMenu() {
 		if (nextButton == null) return;
 
-		if (idOK && passwordOK) {
-			nextButton.setEnabled(true);
-		} else {
-			nextButton.setEnabled(false);
-		}
+		nextButton.setEnabled(idOK && passwordOK);
 	}
 
 	public void onCancel(View view) {
@@ -144,7 +148,7 @@ public class WizardIDRestoreActivity extends WizardBackgroundActivity {
 		EditTextUtil.hideSoftKeyboard(backupIdText);
 		EditTextUtil.hideSoftKeyboard(passwordEditText);
 
-		new AsyncTask<Void, Void, Boolean>() {
+		new AsyncTask<Void, Void, RestoreResult>() {
 			String password, backupString;
 
 			@Override
@@ -155,33 +159,37 @@ public class WizardIDRestoreActivity extends WizardBackgroundActivity {
 			}
 
 			@Override
-			protected Boolean doInBackground(Void... params) {
+			protected RestoreResult doInBackground(Void... params) {
 				try {
 					ThreemaConnection connection = serviceManager.getConnection();
 					if (connection.isRunning()) {
 						connection.stop();
 					}
-					return serviceManager.getUserService().restoreIdentity(backupString, password);
+					if (serviceManager.getUserService().restoreIdentity(backupString, password)) {
+						return RestoreResult.success();
+					}
 				} catch (InterruptedException e) {
 					logger.error("Interrupted", e);
 					Thread.currentThread().interrupt();
+				} catch(FetchIdentityException e) {
+					return RestoreResult.failure(e.getMessage());
 				} catch (Exception e) {
 					logger.error("Exception", e);
 				}
-				return false;
+				return RestoreResult.failure(getString(R.string.wrong_backupid_or_password_or_no_internet_connection));
 			}
 
 			@Override
-			protected void onPostExecute(Boolean result) {
+			protected void onPostExecute(RestoreResult result) {
 				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_RESTORE_PROGRESS, true);
 
-				if (result) {
+				if (result.isSuccess()) {
 					// ID successfully restored from ID backup - cancel reminder
 					serviceManager.getPreferenceService().incrementIDBackupCount();
 					setResult(RESULT_OK);
 					finish();
 				} else {
-					getSupportFragmentManager().beginTransaction().add(SimpleStringAlertDialog.newInstance(R.string.error, getString(R.string.wrong_backupid_or_password_or_no_internet_connection)), "er").commitAllowingStateLoss();
+					getSupportFragmentManager().beginTransaction().add(SimpleStringAlertDialog.newInstance(R.string.error, result.getErrorMessage()), "er").commitAllowingStateLoss();
 				}
 			}
 		}.execute();
@@ -222,4 +230,29 @@ public class WizardIDRestoreActivity extends WizardBackgroundActivity {
 				break;
 		}
 	}
+
+	private static class RestoreResult {
+		private final @Nullable String errorMessage;
+
+		public static RestoreResult success() {
+			return new RestoreResult(null);
+		}
+
+		public static RestoreResult failure(@Nullable String errorMessage) {
+			return new RestoreResult(errorMessage);
+		}
+
+		private RestoreResult(@Nullable String errorMessage) {
+			this.errorMessage = errorMessage;
+		}
+
+		public boolean isSuccess() {
+			return errorMessage == null;
+		}
+
+		@Nullable
+		public String getErrorMessage() {
+			return errorMessage;
+		}
+	}
 }

+ 42 - 10
app/src/main/java/ch/threema/app/activities/wizard/WizardSafeRestoreActivity.java

@@ -50,7 +50,9 @@ import ch.threema.app.threemasafe.ThreemaSafeService;
 import ch.threema.app.threemasafe.ThreemaSafeServiceImpl;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
+import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.app.workers.WorkSyncWorker;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.domain.protocol.csp.ProtocolDefines;
@@ -64,6 +66,7 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 	private static final String DIALOG_TAG_PROGRESS = "tpr";
 	private static final String DIALOG_TAG_FORGOT_ID = "li";
 	private static final String DIALOG_TAG_ADVANCED = "adv";
+	private static final String DIALOG_TAG_WORK_SYNC = "workSync";
 
 	private ThreemaSafeService threemaSafeService;
 
@@ -248,17 +251,32 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_PROGRESS, true);
 
 				if (failureMessage == null) {
-					SimpleStringAlertDialog.newInstance(R.string.restore_success_body,
-							Build.VERSION.SDK_INT <= Build.VERSION_CODES.P ?
-									R.string.android_backup_restart_threema :
-									R.string.safe_backup_tap_to_restart,
-							true).show(getSupportFragmentManager(), "d");
-					try {
-						serviceManager.startConnection();
-					} catch (ThreemaException e) {
-						logger.error("Exception", e);
+					if (ConfigUtils.isWorkBuild()) {
+						GenericProgressDialog.newInstance(R.string.work_data_sync_desc,
+							R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC);
+
+						WorkSyncWorker.Companion.performOneTimeWorkSync(
+							WizardSafeRestoreActivity.this,
+							() -> {
+								// On success
+								DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC, true);
+								onSuccessfulRestore();
+							},
+							() -> {
+								// On fail
+								DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC, true);
+								RuntimeUtil.runOnUiThread(() -> Toast.makeText(WizardSafeRestoreActivity.this, R.string.unable_to_fetch_configuration, Toast.LENGTH_LONG).show());
+								logger.info("Unable to post work request for fetch2");
+								try {
+									userService.removeIdentity();
+								} catch (Exception e) {
+									logger.error("Unable to remove identity", e);
+								}
+								finishAndRemoveTask();
+							});
+					} else {
+						onSuccessfulRestore();
 					}
-					ConfigUtils.scheduleAppRestart(getApplicationContext(), 3000, getApplicationContext().getString(R.string.ipv6_restart_now));
 				} else {
 					Toast.makeText(WizardSafeRestoreActivity.this, getString(R.string.safe_restore_failed) + ". " + failureMessage, Toast.LENGTH_LONG).show();
 					if (safeMDMConfig.isRestoreForced()) {
@@ -269,6 +287,20 @@ public class WizardSafeRestoreActivity extends WizardBackgroundActivity implemen
 		}.execute();
 	}
 
+	private void onSuccessfulRestore() {
+		SimpleStringAlertDialog.newInstance(R.string.restore_success_body,
+			Build.VERSION.SDK_INT <= Build.VERSION_CODES.P ?
+				R.string.android_backup_restart_threema :
+				R.string.safe_backup_tap_to_restart,
+			true).show(getSupportFragmentManager(), "d");
+		try {
+			serviceManager.startConnection();
+		} catch (ThreemaException e) {
+			logger.error("Exception", e);
+		}
+		ConfigUtils.scheduleAppRestart(getApplicationContext(), 3000, getApplicationContext().getString(R.string.ipv6_restart_now));
+	}
+
 	@Override
 	public void onBackPressed() {
 		finish();

+ 22 - 17
app/src/main/java/ch/threema/app/adapters/MessageListAdapter.java

@@ -24,6 +24,7 @@ package ch.threema.app.adapters;
 import android.content.Context;
 import android.content.res.ColorStateList;
 import android.graphics.PorterDuff;
+import android.os.SystemClock;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
@@ -65,7 +66,6 @@ import ch.threema.app.utils.AdapterUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.MessageUtil;
 import ch.threema.app.utils.NameUtil;
-import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.StateBitmapUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.ViewUtil;
@@ -133,6 +133,7 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 		private final Chip joinGroupCallButton;
 		private final TextView ongoingCallDivider, ongoingCallText;
 		private final Chronometer groupCallDuration;
+		private boolean isChronometerRunning = false;
 
 		MessageListViewHolder(final View itemView, final GroupCallManager groupCallManager) {
 			super(itemView);
@@ -190,9 +191,12 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 
 		@AnyThread
 		private void startGroupCallDuration(long base) {
-			RuntimeUtil.runOnUiThread(() -> {
-				groupCallDuration.setBase(base);
-				groupCallDuration.start();
+			groupCallDuration.post(() -> {
+				if (base != groupCallDuration.getBase() || !isChronometerRunning) {
+					groupCallDuration.setBase(base);
+					groupCallDuration.start();
+					isChronometerRunning = true;
+				}
 				groupCallDuration.setVisibility(View.VISIBLE);
 				ongoingCallDivider.setVisibility(View.VISIBLE);
 			});
@@ -200,8 +204,11 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 
 		@AnyThread
 		private void stopGroupCallDuration() {
-			RuntimeUtil.runOnUiThread(() -> {
-				groupCallDuration.stop();
+			groupCallDuration.post(() -> {
+				if (isChronometerRunning) {
+					groupCallDuration.stop();
+					isChronometerRunning = false;
+				}
 				groupCallDuration.setVisibility(View.GONE);
 				ongoingCallDivider.setVisibility(View.GONE);
 			});
@@ -220,11 +227,6 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 		}
 
 		public ConversationModel getConversationModel() { return conversationModel; }
-
-		@Override
-		public void onGroupCallStart(@NonNull GroupModel groupModel) {
-			ListenerManager.conversationListeners.handle(listener -> listener.onModified(conversationModel, null));
-		}
 	}
 
 	public static class FooterViewHolder extends RecyclerView.ViewHolder {
@@ -309,6 +311,7 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 			MessageListViewHolder messageListViewHolder = (MessageListViewHolder) holder;
 			GroupModel group = messageListViewHolder.conversationModel.getGroup();
 			groupCallManager.removeGroupCallObserver(group, messageListViewHolder);
+			messageListViewHolder.stopGroupCallDuration();
 		}
 	}
 
@@ -469,11 +472,10 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 						if (viewElement.icon != null) {
 							holder.attachmentView.setVisibility(View.VISIBLE);
 							holder.attachmentView.setImageResource(viewElement.icon);
-							if (viewElement.placeholder != null) {
-								holder.attachmentView.setContentDescription(viewElement.placeholder);
-							} else {
-								holder.attachmentView.setContentDescription("");
-							}
+							String description = viewElement.placeholder != null
+								? viewElement.placeholder
+								: "";
+							holder.attachmentView.setContentDescription(description);
 
 							// Configure attachment
 							// Configure color of the attachment view
@@ -637,7 +639,10 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 				holder.joinGroupCallButton.setChipBackgroundColor(groupCallTextColor.withAlpha(0x1a));
 				holder.ongoingCallText.setText(isJoined ? R.string.voip_gc_in_call : R.string.voip_gc_ongoing_call);
 				holder.ongoingGroupCallContainer.setVisibility(View.VISIBLE);
-
+				holder.groupCallDuration.postDelayed(() -> {
+					Long runningSince = call.getRunningSince();
+					holder.startGroupCallDuration(runningSince != null ? runningSince : SystemClock.elapsedRealtime());
+				}, 100L);
 				holder.unreadCountView.setVisibility(View.GONE);
 				holder.pinIcon.setVisibility(View.GONE);
 				holder.typingContainer.setVisibility(View.GONE);

+ 166 - 194
app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java

@@ -195,7 +195,7 @@ import ch.threema.app.ui.DebouncedOnClickListener;
 import ch.threema.app.ui.ListViewSwipeListener;
 import ch.threema.app.ui.LockableScrollView;
 import ch.threema.app.ui.MediaItem;
-import ch.threema.app.ui.OngoingCallNoticeModes;
+import ch.threema.app.ui.OngoingCallNoticeMode;
 import ch.threema.app.ui.OngoingCallNoticeView;
 import ch.threema.app.ui.OpenBallotNoticeView;
 import ch.threema.app.ui.QRCodePopup;
@@ -232,12 +232,12 @@ import ch.threema.app.utils.SoundUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.ToolbarUtil;
 import ch.threema.app.voicemessage.VoiceRecorderActivity;
-import ch.threema.app.voip.activities.GroupCallActivity;
 import ch.threema.app.voip.groupcall.GroupCallDescription;
 import ch.threema.app.voip.groupcall.GroupCallManager;
 import ch.threema.app.voip.groupcall.GroupCallObserver;
 import ch.threema.app.voip.listeners.VoipCallEventListener;
 import ch.threema.app.voip.managers.VoipListenerManager;
+import ch.threema.app.voip.services.VoipCallService;
 import ch.threema.app.voip.services.VoipStateService;
 import ch.threema.app.voip.util.VoipUtil;
 import ch.threema.base.utils.LoggingUtil;
@@ -279,7 +279,6 @@ public class ComposeMessageFragment extends Fragment implements
 	ChatAdapterDecorator.ActionModeStatus,
 	SelectorDialog.SelectorDialogClickListener,
 	EmojiPicker.EmojiPickerListener,
-	OpenBallotNoticeView.VisibilityListener,
 	ReportSpamView.OnReportButtonClickListener,
 	ThreemaToolbarActivity.OnSoftKeyboardChangedListener,
 	ExpandableTextEntryDialog.ExpandableTextEntryDialogClickListener {
@@ -462,7 +461,7 @@ public class ComposeMessageFragment extends Fragment implements
 	private ProgressBar searchProgress;
 	private ImageView searchNextButton, searchPreviousButton;
 
-	private OngoingCallNoticeView ongoingCallNoticeView;
+	private OngoingCallNoticeView ongoingCallNotice;
 	private GroupCallObserver groupCallObserver;
 
 	@SuppressLint("SimpleDateFormat")
@@ -555,24 +554,29 @@ public class ComposeMessageFragment extends Fragment implements
 		public void onFinished(long callId, @NonNull String peerIdentity, boolean outgoing, int duration) {
 			logger.debug("VoipCallEventListener onFinished");
 			updateVoipCallMenuItem(true);
+			hideOngoingVoipCallNotice();
 		}
 
 		@Override
 		public void onRejected(long callId, String peerIdentity, boolean outgoing, byte reason) {
 			logger.debug("VoipCallEventListener onRejected");
 			updateVoipCallMenuItem(true);
+			hideOngoingVoipCallNotice();
 		}
 
 		@Override
 		public void onMissed(long callId, String peerIdentity, boolean accepted, @Nullable Date date) {
 			logger.debug("VoipCallEventListener onMissed");
 			updateVoipCallMenuItem(true);
+			hideOngoingVoipCallNotice();
 		}
 
 		@Override
 		public void onAborted(long callId, String peerIdentity) {
 			logger.debug("VoipCallEventListener onAborted");
-			updateVoipCallMenuItem(true); }
+			updateVoipCallMenuItem(true);
+			hideOngoingVoipCallNotice();
+		}
 	};
 
 	private final MessageListener messageListener = new MessageListener() {
@@ -725,7 +729,7 @@ public class ComposeMessageFragment extends Fragment implements
 				registerGroupCallObserver();
 			} else {
 				// Remove ongoing group call notice if not a member of the group anymore
-				RuntimeUtil.runOnUiThread(() -> updateOngoingGroupCallState(null));
+				updateOngoingCallNotice();
 				removeGroupCallObserver();
 			}
 		}
@@ -1087,65 +1091,89 @@ public class ComposeMessageFragment extends Fragment implements
 		return this.fragmentView;
 	}
 
-	@UiThread
+	@AnyThread
 	private void initOngoingCallState() {
-		ongoingCallNoticeView = fragmentView.findViewById(R.id.ongoing_call_notice);
-		if (ongoingCallNoticeView != null) {
-			ongoingCallNoticeView.setButtonAction(this::joinOngoingGroupCall);
-			ongoingCallNoticeView.setContainerAction(null);
-
+		ongoingCallNotice = fragmentView.findViewById(R.id.ongoing_call_notice);
+		if (ongoingCallNotice != null) {
 			if (groupModel != null && groupService.isGroupMember(groupModel)) {
 				registerGroupCallObserver();
 			} else {
-				updateOngoingGroupCallState(null);
+				updateOngoingCallNotice();
 			}
 		}
 	}
 
-	private void joinOngoingGroupCall() {
-		if (groupModel != null) {
-			startActivity(GroupCallActivity.getJoinCallIntent(requireActivity(), groupModel.getId()));
+	@AnyThread
+	private void updateOngoingCallNotice() {
+		boolean hasRunningOOCall = VoipCallService.isRunning()
+			&& contactModel != null
+			&& contactModel.getIdentity() != null
+			&& contactModel.getIdentity().equals(VoipCallService.getOtherPartysIdentity());
+
+		GroupCallDescription chosenCall = getChosenCall();
+		boolean hasRunningGroupCall = chosenCall != null;
+		boolean hasJoinedGroupCall = hasRunningGroupCall
+			&& groupCallManager.isJoinedCall(chosenCall);
+
+		if (hasRunningOOCall && hasJoinedGroupCall) {
+			logger.warn("Invalid state: joined 1:1 AND group call, not showing call notice");
+			updateVoipCallMenuItem(true);
+			hideOngoingCallNotice();
+		} else if (hasRunningOOCall) {
+			showOngoingVoipCallNotice();
+		} else if (hasRunningGroupCall) {
+			OngoingCallNoticeMode mode = hasJoinedGroupCall
+				? OngoingCallNoticeMode.MODE_GROUP_CALL_JOINED
+				: OngoingCallNoticeMode.MODE_GROUP_CALL_RUNNING;
+			showOngoingGroupCallNotice(mode, chosenCall);
+		} else {
+			updateVoipCallMenuItem(true);
+			hideOngoingCallNotice();
 		}
 	}
 
-	private void unsetOngoingCallState() {
-		if (ongoingCallNoticeView != null) {
-			ongoingCallNoticeView.setButtonAction(null);
-			ongoingCallNoticeView.setContainerAction(null);
+	@Nullable
+	private GroupCallDescription getChosenCall() {
+		return ConfigUtils.isGroupCallsEnabled() && groupModel != null && groupCallManager != null
+			? groupCallManager.getCurrentChosenCall(groupModel)
+			: null;
+	}
+
+	@AnyThread
+	private void showOngoingVoipCallNotice() {
+		if (ongoingCallNotice != null) {
+			ongoingCallNotice.showVoip();
+		}
+	}
+
+	@AnyThread
+	private void hideOngoingVoipCallNotice() {
+		if (ongoingCallNotice != null) {
+			ongoingCallNotice.hideVoip();
+		}
+	}
+
+	@AnyThread
+	private void hideOngoingCallNotice() {
+		if (ongoingCallNotice != null) {
+			ongoingCallNotice.hide();
 		}
-		removeGroupCallObserver();
 	}
 
+	@AnyThread
 	private void registerGroupCallObserver() {
 		removeGroupCallObserver();
 		if (groupModel != null && groupCallManager != null) {
-			groupCallObserver = new GroupCallObserver() {
-				@Override
-				public void onGroupCallUpdate(@Nullable GroupCallDescription call) {
-					RuntimeUtil.runOnUiThread(() -> ComposeMessageFragment.this.updateOngoingGroupCallState(call));
-				}
-
-				@Override
-				public void onGroupCallStart(@NonNull GroupModel groupModel) {
-					// noop
-				}
-			};
+			groupCallObserver = call -> updateOngoingCallNotice();
 			groupCallManager.addGroupCallObserver(groupModel, groupCallObserver);
 		}
 	}
 
-	@UiThread
-	private void updateOngoingGroupCallState(@Nullable GroupCallDescription call) {
-		if (call != null && ConfigUtils.isGroupCallsEnabled()) {
-			int participantsCount = call.getCallState() != null ?
-				call.getCallState().getParticipants().size() :
-				0;
-
-			ongoingCallNoticeView.show(call.getRunningSince(), groupCallManager.isJoinedCall(call) ? OngoingCallNoticeModes.MODE_GROUP_CALL_JOINED : OngoingCallNoticeModes.MODE_GROUP_CALL_RUNNING, participantsCount);
+	@AnyThread
+	private void showOngoingGroupCallNotice(OngoingCallNoticeMode mode, @NonNull GroupCallDescription call) {
+		if (ongoingCallNotice != null) {
+			ongoingCallNotice.showGroupCall(call, mode);
 			updateVoipCallMenuItem(false);
-		} else {
-			ongoingCallNoticeView.hide();
-			updateVoipCallMenuItem(true);
 		}
 	}
 
@@ -1311,12 +1339,7 @@ public class ComposeMessageFragment extends Fragment implements
 				this.openGroupRequestNoticeView.updateGroupRequests();
 			}
 
-			if (groupModel != null && groupCallManager != null && groupService.isGroupMember(groupModel)) {
-				GroupCallDescription call = groupCallManager.getCurrentChosenCall(groupModel);
-				if (call != null) {
-					updateOngoingGroupCallState(call);
-				}
-			}
+			updateOngoingCallNotice();
 		}
 	}
 
@@ -1378,7 +1401,7 @@ public class ComposeMessageFragment extends Fragment implements
 	@Override
 	public void onDestroyView() {
 		super.onDestroyView();
-		unsetOngoingCallState();
+		removeGroupCallObserver();
 	}
 
 	@Override
@@ -2129,7 +2152,6 @@ public class ComposeMessageFragment extends Fragment implements
 		this.messageText.setText("");
 		this.messageText.setMessageReceiver(this.messageReceiver);
 		this.openBallotNoticeView.setMessageReceiver(this.messageReceiver);
-		this.openBallotNoticeView.setVisibilityListener(this);
 
 		// restore draft before setting predefined text
 		restoreMessageDraft(false);
@@ -2322,6 +2344,11 @@ public class ComposeMessageFragment extends Fragment implements
 			return false;
 		}
 
+		if (message.getType() == MessageType.BALLOT && !message.isOutbox()) {
+			// If we receive a new ballot message
+			openBallotNoticeView.update();
+		}
+
 		//check if the message already added
 		if (this.listInitializedAt != null && message.getCreatedAt().before(this.listInitializedAt)) {
 			return false;
@@ -3730,7 +3757,7 @@ public class ComposeMessageFragment extends Fragment implements
 						intent = new Intent(activity, ContactNotificationsActivity.class);
 						intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, this.identity);
 					}
-					if (ToolbarUtil.getMenuItemCenterPosition(((ThreemaToolbarActivity)activity).getToolbar(), R.id.menu_muted, location)) {
+					if (ToolbarUtil.getMenuItemCenterPosition(activity.getToolbar(), R.id.menu_muted, location)) {
 						intent.putExtra((ThreemaApplication.INTENT_DATA_ANIM_CENTER), location);
 					}
 					activity.startActivity(intent);
@@ -3819,14 +3846,11 @@ public class ComposeMessageFragment extends Fragment implements
 					currentPageReferenceId = 0;
 					onRefresh();
 
-					ListenerManager.conversationListeners.handle(new ListenerManager.HandleListener<ConversationListener>() {
-						@Override
-						public void handle(ConversationListener listener) {
-							if (!isGroupChat) {
-								conversationService.reset();
-							}
-							listener.onModifiedAll();
+					ListenerManager.conversationListeners.handle(listener -> {
+						if (!isGroupChat) {
+							conversationService.reset();
 						}
+						listener.onModifiedAll();
 					});
 				}
 		}).execute();
@@ -3903,7 +3927,7 @@ public class ComposeMessageFragment extends Fragment implements
 
 	public class ComposeMessageAction implements ActionMode.Callback {
 		private final int position;
-		private MenuItem quoteItem, discardItem, forwardItem, saveItem, copyItem, qrItem, shareItem, showText;
+		private MenuItem quoteItem, forwardItem, saveItem, copyItem, qrItem, shareItem, showText;
 
 		ComposeMessageAction(int position) {
 			this.position = position;
@@ -3911,137 +3935,91 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 
 		private void updateActionMenu() {
-			// workaround for support library bug, see https://code.google.com/p/android/issues/detail?id=81192
-			MenuItemCompat.setShowAsAction(quoteItem, MenuItemCompat.SHOW_AS_ACTION_ALWAYS);
-			MenuItemCompat.setShowAsAction(discardItem, MenuItemCompat.SHOW_AS_ACTION_IF_ROOM);
-			MenuItemCompat.setShowAsAction(saveItem, MenuItemCompat.SHOW_AS_ACTION_IF_ROOM);
-			MenuItemCompat.setShowAsAction(copyItem, MenuItemCompat.SHOW_AS_ACTION_ALWAYS);
-			MenuItemCompat.setShowAsAction(forwardItem, MenuItemCompat.SHOW_AS_ACTION_ALWAYS);
-			MenuItemCompat.setShowAsAction(qrItem, MenuItemCompat.SHOW_AS_ACTION_IF_ROOM);
-			MenuItemCompat.setShowAsAction(shareItem, MenuItemCompat.SHOW_AS_ACTION_IF_ROOM);
-
-			quoteItem.setVisible(false);
-			qrItem.setVisible(false);
-			copyItem.setVisible(false);
-			saveItem.setVisible(false);
-			shareItem.setVisible(false);
-			showText.setVisible(false);
-
-			if (selectedMessages.size() > 1) {
-				boolean isForwardable = selectedMessages.size() <= MAX_FORWARDABLE_ITEMS;
-				boolean isMedia = true;
-				boolean isTextOnly = true;
-				boolean isShareable = true;
-
-				for (AbstractMessageModel message: selectedMessages) {
-					if (isForwardable
-							&& (
-									// if the media is not downloaded
-									!message.isAvailable()
-									// or the message is status message (unread or status)
-									|| message.isStatusMessage()
-									// or a ballot
-									|| message.getType() == MessageType.BALLOT
-									// or a voip status
-									|| message.getType() == MessageType.VOIP_STATUS
-									|| message.getType() == MessageType.GROUP_CALL_STATUS
-					)) {
-						isForwardable = false;
-					}
-					if (isMedia && !message.isAvailable() ||
-									(message.getType() != MessageType.IMAGE &&
-									message.getType() != MessageType.VOICEMESSAGE &&
-									message.getType() != MessageType.VIDEO &&
-									message.getType() != MessageType.FILE)) {
-						isMedia = false;
-					}
-					if (isTextOnly && message.getType() != MessageType.TEXT) {
-						isTextOnly = false;
-					}
-					if (isShareable) {
-						if (message.getType() != MessageType.IMAGE && message.getType() != MessageType.VIDEO && message.getType() != MessageType.FILE) {
-							isShareable = false;
-						}
-					}
-				}
-				forwardItem.setVisible(isForwardable);
-				saveItem.setVisible(isMedia);
-				copyItem.setVisible(isTextOnly);
-				shareItem.setVisible(isShareable);
-			} else if (selectedMessages.size() == 1) {
-				AbstractMessageModel selectedMessage = selectedMessages.get(0);
-
-				if (selectedMessage.isStatusMessage()) {
-					forwardItem.setVisible(false);
-					copyItem.setVisible(true);
-				} else {
-					boolean isValidReceiver = messageReceiver.validateSendingPermission(null);
+			boolean isQuotable = selectedMessages.size() == 1;
+			boolean showAsQRCode = selectedMessages.size() == 1;
+			boolean showAsText = selectedMessages.size() == 1;
+			boolean isForwardable = selectedMessages.size() <= MAX_FORWARDABLE_ITEMS;
+			boolean isSaveable = !AppRestrictionUtil.isShareMediaDisabled(getContext());
+			boolean isCopyable = true;
+			boolean isShareable = !AppRestrictionUtil.isShareMediaDisabled(getContext());
+			boolean hasDefaultRendering = false;
+
+			for (AbstractMessageModel message: selectedMessages) {
+				isQuotable = isQuotable && isQuotable(message);
+				showAsQRCode = showAsQRCode && canShowAsQRCode(message);
+				showAsText = showAsText && canShowAsText(message);
+				isForwardable = isForwardable && isForwardable(message);
+				isSaveable = isSaveable && isSaveable(message);
+				isCopyable = isCopyable && isCopyable(message);
+				isShareable = isShareable && isShareable(message);
+				hasDefaultRendering = hasDefaultRendering || isDefaultRendering(message);
+			}
+
+			quoteItem.setVisible(isQuotable);
+			qrItem.setVisible(showAsQRCode);
+			showText.setVisible(showAsText);
+			forwardItem.setVisible(isForwardable);
+			saveItem.setVisible(isSaveable);
+			copyItem.setVisible(isCopyable);
+			shareItem.setVisible(isShareable);
+
+			if (hasDefaultRendering) {
+				saveItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
+				forwardItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
+			} else {
+				saveItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
+				forwardItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
+			}
+		}
 
-					quoteItem.setVisible(isValidReceiver && QuoteUtil.isQuoteable(selectedMessage));
+		private boolean isQuotable(@NonNull AbstractMessageModel message) {
+			boolean isValidReceiver = messageReceiver.validateSendingPermission(null);
+			return isValidReceiver && QuoteUtil.isQuoteable(message);
+		}
 
-					switch (selectedMessage.getType()) {
-						case IMAGE:
-							saveItem.setVisible(true);
-							forwardItem.setVisible(true);
-							shareItem.setVisible(true);
-							if (!TestUtil.empty(selectedMessage.getCaption())) {
-								copyItem.setVisible(true);
-							}
-							break;
-						case VIDEO:
-							saveItem.setVisible(selectedMessage.isAvailable());
-							forwardItem.setVisible(selectedMessage.isAvailable());
-							shareItem.setVisible(selectedMessage.isAvailable());
-							break;
-						case VOICEMESSAGE:
-							saveItem.setVisible(selectedMessage.isAvailable());
-							forwardItem.setVisible(selectedMessage.isAvailable());
-							break;
-						case FILE:
-							if (selectedMessage.getFileData().getRenderingType() == FileData.RENDERING_DEFAULT) {
-								MenuItemCompat.setShowAsAction(saveItem, MenuItemCompat.SHOW_AS_ACTION_ALWAYS);
-								MenuItemCompat.setShowAsAction(forwardItem, MenuItemCompat.SHOW_AS_ACTION_IF_ROOM);
-							}
-							saveItem.setVisible(selectedMessage.isAvailable());
-							shareItem.setVisible(selectedMessage.isAvailable());
-							forwardItem.setVisible(selectedMessage.isAvailable());
-							if (!TestUtil.empty(selectedMessage.getCaption())) {
-								copyItem.setVisible(true);
-							}
-							break;
-						case BALLOT:
-							saveItem.setVisible(false);
-							forwardItem.setVisible(false);
-							break;
-						case TEXT:
-							saveItem.setVisible(false);
-							forwardItem.setVisible(true);
-							copyItem.setVisible(true);
-							qrItem.setVisible(true);
-							shareItem.setVisible(true);
-							showText.setVisible(true);
-							break;
-						case VOIP_STATUS:
-						case GROUP_CALL_STATUS:
-							saveItem.setVisible(false);
-							forwardItem.setVisible(false);
-							copyItem.setVisible(false);
-							qrItem.setVisible(false);
-							shareItem.setVisible(false);
-							break;
-						case LOCATION:
-							shareItem.setVisible(true);
-							break;
-						default:
-							break;
-					}
-				}
-			}
+		private boolean canShowAsQRCode(@NonNull AbstractMessageModel message) {
+			return message.getType() == MessageType.TEXT    // if the message is a text message
+				&& !message.isStatusMessage();              // and it is not a status message
+		}
 
-			if (AppRestrictionUtil.isShareMediaDisabled(getContext())) {
-				shareItem.setVisible(false);
-				saveItem.setVisible(false);
-			}
+		private boolean canShowAsText(@NonNull AbstractMessageModel message) {
+			return message.getType() == MessageType.TEXT    // if the message is a text message
+				&& !message.isStatusMessage();              // and it is not a status message
+		}
+
+		private boolean isForwardable(@NonNull AbstractMessageModel message) {
+			return message.isAvailable() 	                            // if the media is downloaded
+				&& !message.isStatusMessage()                           // and the message is not status message (unread or status)
+				&& message.getType() != MessageType.BALLOT              // and not a ballot
+				&& message.getType() != MessageType.VOIP_STATUS 		// and not a voip status
+				&& message.getType() != MessageType.GROUP_CALL_STATUS; 	// and not a group call status
+		}
+
+		private boolean isSaveable(@NonNull AbstractMessageModel message) {
+			return message.isAvailable()                            // if the message is available
+				&& (message.getType() == MessageType.IMAGE          // and it is an image
+				|| message.getType() == MessageType.VOICEMESSAGE    // or voice message
+				|| message.getType() == MessageType.VIDEO           // or video
+				|| message.getType() == MessageType.FILE);          // or file
+		}
+
+		private boolean isShareable(@NonNull AbstractMessageModel message) {
+			return message.isAvailable()                    // if the message is available
+				&& (message.getType() == MessageType.IMAGE  // and message is an image
+				|| message.getType() == MessageType.VIDEO   // or video
+				|| message.getType() == MessageType.FILE);  // or voice message
+		}
+
+		private boolean isCopyable(@NonNull AbstractMessageModel message) {
+			boolean isText = message.getType() == MessageType.TEXT && !message.isStatusMessage();
+			boolean isFileWithCaption = message.getType() == MessageType.FILE
+				&& !TextUtils.isEmpty(message.getCaption());
+			return isText || isFileWithCaption; // is text (not status) or a file with non-empty caption
+		}
+
+		private boolean isDefaultRendering(@NonNull AbstractMessageModel message) {
+			return message.getType() == MessageType.FILE                                    // if it is a file
+				&& message.getFileData().getRenderingType() == FileData.RENDERING_DEFAULT;  // and default rendering is set
 		}
 
 		@Override
@@ -4061,7 +4039,6 @@ public class ComposeMessageFragment extends Fragment implements
 
 			ConfigUtils.addIconsToOverflowMenu(null, menu);
 
-			discardItem = menu.findItem(R.id.menu_message_discard);
 			forwardItem = menu.findItem(R.id.menu_message_forward);
 			saveItem = menu.findItem(R.id.menu_message_save);
 			copyItem = menu.findItem(R.id.menu_message_copy);
@@ -4687,11 +4664,6 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 	}
 
-	@Override
-	public void onDismissed() {
-		updateMenus();
-	}
-
 	@Override
 	public void onKeyboardHidden() {
 		if (getActivity() != null && isAdded()) {

+ 18 - 17
app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java

@@ -67,6 +67,7 @@ import org.slf4j.Logger;
 import java.io.File;
 import java.lang.ref.WeakReference;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 
 import ch.threema.app.R;
@@ -264,7 +265,17 @@ public class MessageSectionFragment extends MainFragment
 		public void onNew(final ConversationModel conversationModel) {
 			logger.debug("on new conversation");
 			if (messageListAdapter != null && recyclerView != null) {
-				updateList(0, null, null);
+				List<ConversationModel> changedPositions = Collections.singletonList(conversationModel);
+
+				// If the first item of the recycler view is visible, then scroll up
+				Integer scrollToPosition = null;
+				RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
+				if (layoutManager instanceof LinearLayoutManager
+					&& ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition() == 0) {
+					// By passing a large integer we simulate a "moving up" change that triggers scrolling up
+					scrollToPosition = Integer.MAX_VALUE;
+				}
+				updateList(scrollToPosition, changedPositions, null);
 			}
 		}
 
@@ -1579,22 +1590,12 @@ public class MessageSectionFragment extends MainFragment
 							// make sure footer is refreshed
 							messageListAdapter.refreshFooter();
 
-							if (recyclerView != null) {
-								if (scrollToPosition != null) {
-									if (changedPositions != null && changedPositions.size() == 1) {
-										ConversationModel changedModel = changedPositions.get(0);
-
-										if (changedModel != null) {
-											final List<ConversationModel> copyOfModels = new ArrayList<>(conversationModels);
-											for (ConversationModel model : copyOfModels) {
-												if (model.equals(changedModel)) {
-													if (scrollToPosition > changedModel.getPosition()) {
-														recyclerView.scrollToPosition(changedModel.getPosition());
-													}
-													break;
-												}
-											}
-										}
+							if (recyclerView != null && scrollToPosition != null) {
+								if (changedPositions != null && changedPositions.size() == 1) {
+									ConversationModel changedModel = changedPositions.get(0);
+
+									if (changedModel != null && scrollToPosition > changedModel.getPosition() && conversationModels.contains(changedModel)) {
+										recyclerView.scrollToPosition(changedModel.getPosition());
 									}
 								}
 							}

+ 2 - 2
app/src/main/java/ch/threema/app/fragments/mediaviews/VideoViewFragment.java

@@ -236,6 +236,8 @@ public class VideoViewFragment extends AudioFocusSupportingMediaViewFragment imp
 		} else {
 			abandonFocus();
 		}
+
+		keepScreenOn(isPlaying);
 	}
 
 	@Override
@@ -254,8 +256,6 @@ public class VideoViewFragment extends AudioFocusSupportingMediaViewFragment imp
 			this.videoPlayer.seekTo(0);
 			this.videoViewRef.get().showController();
 		}
-
-		keepScreenOn(playbackState != Player.STATE_IDLE);
 	}
 
 	@Override

+ 3 - 1
app/src/main/java/ch/threema/app/managers/ServiceManager.java

@@ -206,6 +206,7 @@ public class ServiceManager {
 	private ThreemaSafeService threemaSafeService;
 	private RingtoneService ringtoneService;
 	private BackupChatService backupChatService;
+	@NonNull
 	private final DatabaseServiceNew databaseServiceNew;
 	private SensorService sensorService;
 	private VoipStateService voipStateService;
@@ -224,7 +225,7 @@ public class ServiceManager {
 	private EmojiService emojiService;
 
 	public ServiceManager(ThreemaConnection connection,
-						  DatabaseServiceNew databaseServiceNew,
+						  @NonNull DatabaseServiceNew databaseServiceNew,
 						  IdentityStore identityStore,
 						  PreferenceStoreInterface preferenceStore,
 						  MasterKey masterKey,
@@ -994,6 +995,7 @@ public class ServiceManager {
 		return this.voipStateService;
 	}
 
+	@NonNull
 	public DatabaseServiceNew getDatabaseServiceNew() {
 		return this.databaseServiceNew;
 	}

+ 7 - 2
app/src/main/java/ch/threema/app/motionviews/widget/FaceEntity.java

@@ -80,8 +80,13 @@ public abstract class FaceEntity extends MotionEntity {
 	}
 
 	@Override
-	public boolean hasFixedPositionAndSize() {
-		return true;
+	public boolean canMove() {
+		return false;
+	}
+
+	@Override
+	public boolean canChangeColor() {
+		return false;
 	}
 
 	@Override

+ 6 - 1
app/src/main/java/ch/threema/app/motionviews/widget/ImageEntity.java

@@ -68,7 +68,12 @@ public class ImageEntity extends MotionEntity {
     }
 
 	@Override
-	public boolean hasFixedPositionAndSize() {
+	public boolean canMove() {
+		return true;
+	}
+
+	@Override
+	public boolean canChangeColor() {
 		return false;
 	}
 

+ 17 - 1
app/src/main/java/ch/threema/app/motionviews/widget/MotionEntity.java

@@ -26,6 +26,8 @@ import android.graphics.Matrix;
 import android.graphics.Paint;
 import android.graphics.Path;
 import android.graphics.PointF;
+
+import androidx.annotation.ColorInt;
 import androidx.annotation.IntRange;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -81,6 +83,9 @@ public abstract class MotionEntity {
 	 */
 	protected final float[] srcPoints = new float[10];  // x0, y0, x1, y1, x2, y2, x3, y3, x0, y0
 
+	@ColorInt
+	protected int color;
+
 	@NonNull
 	private Paint borderPaint = new Paint();
 
@@ -271,7 +276,18 @@ public abstract class MotionEntity {
 
 	protected abstract void drawContent(@NonNull Canvas canvas, @Nullable Paint drawingPaint);
 
-	public abstract boolean hasFixedPositionAndSize();
+	public abstract boolean canMove();
+
+	public abstract boolean canChangeColor();
+
+	@ColorInt
+	public int getColor() {
+		return this.color;
+	}
+
+	public void setColor(@ColorInt int color) {
+		this.color = color;
+	}
 
 	public abstract int getWidth();
 

+ 3 - 3
app/src/main/java/ch/threema/app/motionviews/widget/MotionView.java

@@ -225,7 +225,7 @@ public class MotionView extends FrameLayout {
 	}
 
 	private void handleTranslate(PointF delta) {
-		if (selectedEntity != null && !selectedEntity.hasFixedPositionAndSize()) {
+		if (selectedEntity != null && selectedEntity.canMove()) {
 			float newCenterX = selectedEntity.absoluteCenterX() + delta.x;
 			float newCenterY = selectedEntity.absoluteCenterY() + delta.y;
 			// limit entity center to screen bounds
@@ -416,7 +416,7 @@ public class MotionView extends FrameLayout {
 	private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
 		@Override
 		public boolean onScale(ScaleGestureDetector detector) {
-			if (selectedEntity != null && !selectedEntity.hasFixedPositionAndSize()) {
+			if (selectedEntity != null && selectedEntity.canMove()) {
 				float scaleFactorDiff = detector.getScaleFactor();
 				selectedEntity.getLayer().postScale(scaleFactorDiff - 1.0F);
 				updateUI();
@@ -428,7 +428,7 @@ public class MotionView extends FrameLayout {
 	private class RotateListener extends RotateGestureDetector.SimpleOnRotateGestureListener {
 		@Override
 		public boolean onRotate(RotateGestureDetector detector) {
-			if (selectedEntity != null && !selectedEntity.hasFixedPositionAndSize()) {
+			if (selectedEntity != null && selectedEntity.canMove()) {
 				selectedEntity.getLayer().postRotate(-detector.getRotationDegreesDelta());
 				updateUI();
 			}

+ 6 - 1
app/src/main/java/ch/threema/app/motionviews/widget/PathEntity.java

@@ -37,7 +37,12 @@ public class PathEntity extends MotionEntity {
 	protected void drawContent(@NonNull Canvas canvas, @Nullable Paint drawingPaint) {}
 
 	@Override
-	public boolean hasFixedPositionAndSize() {
+	public boolean canMove() {
+		return false;
+	}
+
+	@Override
+	public boolean canChangeColor() {
 		return false;
 	}
 

+ 15 - 2
app/src/main/java/ch/threema/app/motionviews/widget/TextEntity.java

@@ -177,8 +177,21 @@ public class TextEntity extends MotionEntity {
 	}
 
 	@Override
-	public boolean hasFixedPositionAndSize() {
-		return false;
+	public boolean canMove() {
+		return true;
+	}
+
+	@Override
+	public boolean canChangeColor() {
+		return true;
+	}
+
+	@Override
+	public void setColor(int color) {
+		super.setColor(color);
+
+		getLayer().getFont().setColor(color);
+		updateEntity();
 	}
 
 	@Override

+ 33 - 39
app/src/main/java/ch/threema/app/preference/SettingsAppearanceFragment.kt

@@ -24,8 +24,6 @@ package ch.threema.app.preference
 import androidx.preference.CheckBoxPreference
 import androidx.preference.DropDownPreference
 import androidx.preference.Preference
-import androidx.preference.PreferenceCategory
-import ch.threema.app.BuildFlavor
 import ch.threema.app.R
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.dialogs.GenericAlertDialog
@@ -147,45 +145,41 @@ class SettingsAppearanceFragment : ThreemaPreferenceFragment() {
 
     private fun initEmojiStylePref() {
         val emojiPreference = getPref<DropDownPreference>(R.string.preferences__emoji_style)
-        if (BuildFlavor.isLibre()) {
-            val aboutCategory = getPref<PreferenceCategory>("pref_key_appearance_cat")
-            aboutCategory.removePreference(emojiPreference)
-        } else {
-            var emojiIndex: Int = preferenceManager.sharedPreferences?.getString(resources.getString(R.string.preferences__emoji_style), "0")?.toInt() ?: 0
-            val emojiArray = resources.getStringArray(R.array.list_emoji_style)
-            if (emojiIndex >= emojiArray.size) {
-                emojiIndex = 0
-            }
-            val oldEmojiStyle = emojiIndex
-            emojiPreference.summary = emojiArray[emojiIndex]
-            emojiPreference.setOnPreferenceChangeListener { _, newValue ->
-                val newEmojiStyle = newValue.toString().toInt()
-                if (newEmojiStyle != oldEmojiStyle) {
-                    if (newEmojiStyle == PreferenceService.EmojiStyle_ANDROID) {
-                        val dialog = GenericAlertDialog.newInstance(R.string.prefs_android_emojis,
-                                R.string.android_emojis_warning,
-                                R.string.ok,
-                                R.string.cancel)
-                        dialog.setData(newEmojiStyle)
-                        dialog.setCallback(object : GenericAlertDialog.DialogClickListener {
-                            override fun onYes(tag: String?, data: Any?) {
-                                ConfigUtils.setEmojiStyle(activity, data as Int)
-                                updateEmojiPrefs(data)
-                                ConfigUtils.recreateActivity(activity)
-                            }
-                            override fun onNo(tag: String?, data: Any?) {
-                                updateEmojiPrefs(PreferenceService.EmojiStyle_DEFAULT)
-                            }
-                        })
-                        dialog.show(parentFragmentManager, "android_emojis")
-                    } else {
-                        ConfigUtils.setEmojiStyle(activity, newEmojiStyle)
-                        updateEmojiPrefs(newEmojiStyle)
-                        ConfigUtils.recreateActivity(activity)
-                    }
+
+        var emojiIndex: Int = preferenceManager.sharedPreferences?.getString(resources.getString(R.string.preferences__emoji_style), "0")?.toInt() ?: 0
+        val emojiArray = resources.getStringArray(R.array.list_emoji_style)
+        if (emojiIndex >= emojiArray.size) {
+            emojiIndex = 0
+        }
+        val oldEmojiStyle = emojiIndex
+        emojiPreference.summary = emojiArray[emojiIndex]
+        emojiPreference.setOnPreferenceChangeListener { _, newValue ->
+            val newEmojiStyle = newValue.toString().toInt()
+            if (newEmojiStyle != oldEmojiStyle) {
+                if (newEmojiStyle == PreferenceService.EmojiStyle_ANDROID) {
+                    val dialog = GenericAlertDialog.newInstance(R.string.prefs_android_emojis,
+                        R.string.android_emojis_warning,
+                        R.string.ok,
+                        R.string.cancel)
+                    dialog.setData(newEmojiStyle)
+                    dialog.setCallback(object : GenericAlertDialog.DialogClickListener {
+                        override fun onYes(tag: String?, data: Any?) {
+                            ConfigUtils.setEmojiStyle(activity, data as Int)
+                            updateEmojiPrefs(data)
+                            ConfigUtils.recreateActivity(activity)
+                        }
+                        override fun onNo(tag: String?, data: Any?) {
+                            updateEmojiPrefs(PreferenceService.EmojiStyle_DEFAULT)
+                        }
+                    })
+                    dialog.show(parentFragmentManager, "android_emojis")
+                } else {
+                    ConfigUtils.setEmojiStyle(activity, newEmojiStyle)
+                    updateEmojiPrefs(newEmojiStyle)
+                    ConfigUtils.recreateActivity(activity)
                 }
-                true
             }
+            true
         }
     }
 

+ 2 - 2
app/src/main/java/ch/threema/app/processors/MessageProcessor.java

@@ -503,13 +503,13 @@ public class MessageProcessor implements MessageProcessorInterface {
 
 	@Override
 	public void processServerAlert(String s) {
-		ServerMessageModel msg = new ServerMessageModel(s, ServerMessageModel.Type.ALERT);
+		ServerMessageModel msg = new ServerMessageModel(s, ServerMessageModel.TYPE_ALERT);
 		this.messageService.saveIncomingServerMessage(msg);
 	}
 
 	@Override
 	public void processServerError(String s, boolean b) {
-		ServerMessageModel msg = new ServerMessageModel(s, ServerMessageModel.Type.ERROR);
+		ServerMessageModel msg = new ServerMessageModel(s, ServerMessageModel.TYPE_ERROR);
 		this.messageService.saveIncomingServerMessage(msg);
 	}
 

+ 15 - 6
app/src/main/java/ch/threema/app/services/GroupService.java

@@ -112,12 +112,12 @@ public interface GroupService extends AvatarService<GroupModel> {
 	 * Update group properties and members.
 	 * This method triggers protocol messages to all group members that are affected by the change.
 	 *
-	 * @param groupModel Group that should be modified
-	 * @param name New name of group, {@code null} if unchanged.
-	 * @param groupDesc New group description for the group, {@code null} if unchanged.
+	 * @param groupModel            Group that should be modified
+	 * @param name                  New name of group, {@code null} if unchanged.
+	 * @param groupDesc             New group description for the group, {@code null} if unchanged.
 	 * @param groupMemberIdentities Identities of all group members.
-	 * @param photo New group photo, {@code null} if unchanged.
-	 * @param removePhoto Whether to remove the group photo.
+	 * @param photo                 New group photo, {@code null} if unchanged.
+	 * @param removePhoto           Whether to remove the group photo.
 	 * @return Updated groupModel
 	 */
 	@NonNull
@@ -132,7 +132,16 @@ public interface GroupService extends AvatarService<GroupModel> {
 
 	boolean renameGroup(GroupRenameMessage renameMessage) throws ThreemaException;
 
-	boolean renameGroup(GroupModel group, String newName) throws ThreemaException;
+	/**
+	 * Rename the group. This renames the group regardless whether the new name is the same or not.
+	 * If the user is the owner of the group, a rename message is sent to every member of the group.
+	 * Note that this method does not trigger the group listeners.
+	 *
+	 * @param group   the group model that may be updated
+	 * @param newName the new name of the group
+	 * @throws ThreemaException if sending the group rename message fails
+	 */
+	void renameGroup(GroupModel group, String newName) throws ThreemaException;
 
 	/**
 	 * @return return true if a new member added, false a existing group member updated or null if a error occurred

+ 32 - 19
app/src/main/java/ch/threema/app/services/GroupServiceImpl.java

@@ -819,7 +819,7 @@ public class GroupServiceImpl implements GroupService {
 			return groupCreateMessage;
 		});
 
-		if(groupModel.getName() != null && groupModel.getName().length() > 0) {
+		if (!TextUtils.isEmpty(groupModel.getName())) {
 			this.renameGroup(groupModel, groupModel.getName());
 		}
 
@@ -1111,7 +1111,14 @@ public class GroupServiceImpl implements GroupService {
 		}
 
 		if (name != null) {
+			// Rename the group (for all members) if the group name has changed. This includes the
+			// new members of the group.
 			this.renameGroup(groupModel, name);
+			ListenerManager.groupListeners.handle(listener -> listener.onRename(groupModel));
+		} else if (!newMembers.isEmpty()) {
+			// Send rename message to all new group members, so that they receive the group name,
+			// even if the group name did not change.
+			this.sendGroupRenameToIdentitiesIfOwner(groupModel, newMembers.toArray(new String[0]));
 		}
 
 		if (groupDesc != null) {
@@ -1169,10 +1176,12 @@ public class GroupServiceImpl implements GroupService {
 	public boolean renameGroup(GroupRenameMessage renameMessage) throws ThreemaException {
 		final GroupModel groupModel = this.getGroup(renameMessage);
 
-		if(groupModel != null) {
+		if (groupModel != null) {
+			final String oldGroupName = groupModel.getName() != null ? groupModel.getName() : "";
+			final String newGroupName = renameMessage.getGroupName() != null ? renameMessage.getGroupName() : "";
 			//only rename, if the name is different
-			if(!TestUtil.compare(groupModel.getName(), renameMessage.getGroupName())) {
-				this.renameGroup(groupModel, renameMessage.getGroupName());
+			if (!oldGroupName.equals(newGroupName)) {
+				this.renameGroup(groupModel, newGroupName);
 				ListenerManager.groupListeners.handle(listener -> listener.onRename(groupModel));
 			}
 			return true;
@@ -1182,26 +1191,29 @@ public class GroupServiceImpl implements GroupService {
 	}
 
 	@Override
-	public boolean renameGroup(final GroupModel group, final String newName) throws ThreemaException {
-		boolean localeRenamed = !TestUtil.compare(group.getName(), newName);
+	public void renameGroup(final GroupModel group, final String newName) throws ThreemaException {
+		// Update and save the group model
 		group.setName(newName);
 		this.save(group);
 
-		if(this.isGroupOwner(group)) {
-			//send rename event!
-			this.groupMessagingService.sendMessage(group, this.getGroupIdentities(group), messageId -> {
-				final GroupRenameMessage rename = new GroupRenameMessage();
-				rename.setMessageId(messageId);
-				rename.setGroupName(newName);
-				return rename;
-			});
+		// Send rename message to group members if group owner
+		sendGroupRenameToIdentitiesIfOwner(group, getGroupIdentities(group));
+	}
 
-			if(localeRenamed) {
-				ListenerManager.groupListeners.handle(listener -> listener.onRename(group));
-			}
+	private void sendGroupRenameToIdentitiesIfOwner(@NonNull GroupModel groupModel, @NonNull String[] identities) throws ThreemaException {
+		if (!this.isGroupOwner(groupModel)) {
+			// Don't send the group rename if the user is not the owner
+			return;
 		}
 
-		return false;
+		final String groupName = groupModel.getName() != null ? groupModel.getName() : "";
+
+		this.groupMessagingService.sendMessage(groupModel, identities, messageId -> {
+			final GroupRenameMessage rename = new GroupRenameMessage();
+			rename.setMessageId(messageId);
+			rename.setGroupName(groupName);
+			return rename;
+		});
 	}
 
 	// on Update new group desc
@@ -1531,9 +1543,10 @@ public class GroupServiceImpl implements GroupService {
 			});
 
 			this.groupMessagingService.sendMessage(groupModel, memberIdentities, messageId -> {
+				final String groupName = groupModel.getName() != null ? groupModel.getName() : "";
 				final GroupRenameMessage groupRenameMessage = new GroupRenameMessage();
 				groupRenameMessage.setMessageId(messageId);
-				groupRenameMessage.setGroupName(groupModel.getName());
+				groupRenameMessage.setGroupName(groupName);
 				return groupRenameMessage;
 			});
 

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

@@ -2981,11 +2981,13 @@ public class MessageServiceImpl implements MessageService {
 		}
 	}
 
+	@Override
 	public void saveIncomingServerMessage(final ServerMessageModel msg) {
-		//do not save the server message model for this moment!
-		//show as alert
+		// Store server message into database
+		databaseServiceNew.getServerMessageModelFactory().storeServerMessageModel(msg);
+		// Show as alert
 		ListenerManager.serverMessageListeners.handle(listener -> {
-			if (msg.getType() == ServerMessageModel.Type.ALERT) {
+			if (msg.getType() == ServerMessageModel.TYPE_ALERT) {
 				listener.onAlert(msg);
 			} else {
 				listener.onError(msg);

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

@@ -1703,7 +1703,6 @@ public class NotificationServiceImpl implements NotificationService {
 	@Override
 	public void showServerMessage(ServerMessageModel m) {
 		Intent intent = new Intent(context, ServerMessageActivity.class);
-		IntentDataUtil.append(m, intent);
 		PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PENDING_INTENT_FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
 
 		NotificationCompat.Builder builder =

+ 19 - 0
app/src/main/java/ch/threema/app/services/PreferenceService.java

@@ -449,6 +449,24 @@ public interface PreferenceService {
 	void setThreemaSafeErrorCode(int code);
 	int getThreemaSafeErrorCode();
 
+	/**
+	 * Set the earliest date where the threema safe backup failed. Only set this if there are
+	 * changes for the backup available. Don't update the date when there is already a date set as
+	 * this is the first occurrence of a failed backup. Override this date with null, when a safe
+	 * backup has been created successfully.
+	 *
+	 * @param date the date when the safe backup first failed
+	 */
+	void setThreemaSafeErrorDate(@Nullable Date date);
+
+	/**
+	 * Get the first date where the safe backup failed. If this is null, then the last safe backup
+	 * was successful.
+	 * @return the date of the first failed safe backup
+	 */
+	@Nullable
+	Date getThreemaSafeErrorDate();
+
 	void setThreemaSafeServerMaxUploadSize(long maxBackupBytes);
 	long getThreemaSafeServerMaxUploadSize();
 
@@ -539,4 +557,5 @@ public interface PreferenceService {
 	void incrementMultipleRecipientsTooltipCount();
 
 	boolean isGroupCallSendInitEnabled();
+	boolean skipGroupCallCreateDelay();
 }

+ 27 - 13
app/src/main/java/ch/threema/app/services/PreferenceServiceImpl.java

@@ -46,7 +46,6 @@ import java.util.Map;
 import java.util.Set;
 
 import ch.threema.app.BuildConfig;
-import ch.threema.app.BuildFlavor;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.notifications.NotificationUtil;
@@ -318,7 +317,7 @@ public class PreferenceServiceImpl implements PreferenceService {
 	@Override
 	@Deprecated
 	public LinkedList<Integer> getRecentEmojis() {
-		LinkedList<Integer> list = new LinkedList<Integer>();
+		LinkedList<Integer> list = new LinkedList<>();
 		JSONArray array = this.preferenceStore.getJSONArray(this.getKeyName(R.string.preferences__recent_emojis), false);
 		for (int i = 0; i < array.length(); i++) {
 			try {
@@ -337,7 +336,7 @@ public class PreferenceServiceImpl implements PreferenceService {
 		if (theArray != null) {
 			return new LinkedList<>(Arrays.asList(theArray));
 		} else {
-			return new LinkedList<>(new LinkedList<String>());
+			return new LinkedList<>(new LinkedList<>());
 		}
 	}
 
@@ -435,7 +434,7 @@ public class PreferenceServiceImpl implements PreferenceService {
 				return time;
 			}
 		} catch (NumberFormatException x) {
-
+			// ignored
 		}
 		return -1;
 	}
@@ -605,13 +604,14 @@ public class PreferenceServiceImpl implements PreferenceService {
 		);
 	}
 
+	@Override
 	public void clear() {
 		this.preferenceStore.clear();
 	}
 
 	@Override
 	public List<String[]> write() {
-		List<String[]> res = new ArrayList<String[]>();
+		List<String[]> res = new ArrayList<>();
 		Map<String, ?> values = this.preferenceStore.getAllNonCrypted();
 		Iterator<String> i = values.keySet().iterator();
 		while (i.hasNext()) {
@@ -741,10 +741,6 @@ public class PreferenceServiceImpl implements PreferenceService {
 
 	@Override
 	public int getEmojiStyle() {
-		if (BuildFlavor.isLibre()) {
-			return EmojiStyle_ANDROID;
-		}
-
 		String theme = this.preferenceStore.getString(this.getKeyName(R.string.preferences__emoji_style));
 		if (theme != null && theme.length() > 0) {
 			if (Integer.valueOf(theme) == 1) {
@@ -957,6 +953,7 @@ public class PreferenceServiceImpl implements PreferenceService {
 		this.preferenceStore.saveStringHashMap(this.getKeyName(R.string.preferences__diverse_emojis), diverseEmojis, false);
 	}
 
+	@Override
 	public boolean isWebClientEnabled() {
 		return this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__web_client_enabled));
 	}
@@ -1049,8 +1046,9 @@ public class PreferenceServiceImpl implements PreferenceService {
 		return this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__receive_profilepics));
 	}
 
-	public @NonNull
-	String getAECMode() {
+	@Override
+	@NonNull
+	public String getAECMode() {
 		String mode = this.preferenceStore.getString(this.getKeyName(R.string.preferences__voip_echocancel));
 		if ("sw".equals(mode)) {
 			return mode;
@@ -1278,6 +1276,17 @@ public class PreferenceServiceImpl implements PreferenceService {
 		return this.preferenceStore.getInt(this.getKeyName(R.string.preferences__threema_safe_error_code));
 	}
 
+	@Override
+	public void setThreemaSafeErrorDate(@Nullable Date date) {
+		this.preferenceStore.save(this.getKeyName(R.string.preferences__threema_safe_create_error_date), date);
+	}
+
+	@Override
+	@Nullable
+	public Date getThreemaSafeErrorDate() {
+		return this.preferenceStore.getDate(this.getKeyName(R.string.preferences__threema_safe_create_error_date));
+	}
+
 	@Override
 	public void setThreemaSafeServerMaxUploadSize(long maxBackupBytes) {
 		this.preferenceStore.save(this.getKeyName(R.string.preferences__threema_safe_server_upload_size), maxBackupBytes);
@@ -1353,6 +1362,7 @@ public class PreferenceServiceImpl implements PreferenceService {
 		return this.preferenceStore.getString(this.getKeyName(R.string.preferences__work_safe_mdm_config), true);
 	}
 
+	@Override
 	public void setWorkDirectoryEnabled(boolean enabled) {
 		this.preferenceStore.save(this.getKeyName(R.string.preferences__work_directory_enabled), enabled);
 	}
@@ -1608,7 +1618,11 @@ public class PreferenceServiceImpl implements PreferenceService {
 
 	@Override
 	public boolean isGroupCallSendInitEnabled() {
-		return BuildConfig.DEBUG
-			&& this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__group_call_send_init), false);
+		return ConfigUtils.isTestBuild() && this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__group_call_send_init), false);
+	}
+
+	@Override
+	public boolean skipGroupCallCreateDelay() {
+		return ConfigUtils.isTestBuild() && this.preferenceStore.getBoolean(this.getKeyName(R.string.preferences__group_call_skip_delay), false);
 	}
 }

+ 13 - 3
app/src/main/java/ch/threema/app/services/messageplayer/AudioMessagePlayer.java

@@ -203,7 +203,7 @@ public class AudioMessagePlayer extends MessagePlayer implements AudioManager.On
 	/**
 	 * called, after the media player was prepared
 	 */
-	private void prepared(MediaPlayerStateWrapper mp, boolean resume) {
+	private void prepared(MediaPlayerStateWrapper mp, final boolean resume) {
 		logger.debug("prepared");
 
 		//do not play if state is changed! (not playing)
@@ -230,12 +230,19 @@ public class AudioMessagePlayer extends MessagePlayer implements AudioManager.On
 		}
 		logger.debug("duration = {}", duration);
 
-		if (this.position != 0) {
+		if (this.position >= 0) {
+			this.mediaPlayer.setOnSeekCompleteListener(mp1 -> onSeekCompleted(resume));
 			this.mediaPlayer.seekTo(this.position);
+		} else {
+			onSeekCompleted(resume);
 		}
+	}
 
+	private void onSeekCompleted(final boolean resume) {
 		logger.debug("play from position {}", this.position);
 
+		this.mediaPlayer.setOnSeekCompleteListener(null);
+
 		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
 			float audioPlaybackSpeed = preferenceService.getAudioPlaybackSpeed();
 			float newPlaybackSpeed = mediaPlayer.setPlaybackSpeed(audioPlaybackSpeed);
@@ -277,7 +284,10 @@ public class AudioMessagePlayer extends MessagePlayer implements AudioManager.On
 						Thread.sleep(SEEKBAR_UPDATE_FREQUENCY);
 
 						if (mediaPlayer != null && getState() == State_PLAYING && isPlaying()) {
-							position = mediaPlayer.getCurrentPosition();
+							int newposition = mediaPlayer.getCurrentPosition();
+							if (newposition > position) {
+								position = newposition;
+							}
 							AudioMessagePlayer.this.updatePlayState();
 							cont = !Thread.interrupted();
 						}

+ 45 - 0
app/src/main/java/ch/threema/app/services/systemupdate/SystemUpdateToVersion81.kt

@@ -0,0 +1,45 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2022-2023 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.services.systemupdate
+
+import ch.threema.app.services.UpdateSystemService
+import net.sqlcipher.database.SQLiteDatabase
+
+internal class SystemUpdateToVersion81(
+    private val sqLiteDatabase: SQLiteDatabase
+) : UpdateToVersion(), UpdateSystemService.SystemUpdate {
+    companion object {
+        const val VERSION = 81
+    }
+
+    override fun runASync() = true
+
+    override fun runDirectly(): Boolean {
+        sqLiteDatabase.execSQL(
+            "CREATE TABLE `server_messages` (" +
+            "`message` VARCHAR PRIMARY KEY ON CONFLICT REPLACE, " +
+            "`type` INTEGER)")
+        return true
+    }
+
+    override fun getText() = "version $VERSION (store system messages)"
+}

+ 30 - 2
app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeService.java

@@ -45,6 +45,28 @@ public interface ThreemaSafeService {
 
 	int BACKUP_ID_LENGTH = 32;
 
+	/**
+	 * This exception is thrown if creating and uploading the Threema Safe backup fails.
+	 */
+	class ThreemaSafeUploadException extends ThreemaException {
+		private final boolean uploadNeeded;
+
+		public ThreemaSafeUploadException(String msg, boolean uploadNeeded) {
+			super(msg);
+			this.uploadNeeded = uploadNeeded;
+		}
+
+		/**
+		 * Check whether the backup potentially should have been uploaded to the server.
+		 *
+		 * @return {@code false} if the backup should not be uploaded, {@code true} if it
+		 * potentially should be uploaded.
+		 */
+		public boolean isUploadNeeded() {
+			return uploadNeeded;
+		}
+	}
+
 	@Nullable
 	byte[] deriveMasterKey(String password, String identity);
 
@@ -68,13 +90,19 @@ public interface ThreemaSafeService {
 
 	void uploadNow(Context context, boolean force);
 
-	void createBackup(boolean force) throws ThreemaException;
+	void createBackup(boolean force) throws ThreemaSafeUploadException;
 
 	void deleteBackup() throws ThreemaException;
 
 	void restoreBackup(String identity, String password, ThreemaSafeServerInfo serverInfo) throws ThreemaException, IOException;
 
-	@Nullable ArrayList<String> searchID(String phone, String email);
+	/**
+	 * Search a Threema ID by phone number and/or email address.
+	 *
+	 * @return ArrayList of matching Threema IDs, null if none was found
+	 */
+	@Nullable
+	ArrayList<String> searchID(String phone, String email);
 
 	/**
 	 * Launch the password dialog to setup Threema Safe.

+ 25 - 40
app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeServiceImpl.java

@@ -472,20 +472,16 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 	}
 
 	@Override
-	public void createBackup(boolean force) throws ThreemaException {
+	public void createBackup(boolean force) throws ThreemaSafeUploadException {
 		logger.info("Starting Threema Safe backup");
 
 		if (!preferenceService.getThreemaSafeEnabled()) {
-			throw new ThreemaException("Disabled");
-		}
-
-		if (getThreemaSafeEncryptionKey() == null) {
-			throw new ThreemaException("No key");
+			throw new ThreemaSafeUploadException("Disabled", false);
 		}
 
 		ThreemaSafeServerInfo serverInfo = preferenceService.getThreemaSafeServerInfo();
 		if (serverInfo == null) {
-			throw new ThreemaException("No server info");
+			throw new ThreemaSafeUploadException("No server info", true);
 		}
 
 		// test server to update configuration
@@ -494,13 +490,13 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 			serverTestResponse = testServer(serverInfo);
 		} catch (ThreemaException e) {
 			preferenceService.setThreemaSafeErrorCode(ERROR_CODE_SERVER_FAIL);
-			throw new ThreemaException("Server test failed. " + e.getMessage());
+			throw new ThreemaSafeUploadException("Server test failed. " + e.getMessage(), true);
 		}
 
 		String json = getJson();
 		if (json == null) {
 			preferenceService.setThreemaSafeErrorCode(ERROR_CODE_JSON_FAIL);
-			throw new ThreemaException("Json failed");
+			throw new ThreemaSafeUploadException("Json failed", true);
 		}
 
 		// get a hash of the json to determine if there are any changes
@@ -511,7 +507,7 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 			hashString = StringConversionUtil.byteArrayToString(messageDigest.digest());
 		} catch (NoSuchAlgorithmException e) {
 			preferenceService.setThreemaSafeErrorCode(ERROR_CODE_HASH_FAIL);
-			throw new ThreemaException("Hash calculation failed");
+			throw new ThreemaSafeUploadException("Hash calculation failed", true);
 		}
 
 		if (!force) {
@@ -528,8 +524,7 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 				if (preferenceService.getThreemaSafeErrorCode() == ERROR_CODE_OK &&
 					preferenceService.getThreemaSafeUploadDate() != null &&
 					reUploadThreshold.before(preferenceService.getThreemaSafeUploadDate())) {
-					logger.info("Grace time not yet reached. NOT uploaded");
-					return;
+					throw new ThreemaSafeUploadException("Grace time not yet reached. NOT uploaded", false);
 				}
 			}
 		}
@@ -537,7 +532,11 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 		byte[] gzippedPlaintext = gZipCompress(json.getBytes());
 		if (gzippedPlaintext == null || gzippedPlaintext.length <= 0) {
 			preferenceService.setThreemaSafeErrorCode(ERROR_CODE_GZIP_FAIL);
-			throw new ThreemaException("Compression failed");
+			throw new ThreemaSafeUploadException("Compression failed", true);
+		}
+
+		if (getThreemaSafeEncryptionKey() == null) {
+			throw new ThreemaSafeUploadException("No key", true);
 		}
 
 		SecureRandom random = new SecureRandom();
@@ -566,20 +565,15 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 		} catch (UploadSizeExceedException e) {
 			logger.error("Exception", e);
 			preferenceService.setThreemaSafeErrorCode(ERROR_CODE_SIZE_EXCEEDED);
-			throw new ThreemaException(e.getMessage());
+			throw new ThreemaSafeUploadException(e.getMessage(), true);
 		} catch (Exception e) {
 			logger.error("Exception", e);
 			preferenceService.setThreemaSafeErrorCode(ERROR_CODE_UPLOAD_FAIL);
-			throw new ThreemaException("Upload failed");
+			throw new ThreemaSafeUploadException("Upload failed", true);
 		}
 
 		if (force) {
-			RuntimeUtil.runOnUiThread(new Runnable() {
-				@Override
-				public void run() {
-					Toast.makeText(context, R.string.threema_safe_upload_successful, Toast.LENGTH_LONG).show();
-				}
-			});
+			RuntimeUtil.runOnUiThread(() -> Toast.makeText(context, R.string.threema_safe_upload_successful, Toast.LENGTH_LONG).show());
 		}
 
 		logger.info(context.getString(R.string.threema_safe_upload_successful));
@@ -1244,22 +1238,15 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 		}
 	}
 
-	/**
-	 * Search a Threema ID by phone number and/or email address.
-	 * @param phone
-	 * @param email
-	 * @return ArrayList of matching Threema IDs, null if none was found
-	 */
 	@Override
 	@Nullable
 	public ArrayList<String> searchID(String phone, String email) {
 		if (phone != null  || email != null) {
-			Map<String, Object> phoneMap = new HashMap<String, Object>() {{
-				put(phone, null);
-			}};
-			Map<String, Object> emailMap = new HashMap<String, Object>() {{
-				put(email, null);
-			}};
+			Map<String, Object> phoneMap = new HashMap<>();
+			phoneMap.put(phone, null);
+
+			Map<String, Object> emailMap = new HashMap<>();
+			emailMap.put(email, null);
 
 			try {
 				Map<String, APIConnector.MatchIdentityResult> results = apiConnector.matchIdentities(emailMap, phoneMap, localeService.getCountryIsoCode(), true, identityStore, null);
@@ -1422,7 +1409,7 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 			contact.put(TAG_SAFE_CONTACT_PUBLIC_KEY, Base64.encodeBytes(contactModel.getPublicKey()));
 		}
 		if (contactModel.getDateCreated() != null) {
-			contact.put(TAG_SAFE_CONTACT_CREATED_AT, contactModel.getDateCreated().getTime());
+			contact.put(TAG_SAFE_CONTACT_CREATED_AT, Utils.getUnsignedTimestamp(contactModel.getDateCreated()));
 		} else {
 			contact.put(TAG_SAFE_CONTACT_CREATED_AT, 0);
 		}
@@ -1470,7 +1457,7 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 			group.put(TAG_SAFE_GROUP_NAME, groupModel.getName());
 		}
 		if (groupModel.getCreatedAt() != null) {
-			group.put(TAG_SAFE_GROUP_CREATED_AT, groupModel.getCreatedAt().getTime());
+			group.put(TAG_SAFE_GROUP_CREATED_AT, Utils.getUnsignedTimestamp(groupModel.getCreatedAt()));
 		} else {
 			group.put(TAG_SAFE_GROUP_CREATED_AT, 0);
 		}
@@ -1642,7 +1629,7 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 		distributionlist.put(TAG_SAFE_DISTRIBUTIONLIST_ID, Utils.byteArrayToHexString(Utils.longToByteArray(distributionListModel.getId())));
 		distributionlist.put(TAG_SAFE_DISTRIBUTIONLIST_NAME, distributionListModel.getName());
 		if (distributionListModel.getCreatedAt() != null) {
-			distributionlist.put(TAG_SAFE_DISTRIBUTIONLIST_CREATED_AT, distributionListModel.getCreatedAt().getTime());
+			distributionlist.put(TAG_SAFE_DISTRIBUTIONLIST_CREATED_AT, Utils.getUnsignedTimestamp(distributionListModel.getCreatedAt()));
 		} else {
 			distributionlist.put(TAG_SAFE_DISTRIBUTIONLIST_CREATED_AT, 0);
 		}
@@ -1689,7 +1676,7 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 		JSONObject info = new JSONObject();
 
 		info.put(TAG_SAFE_INFO_VERSION, PROTOCOL_VERSION);
-		info.put(TAG_SAFE_INFO_DEVICE, ConfigUtils.getAppVersion(context) + "A/" + Locale.getDefault().toString());
+		info.put(TAG_SAFE_INFO_DEVICE, ConfigUtils.getAppVersion(context) + "A/" + Locale.getDefault());
 
 		return info;
 	}
@@ -1879,11 +1866,9 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 		return null;
 	}
 
-	public class UploadSizeExceedException extends Exception {
+	public static class UploadSizeExceedException extends Exception {
 		UploadSizeExceedException(String e) {
 			super(e);
 		}
 	}
-
-
 }

+ 105 - 32
app/src/main/java/ch/threema/app/ui/OngoingCallNoticeView.kt

@@ -22,6 +22,7 @@
 package ch.threema.app.ui
 
 import android.content.Context
+import android.content.Intent
 import android.content.res.ColorStateList
 import android.os.Build
 import android.util.AttributeSet
@@ -31,26 +32,35 @@ import android.widget.Chronometer
 import android.widget.LinearLayout
 import android.widget.RelativeLayout
 import android.widget.TextView
+import androidx.annotation.AnyThread
 import androidx.annotation.UiThread
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.content.res.AppCompatResources
 import androidx.lifecycle.DefaultLifecycleObserver
 import ch.threema.app.R
 import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.voip.activities.CallActivity
+import ch.threema.app.voip.activities.GroupCallActivity
+import ch.threema.app.voip.groupcall.GroupCallDescription
+import ch.threema.app.voip.groupcall.LocalGroupId
+import ch.threema.app.voip.services.VoipCallService
 import com.google.android.material.chip.Chip
 
-object OngoingCallNoticeModes {
-	const val MODE_VOIP = 0
-	const val MODE_GROUP_CALL_RUNNING = 1
-	const val MODE_GROUP_CALL_JOINED = 2
+enum class OngoingCallNoticeMode {
+	MODE_VOIP,
+	MODE_GROUP_CALL_RUNNING,
+	MODE_GROUP_CALL_JOINED
 }
 
 class OngoingCallNoticeView : LinearLayout, DefaultLifecycleObserver {
+	private var operationMode: OngoingCallNoticeMode? = null
+	private var groupId: LocalGroupId? = null
 	private lateinit var actionButton: Chip
 	private lateinit var callContainer: RelativeLayout
 	private lateinit var chronometer: Chronometer
 	private lateinit var callText: TextView
 	private lateinit var participantsText: TextView
+	private lateinit var callDurationDivider: TextView
 
 	constructor(context: Context) : super(context) {
 		init()
@@ -64,14 +74,46 @@ class OngoingCallNoticeView : LinearLayout, DefaultLifecycleObserver {
 		init()
 	}
 
-	@UiThread
+	@AnyThread
+	fun showVoip() {
+		post {
+			setupVoipActions()
+			show(VoipCallService.getStartTime(), OngoingCallNoticeMode.MODE_VOIP)
+		}
+	}
+
+	/**
+	 * Hide the notice only if the current [OngoingCallNoticeMode] is [OngoingCallNoticeMode.MODE_VOIP].
+	 */
+	@AnyThread
+	fun hideVoip() {
+		if (operationMode == OngoingCallNoticeMode.MODE_VOIP) {
+			hide()
+		}
+	}
+
+	@AnyThread
+	fun showGroupCall(call: GroupCallDescription, mode: OngoingCallNoticeMode) {
+		post {
+			val participantsCount = call.callState?.participants?.size ?: 0
+			setupGroupCallActions(call)
+			show(call.getRunningSince(), mode, participantsCount)
+		}
+	}
+
+	/**
+	 * Hides the notice no matter what the current [OngoingCallNoticeMode] is.
+	 */
+	@AnyThread
 	fun hide() {
-		chronometer.stop()
-		visibility = View.GONE
+		post {
+			chronometer.stop()
+			visibility = View.GONE
+		}
 	}
 
 	@UiThread
-	fun show(startTime: Long?, mode: Int, participantCount: Int) {
+	private fun show(startTime: Long?, mode: OngoingCallNoticeMode, participantCount: Int = 0) {
 		setOperationMode(mode, participantCount)
 		startTime?.let {
 			chronometer.base = it
@@ -80,22 +122,15 @@ class OngoingCallNoticeView : LinearLayout, DefaultLifecycleObserver {
 		visibility = View.VISIBLE
 	}
 
-	fun setContainerAction(action: Runnable?) {
-		setViewAction(callContainer, action)
-	}
-
-	fun setButtonAction(action: Runnable?) {
-		setViewAction(actionButton, action)
-	}
-
-	fun setOperationMode(mode: Int, participantCount: Int) {
+	private fun setOperationMode(mode: OngoingCallNoticeMode, participantCount: Int) {
+		operationMode = mode
 
 		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
 			actionButton.setTextAppearance(R.style.TextAppearance_Chip_ChatNotice)
 		}
 
 		when (mode) {
-			OngoingCallNoticeModes.MODE_VOIP -> {
+			OngoingCallNoticeMode.MODE_VOIP -> {
 				callContainer.isClickable = true
 				callContainer.isFocusable = true
 				actionButton.text = context.getString(R.string.voip_hangup)
@@ -105,8 +140,9 @@ class OngoingCallNoticeView : LinearLayout, DefaultLifecycleObserver {
 				actionButton.chipIconTint = getDangerousTextColor()
 				callText.setText(R.string.call_ongoing)
 				participantsText.visibility = GONE
+				callDurationDivider.visibility = GONE
 			}
-			OngoingCallNoticeModes.MODE_GROUP_CALL_JOINED -> {
+			OngoingCallNoticeMode.MODE_GROUP_CALL_JOINED -> {
 				callContainer.isClickable = false
 				callContainer.isFocusable = false
 				actionButton.text = context.getString(R.string.voip_gc_open_call)
@@ -117,7 +153,7 @@ class OngoingCallNoticeView : LinearLayout, DefaultLifecycleObserver {
 				callText.setText(R.string.voip_gc_in_call)
 				setParticipantsText(participantCount)
 			}
-			OngoingCallNoticeModes.MODE_GROUP_CALL_RUNNING -> {
+			OngoingCallNoticeMode.MODE_GROUP_CALL_RUNNING -> {
 				callContainer.isClickable = false
 				callContainer.isFocusable = false
 				actionButton.text = context.getString(R.string.voip_gc_join_call)
@@ -128,9 +164,6 @@ class OngoingCallNoticeView : LinearLayout, DefaultLifecycleObserver {
 				callText.setText(R.string.voip_gc_ongoing_call)
 				setParticipantsText(participantCount)
 			}
-			else -> {
-				// should never happen
-			}
 		}
 	}
 
@@ -141,6 +174,7 @@ class OngoingCallNoticeView : LinearLayout, DefaultLifecycleObserver {
 		callText = findViewById(R.id.call_text)
 		chronometer = findViewById(R.id.call_duration)
 		participantsText = findViewById(R.id.participants_count)
+		callDurationDivider = findViewById(R.id.ongoing_call_divider)
 	}
 
 	private fun init() {
@@ -157,19 +191,10 @@ class OngoingCallNoticeView : LinearLayout, DefaultLifecycleObserver {
 		return backgroundColor.withAlpha(0x1a)
 	}
 
-	private fun getDefaultBackgroundColor(): ColorStateList {
-		val backgroundColor = ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(context, R.attr.colorAccent))
-		return backgroundColor.withAlpha(0x1a)
-	}
-
 	private fun getTextColorGroupCall(): ColorStateList {
 		return ColorStateList.valueOf(resources.getColor(R.color.group_call_accent))
 	}
 
-	private fun getTextColor(): ColorStateList {
-		return ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(context, R.attr.colorAccent))
-	}
-
 	private fun getDangerousTextColor(): ColorStateList {
 		return ColorStateList.valueOf(resources.getColor(R.color.material_red))
 	}
@@ -179,6 +204,29 @@ class OngoingCallNoticeView : LinearLayout, DefaultLifecycleObserver {
 		return backgroundColor.withAlpha(0x1a)
 	}
 
+	private fun voipContainerAction() {
+		if (VoipCallService.isRunning()) {
+			val openIntent = Intent(context, CallActivity::class.java)
+			openIntent.putExtra(VoipCallService.EXTRA_ACTIVITY_MODE, CallActivity.MODE_ACTIVE_CALL)
+			openIntent.putExtra(
+				VoipCallService.EXTRA_CONTACT_IDENTITY,
+				VoipCallService.getOtherPartysIdentity()
+			)
+			openIntent.putExtra(VoipCallService.EXTRA_START_TIME, VoipCallService.getStartTime())
+			context.startActivity(openIntent)
+		}
+	}
+
+	private fun voipButtonAction() {
+		val hangupIntent = Intent(context, VoipCallService::class.java)
+		hangupIntent.action = VoipCallService.ACTION_HANGUP
+		context.startService(hangupIntent)
+	}
+
+	private fun groupCallButtonAction(call: GroupCallDescription) {
+		context.startActivity(GroupCallActivity.getJoinCallIntent(context, call.getGroupIdInt()))
+	}
+
 	private fun setViewAction(view: View?, action: Runnable?) {
 		if (action == null) {
 			view?.setOnClickListener(null)
@@ -187,6 +235,29 @@ class OngoingCallNoticeView : LinearLayout, DefaultLifecycleObserver {
 		}
 	}
 
+	private fun setContainerAction(action: Runnable?) {
+		setViewAction(callContainer, action)
+	}
+
+	private fun setButtonAction(action: Runnable?) {
+		setViewAction(actionButton, action)
+	}
+
+	private fun setupVoipActions() {
+		groupId = null
+		setContainerAction(this::voipContainerAction)
+		setButtonAction(this::voipButtonAction)
+	}
+
+	private fun setupGroupCallActions(call: GroupCallDescription) {
+		if (groupId != call.groupId) {
+			groupId = call.groupId
+			setContainerAction(null)
+			val action = call.let { { groupCallButtonAction(it) } }
+			setButtonAction(action)
+		}
+	}
+
 	private fun setParticipantsText(participantCount: Int) {
 		if (participantCount > 0) {
 			participantsText.text = ConfigUtils.getSafeQuantityString(
@@ -196,8 +267,10 @@ class OngoingCallNoticeView : LinearLayout, DefaultLifecycleObserver {
 				participantCount
 			)
 			participantsText.visibility = VISIBLE
+			callDurationDivider.visibility = VISIBLE
 		} else {
 			participantsText.visibility = GONE
+			callDurationDivider.visibility = GONE
 		}
 	}
 }

+ 260 - 155
app/src/main/java/ch/threema/app/ui/OpenBallotNoticeView.java

@@ -37,15 +37,19 @@ import android.view.MenuInflater;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.view.animation.RotateAnimation;
 
 import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
 import androidx.appcompat.app.AppCompatActivity;
 import androidx.appcompat.view.ContextThemeWrapper;
 import androidx.appcompat.view.menu.MenuBuilder;
 import androidx.appcompat.view.menu.MenuPopupHelper;
 import androidx.constraintlayout.widget.ConstraintLayout;
+import androidx.core.content.ContextCompat;
 import androidx.fragment.app.FragmentManager;
 import androidx.lifecycle.DefaultLifecycleObserver;
 import androidx.lifecycle.LifecycleOwner;
@@ -60,6 +64,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 
 import org.slf4j.Logger;
 
+import java.util.LinkedList;
 import java.util.List;
 
 import ch.threema.app.R;
@@ -85,11 +90,12 @@ import ch.threema.storage.models.ballot.BallotModel;
 /**
  * A view that shows all open ballots (polls) for a chat in a ChipGroup and allows users to vote or close the ballot
  */
-public class OpenBallotNoticeView extends ConstraintLayout implements DefaultLifecycleObserver, Chip.OnClickListener {
+public class OpenBallotNoticeView extends ConstraintLayout implements DefaultLifecycleObserver {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("OpenBallotNoticeView");
 	private static final int MAX_BALLOTS_SHOWN = 20;
 	private static final int MAX_BALLOT_TITLE_LENGTH = 25;
 	private ChipGroup chipGroup;
+	private final List<BallotChipHolder> shownBallots = new LinkedList<>();
 	private BallotService ballotService;
 	private UserService userService;
 	private PreferenceService preferenceService;
@@ -106,7 +112,11 @@ public class OpenBallotNoticeView extends ConstraintLayout implements DefaultLif
 
 		@Override
 		public void onVoteChanged(BallotModel ballotModel, String votingIdentity, boolean isFirstVote) {
-			RuntimeUtil.runOnUiThread(() -> updateBallotDisplay());
+			// There is no need to update the chips if the vote has been changed. However, update
+			// the view when a first vote has been received as this may change the vote counter.
+			if (isFirstVote) {
+				RuntimeUtil.runOnUiThread(() -> updateBallotDisplay());
+			}
 		}
 
 		@Override
@@ -268,129 +278,48 @@ public class OpenBallotNoticeView extends ConstraintLayout implements DefaultLif
 
 			@Override
 			protected void onPostExecute(List<BallotModel> ballotModels) {
-				chipGroup.removeAllViews();
-				numOpenBallots = ballotModels.size();
-				if (numOpenBallots <= 0) {
+				// Hide this view if there are no open ballots (anymore)
+				if (ballotModels.isEmpty()) {
 					hide(false);
-				} else {
-					int i = 0;
-
-					Chip firstChip = new Chip(getContext());
-					ChipDrawable firstChipDrawable = ChipDrawable.createFromAttributes(getContext(),
-						null,
-						0,
-						R.style.Chip_ChatNotice_Overview_Intro);
-					firstChip.setChipDrawable(firstChipDrawable);
-					if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
-						firstChip.setTextAppearance(R.style.TextAppearance_Chip_ChatNotice);
-					} else {
-						firstChip.setTextSize(14);
-					}
-					firstChip.setTextColor(ConfigUtils.getColorFromAttribute(getContext(), R.attr.text_color_openNotice));
-					firstChip.setChipBackgroundColor(ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(getContext(), R.attr.background_openNotice)));
-					firstChip.setText(R.string.ballot_open);
-					firstChip.setClickable(false);
-					chipGroup.addView(firstChip);
-
-					int j = 0;
-					for (BallotModel ballot: ballotModels) {
-						// show only the latest MAX_BALLOTS_SHOWN open ballots
-						if (i++ >= MAX_BALLOTS_SHOWN) {
-							break;
-						}
-
-						int voters = ballotService.getVotedParticipants(ballot.getId()).size();
-						int participants = ballotService.getParticipants(ballot.getId()).length;
-						if (participants == 0) {
-							continue;
-						}
-
-						String name = ballot.getName();
+					return;
+				}
 
-						if (TestUtil.empty(name)) {
-							name = getContext().getString(R.string.ballot_placeholder);
-						} else {
-							if (name.length() > MAX_BALLOT_TITLE_LENGTH) {
-								name = name.substring(0, MAX_BALLOT_TITLE_LENGTH);
-								name += "…";
-							}
-						}
+				// If there aren't any chips, then add the first chip that explains that this view
+				// shows open ballots
+				if (shownBallots.isEmpty()) {
+					chipGroup.addView(createFirstChip());
+				}
 
-						Chip chip = new Chip(getContext());
-						ChipDrawable chipDrawable = ChipDrawable.createFromAttributes(getContext(),
-							null,
-							0,
-							R.style.Chip_ChatNotice_Overview);
-						chip.setChipDrawable(chipDrawable);
-						if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
-							chip.setTextAppearance(R.style.TextAppearance_Chip_ChatNotice);
-						} else {
-							chip.setTextSize(14);
-						}
-						chip.setOnClickListener((View v) -> {
-							OpenBallotNoticeView.this.onChipClick(v, voters == participants);
-						});
-
-						new AsyncTask<Void, Void, Bitmap>() {
-							@Override
-							protected Bitmap doInBackground(Void... params) {
-								Bitmap bitmap = contactService.getAvatar(contactService.getByIdentity(ballot.getCreatorIdentity()), false);
-								if (bitmap != null) {
-									return BitmapUtil.replaceTransparency(bitmap, Color.WHITE);
-								}
-								return null;
-							}
-
-							@Override
-							protected void onPostExecute(Bitmap avatar) {
-								if (avatar != null) {
-									chip.setChipIcon(AvatarConverterUtil.convertToRound(getResources(), avatar));
-								} else {
-									chip.setChipIconResource(R.drawable.ic_vote_outline);
-								}
-							}
-						}.execute();
-
-						chip.setTag(ballot);
-						chip.setTextEndPadding(getResources().getDimensionPixelSize(R.dimen.chip_end_padding_text_only));
-
-						ColorStateList foregroundColor, backgroundColor;
-						boolean isMine = BallotUtil.isMine(ballot, userService);
-
-						if (isMine) {
-							chip.setText(name + " (" + voters + "/" + participants + ")");
-						} else {
-							chip.setText(name);
-						}
+				int numBallotsShown = 0;
+				for (int i = 0; i < ballotModels.size(); i++) {
+					if (shownBallots.size() > i) {
+						// Update the available chips if possible
+						shownBallots.get(i).updateBallotModel(ballotModels.get(i));
+					} else {
+						// Add new chips if there are not enough chips present
+						shownBallots.add(new BallotChipHolder(ballotModels.get(i)));
+					}
+					// Count the shown chips. Note that chips with invalid ballots are not shown,
+					// but remain in this list in case an update makes them valid.
+					if (shownBallots.get(i).isShown()) {
+						numBallotsShown++;
+					}
+					// Don't add more than limit
+					if (numBallotsShown >= MAX_BALLOTS_SHOWN) {
+						break;
+					}
+				}
 
-						if (isMine && voters == participants) {
-							// all votes are in
-							if (ConfigUtils.getAppTheme(getContext()) == ConfigUtils.THEME_DARK) {
-								foregroundColor = ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(getContext(), R.attr.textColorSecondary));
-								backgroundColor = ColorStateList.valueOf(getResources().getColor(R.color.material_red));
-							} else {
-								foregroundColor = ColorStateList.valueOf(getResources().getColor(R.color.material_red));
-								backgroundColor = foregroundColor.withAlpha(getResources().getInteger(R.integer.chip_alpha));
-							}
-						} else {
-							if (ConfigUtils.getAppTheme(getContext()) == ConfigUtils.THEME_DARK) {
-								foregroundColor = ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(getContext(), R.attr.textColorPrimary));
-								backgroundColor = ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(getContext(), R.attr.colorAccent));
-							} else {
-								foregroundColor = ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(getContext(), R.attr.colorAccent));
-								backgroundColor = foregroundColor.withAlpha(getResources().getInteger(R.integer.chip_alpha));
-							}
-						}
+				// Remove the last ballot models
+				for (int i = shownBallots.size() - 1; i >= ballotModels.size(); i--) {
+					BallotChipHolder removedHolder = shownBallots.remove(i);
+					removedHolder.remove();
+				}
 
-						chip.setTextColor(foregroundColor);
-						chip.setChipBackgroundColor(backgroundColor);
+				OpenBallotNoticeView.this.numOpenBallots = numBallotsShown;
 
-						chipGroup.addView(chip);
-						j++;
-					}
-					if (j > 0) {
-						show(false);
-					}
+				if (numBallotsShown > 0) {
+					show(false);
 				}
 			}
 		}.execute();
@@ -401,7 +330,8 @@ public class OpenBallotNoticeView extends ConstraintLayout implements DefaultLif
 		updateBallotDisplay();
 	}
 
-	public void setVisibilityListener(VisibilityListener listener) {
+	public void update() {
+		updateBallotDisplay();
 	}
 
 	@Override
@@ -423,27 +353,28 @@ public class OpenBallotNoticeView extends ConstraintLayout implements DefaultLif
 		ListenerManager.ballotListeners.remove(this.ballotListener);
 	}
 
-	@Override
-	public void onClick(View v) {
-		BallotModel model = (BallotModel) v.getTag();
-
-		if (BallotUtil.canClose(model, identity)) {
-			int voters = ballotService.getVotedParticipants(model.getId()).size();
-			int participants = ballotService.getParticipants(model.getId()).length;
-
-			if (participants > 0 && voters == participants) {
-				onChipClick(v, true);
-				return;
-			}
+	@NonNull
+	private Chip createFirstChip() {
+		Chip firstChip = new Chip(getContext());
+		ChipDrawable firstChipDrawable = ChipDrawable.createFromAttributes(getContext(),
+			null,
+			0,
+			R.style.Chip_ChatNotice_Overview_Intro);
+		firstChip.setChipDrawable(firstChipDrawable);
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+			firstChip.setTextAppearance(R.style.TextAppearance_Chip_ChatNotice);
+		} else {
+			firstChip.setTextSize(14);
 		}
-
-		vote(model);
+		firstChip.setTextColor(ConfigUtils.getColorFromAttribute(getContext(), R.attr.text_color_openNotice));
+		firstChip.setChipBackgroundColor(ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(getContext(), R.attr.background_openNotice)));
+		firstChip.setText(R.string.ballot_open);
+		firstChip.setClickable(false);
+		return firstChip;
 	}
 
 	@SuppressLint("RestrictedApi")
-	public void onChipClick(View v, boolean isVoteComplete) {
-		BallotModel ballotModel = (BallotModel) v.getTag();
-
+	public void onChipClick(@NonNull View v, @Nullable BallotModel ballotModel, boolean isVoteComplete) {
 		if (ballotModel != null) {
 			MenuBuilder menuBuilder = new MenuBuilder(getContext());
 			new MenuInflater(getContext()).inflate(R.menu.chip_open_ballots, menuBuilder);
@@ -475,26 +406,24 @@ public class OpenBallotNoticeView extends ConstraintLayout implements DefaultLif
 
 			menuBuilder.setCallback(new MenuBuilder.Callback() {
 				@Override
-				public boolean onMenuItemSelected(MenuBuilder menu, MenuItem item) {
-					switch (item.getItemId()) {
-						case R.id.menu_ballot_vote:
-							vote(ballotModel);
-							break;
-						case R.id.menu_ballot_results:
-							BallotUtil.openMatrixActivity(getContext(), ballotModel, identity);
-							break;
-						case R.id.menu_ballot_close:
-							close(ballotModel);
-							break;
-						case R.id.menu_ballot_delete:
-							delete(ballotModel);
-							break;
+				public boolean onMenuItemSelected(@NonNull MenuBuilder menu, @NonNull MenuItem item) {
+					int id = item.getItemId();
+					if (id == R.id.menu_ballot_vote) {
+						vote(ballotModel);
+					} else if (id == R.id.menu_ballot_results) {
+						BallotUtil.openMatrixActivity(getContext(), ballotModel, identity);
+					} else if (id == R.id.menu_ballot_close) {
+						close(ballotModel);
+					} else if (id == R.id.menu_ballot_delete) {
+						delete(ballotModel);
 					}
 					return true;
 				}
 
 				@Override
-				public void onMenuModeChange(MenuBuilder menu) {}
+				public void onMenuModeChange(@NonNull MenuBuilder menu) {
+					// nothing to do
+				}
 			});
 
 			if (!BallotUtil.canViewMatrix(ballotModel, identity)) {
@@ -502,7 +431,7 @@ public class OpenBallotNoticeView extends ConstraintLayout implements DefaultLif
 			}
 
 			if (!BallotUtil.canClose(ballotModel, identity)) {
-				menuBuilder.removeItem(R.id.menu_ballot_close);;
+				menuBuilder.removeItem(R.id.menu_ballot_close);
 			}
 
 			Context wrapper = new ContextThemeWrapper(getContext(), ConfigUtils.getAppTheme(getContext()) == ConfigUtils.THEME_DARK ? R.style.AppBaseTheme_Dark : R.style.AppBaseTheme);
@@ -558,7 +487,183 @@ public class OpenBallotNoticeView extends ConstraintLayout implements DefaultLif
 		return (AppCompatActivity) getContext();
 	}
 
-	public interface VisibilityListener {
-		void onDismissed();
+	private class BallotChipHolder {
+		@NonNull
+		private BallotModel ballot;
+		private final Chip chip;
+		private boolean isShown = true;
+		private int displayedVotes = -1;
+		private int displayedParticipants = -1;
+
+		private final Animation animation = new RotateAnimation(
+			-3f,
+			3,
+			Animation.RELATIVE_TO_SELF,
+			0.5f,
+			Animation.RELATIVE_TO_SELF,
+			0.5f
+		);
+
+		private BallotChipHolder(@NonNull BallotModel ballotModel) {
+			this.ballot = ballotModel;
+			this.chip = createChip();
+
+			chipGroup.addView(this.chip);
+
+			animation.setDuration(50);
+			animation.setRepeatCount(4);
+			animation.setRepeatMode(Animation.REVERSE);
+
+			show();
+		}
+
+		private void updateBallotModel(@NonNull BallotModel ballotModel) {
+			boolean isAnotherBallot = ballot.getId() != ballotModel.getId();
+			this.ballot = ballotModel;
+			if (isAnotherBallot) {
+				show();
+			} else {
+				updateName();
+				setColor(BallotUtil.isMine(ballot, userService), displayedVotes, displayedParticipants);
+			}
+		}
+
+		@NonNull
+		private Chip createChip() {
+			Chip ballotChip = new Chip(getContext());
+
+			ChipDrawable chipDrawable = ChipDrawable.createFromAttributes(getContext(),
+				null,
+				0,
+				R.style.Chip_ChatNotice_Overview);
+			ballotChip.setChipDrawable(chipDrawable);
+
+			if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+				ballotChip.setTextAppearance(R.style.TextAppearance_Chip_ChatNotice);
+			} else {
+				ballotChip.setTextSize(14);
+			}
+
+			ballotChip.setTextEndPadding(getResources().getDimensionPixelSize(R.dimen.chip_end_padding_text_only));
+
+			return ballotChip;
+		}
+
+		private void show() {
+			chip.setVisibility(View.VISIBLE);
+
+			int votes = ballotService.getVotedParticipants(ballot.getId()).size();
+			int participants = ballotService.getParticipants(ballot.getId()).length;
+			if (participants == 0) {
+				displayedVotes = -1;
+				displayedParticipants = -1;
+				chip.setVisibility(View.GONE);
+				isShown = false;
+				return;
+			}
+
+			displayedVotes = votes;
+			displayedParticipants = participants;
+
+			chip.setOnClickListener((View v) -> OpenBallotNoticeView.this.onChipClick(v, ballot,votes == participants));
+
+			boolean isMine = BallotUtil.isMine(ballot, userService);
+
+			chip.setText(getText(isMine, votes, participants));
+
+			setAvatar();
+
+			setColor(isMine, votes, participants);
+		}
+
+		private void updateName() {
+			int votes = ballotService.getVotedParticipants(ballot.getId()).size();
+			int participants = ballotService.getParticipants(ballot.getId()).length;
+			chip.setText(getText(BallotUtil.isMine(ballot, userService), votes, participants));
+			if (votes > displayedVotes && participants == displayedParticipants) {
+				// Animate view when the number of votes increased
+				chip.setAnimation(animation);
+			}
+			displayedVotes = votes;
+			displayedParticipants = participants;
+		}
+
+		private void remove() {
+			chipGroup.removeView(chip);
+		}
+
+		private boolean isShown() {
+			return isShown;
+		}
+
+		@SuppressLint("DefaultLocale")
+		@NonNull
+		private String getText(boolean isMine, int votes, int participants) {
+			String name = ballot.getName();
+
+			if (TestUtil.empty(name)) {
+				name = getContext().getString(R.string.ballot_placeholder);
+			} else {
+				if (name.length() > MAX_BALLOT_TITLE_LENGTH) {
+					name = name.substring(0, MAX_BALLOT_TITLE_LENGTH);
+					name += "…";
+				}
+			}
+			if (isMine) {
+				return String.format("%s (%d/%d)", name, votes, participants);
+			} else {
+				return name;
+			}
+		}
+
+		private void setAvatar() {
+			new AsyncTask<Void, Void, Bitmap>() {
+				@Override
+				protected Bitmap doInBackground(Void... params) {
+					Bitmap bitmap = contactService.getAvatar(contactService.getByIdentity(ballot.getCreatorIdentity()), false);
+					if (bitmap != null) {
+						return BitmapUtil.replaceTransparency(bitmap, Color.WHITE);
+					}
+					return null;
+				}
+
+				@Deprecated
+				@Override
+				protected void onPostExecute(Bitmap avatar) {
+					if (avatar != null) {
+						chip.setChipIcon(AvatarConverterUtil.convertToRound(getResources(), avatar));
+					} else {
+						chip.setChipIconResource(R.drawable.ic_vote_outline);
+					}
+				}
+			}.execute();
+		}
+
+		private void setColor(boolean isMine, int voters, int participants) {
+			ColorStateList foregroundColor, backgroundColor;
+
+			if (isMine && voters == participants) {
+				// all votes are in
+				if (ConfigUtils.getAppTheme(getContext()) == ConfigUtils.THEME_DARK) {
+					foregroundColor = ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(getContext(), R.attr.textColorSecondary));
+					backgroundColor = ColorStateList.valueOf(ContextCompat.getColor(getContext(), R.color.material_red));
+				} else {
+					foregroundColor = ColorStateList.valueOf(ContextCompat.getColor(getContext(), R.color.material_red));
+					backgroundColor = foregroundColor.withAlpha(getResources().getInteger(R.integer.chip_alpha));
+				}
+			} else {
+				if (ConfigUtils.getAppTheme(getContext()) == ConfigUtils.THEME_DARK) {
+					foregroundColor = ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(getContext(), R.attr.textColorPrimary));
+					backgroundColor = ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(getContext(), R.attr.colorAccent));
+				} else {
+					foregroundColor = ColorStateList.valueOf(ConfigUtils.getColorFromAttribute(getContext(), R.attr.colorAccent));
+					backgroundColor = foregroundColor.withAlpha(getResources().getInteger(R.integer.chip_alpha));
+				}
+			}
+
+			chip.setTextColor(foregroundColor);
+			chip.setChipBackgroundColor(backgroundColor);
+		}
 	}
+
 }

+ 103 - 40
app/src/main/java/ch/threema/app/ui/PaintSelectionPopup.java

@@ -30,32 +30,40 @@ import android.widget.FrameLayout;
 import android.widget.LinearLayout;
 import android.widget.PopupWindow;
 
+import androidx.annotation.NonNull;
 import ch.threema.app.R;
+import ch.threema.app.motionviews.widget.MotionEntity;
 import ch.threema.app.utils.AnimationUtil;
 
-public class PaintSelectionPopup extends PopupWindow implements View.OnClickListener {
+public class PaintSelectionPopup extends PopupWindow {
 
-	public static final int TAG_REMOVE = 1;
-	public static final int TAG_FLIP = 2;
-	public static final int TAG_TO_FRONT = 3;
-	private FrameLayout removeView, flipView, tofrontView;
-	private View parentView;
+	private final View removeView;
+	private final View flipSeparator;
+	private final View flipView;
+	private final View bringToFrontSeparator;
+	private final View bringToFrontView;
+	private final View colorSeparator;
+	private final View colorView;
+	private final View parentView;
 	private PaintSelectPopupListener paintSelectPopupListener;
 
-	private final int[] location = new int[2];
-
 	public PaintSelectionPopup(Context context, View parentView) {
 		super(context);
 
 		this.parentView = parentView;
 
 		LayoutInflater layoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
-		LinearLayout topLayout = (LinearLayout) layoutInflater.inflate(R.layout.popup_paint_selection, null, true);
+		FrameLayout topLayout = (FrameLayout) layoutInflater.inflate(R.layout.popup_paint_selection, null, true);
 
-		this.removeView = topLayout.findViewById(R.id.remove_layout);
-		this.flipView = topLayout.findViewById(R.id.flip_layout);
-		this.tofrontView = topLayout.findViewById(R.id.tofront_layout);
+		this.removeView = topLayout.findViewById(R.id.remove_paint);
+		this.flipSeparator = topLayout.findViewById(R.id.flip_separator);
+		this.flipView = topLayout.findViewById(R.id.flip_paint);
+		this.bringToFrontSeparator = topLayout.findViewById(R.id.bring_to_front_separator);
+		this.bringToFrontView = topLayout.findViewById(R.id.bring_to_front_paint);
+		this.colorSeparator = topLayout.findViewById(R.id.color_separator);
+		this.colorView = topLayout.findViewById(R.id.color_paint);
 
+		setBackgroundDrawable(null);
 		setContentView(topLayout);
 		setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED);
 		setWidth(LinearLayout.LayoutParams.WRAP_CONTENT);
@@ -66,21 +74,21 @@ public class PaintSelectionPopup extends PopupWindow implements View.OnClickList
 		setElevation(10);
 	}
 
-	public void show(int x, int y, boolean allowReordering) {
-		this.removeView.setOnClickListener(this);
-		this.removeView.setTag(TAG_REMOVE);
+	public void show(int x, int y, @NonNull MotionEntity entity) {
+		initRemoveView();
 
-		if (allowReordering) {
-			this.flipView.setVisibility(View.VISIBLE);
-			this.flipView.setOnClickListener(this);
-			this.flipView.setTag(TAG_FLIP);
+		if (entity.canMove()) {
+			initFlipView();
+			initBringToFrontView();
+		} else {
+			hideFlipView();
+			hideBringToFrontView();
+		}
 
-			this.tofrontView.setVisibility(View.VISIBLE);
-			this.tofrontView.setOnClickListener(this);
-			this.tofrontView.setTag(TAG_TO_FRONT);
+		if (entity.canChangeColor()) {
+			initColorView();
 		} else {
-			this.flipView.setVisibility(View.GONE);
-			this.tofrontView.setVisibility(View.GONE);
+			hideColorView();
 		}
 
 		if (this.paintSelectPopupListener != null) {
@@ -95,39 +103,94 @@ public class PaintSelectionPopup extends PopupWindow implements View.OnClickList
 				getContentView().getViewTreeObserver().removeOnGlobalLayoutListener(this);
 
 				AnimationUtil.popupAnimateIn(getContentView());
+
+				int animationDelay = 10;
+				final int animationDelayStep = 100;
+
+				if (removeView.getVisibility() == View.VISIBLE) {
+					AnimationUtil.bubbleAnimate(removeView, animationDelay += animationDelayStep);
+				}
+				if (flipView.getVisibility() == View.VISIBLE) {
+					AnimationUtil.bubbleAnimate(flipView, animationDelay += animationDelayStep);
+				}
+				if (bringToFrontView.getVisibility() == View.VISIBLE) {
+					AnimationUtil.bubbleAnimate(bringToFrontView, animationDelay += animationDelayStep);
+				}
+				if (colorView.getVisibility() == View.VISIBLE) {
+					AnimationUtil.bubbleAnimate(colorView, animationDelay + animationDelayStep);
+				}
 			}
 		});
 	}
 
+	private void initRemoveView() {
+		this.removeView.setVisibility(View.VISIBLE);
+		this.removeView.setOnClickListener(v -> {
+			paintSelectPopupListener.onRemoveClicked();
+			dismiss();
+		});
+	}
+
+	private void initFlipView() {
+		this.flipView.setVisibility(View.VISIBLE);
+		this.flipSeparator.setVisibility(View.VISIBLE);
+		this.flipView.setOnClickListener(v -> {
+			paintSelectPopupListener.onFlipClicked();
+			dismiss();
+		});
+	}
+
+	private void hideFlipView() {
+		this.flipSeparator.setVisibility(View.GONE);
+		this.flipView.setVisibility(View.GONE);
+	}
+
+	private void initBringToFrontView() {
+		this.bringToFrontView.setVisibility(View.VISIBLE);
+		this.bringToFrontSeparator.setVisibility(View.VISIBLE);
+		this.bringToFrontView.setOnClickListener(v -> {
+			paintSelectPopupListener.onBringToFrontClicked();
+			dismiss();
+		});
+	}
+
+	private void hideBringToFrontView() {
+		this.bringToFrontSeparator.setVisibility(View.GONE);
+		this.bringToFrontView.setVisibility(View.GONE);
+	}
+
+	private void initColorView() {
+		this.colorView.setVisibility(View.VISIBLE);
+		this.colorSeparator.setVisibility(View.VISIBLE);
+		this.colorView.setOnClickListener(v -> {
+			paintSelectPopupListener.onColorClicked();
+			dismiss();
+		});
+	}
+
+	private void hideColorView() {
+		this.colorSeparator.setVisibility(View.GONE);
+		this.colorView.setVisibility(View.GONE);
+	}
+
 	@Override
 	public void dismiss() {
 		if (this.paintSelectPopupListener != null) {
 			this.paintSelectPopupListener.onClose();
 		}
 
-		AnimationUtil.popupAnimateOut(getContentView(), new Runnable() {
-			@Override
-			public void run() {
-				PaintSelectionPopup.super.dismiss();
-			}
-		});
-
+		AnimationUtil.popupAnimateOut(getContentView(), PaintSelectionPopup.super::dismiss);
 	}
 
 	public void setListener(PaintSelectPopupListener listener) {
 		this.paintSelectPopupListener = listener;
 	}
 
-	@Override
-	public void onClick(View v) {
-		if (paintSelectPopupListener != null) {
-			paintSelectPopupListener.onItemSelected((int) v.getTag());
-			dismiss();
-		}
-	}
-
 	public interface PaintSelectPopupListener {
-		void onItemSelected(int tag);
+		void onRemoveClicked();
+		void onFlipClicked();
+		void onBringToFrontClicked();
+		void onColorClicked();
 		void onOpen();
 		void onClose();
 	}

+ 55 - 0
app/src/main/java/ch/threema/app/ui/ServerMessageViewModel.kt

@@ -0,0 +1,55 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2023 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.ui
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import ch.threema.app.ThreemaApplication
+import ch.threema.storage.DatabaseServiceNew
+import ch.threema.storage.factories.ServerMessageModelFactory
+
+class ServerMessageViewModel : ViewModel() {
+
+    private val serverMessageModelFactory: ServerMessageModelFactory?
+
+    private val serverMessage = MutableLiveData<String?>()
+    fun getServerMessage(): LiveData<String?> = serverMessage
+
+    init {
+        val serviceManager = ThreemaApplication.getServiceManager()
+        val databaseService: DatabaseServiceNew? = serviceManager?.databaseServiceNew
+        serverMessageModelFactory = databaseService?.serverMessageModelFactory
+
+        serverMessage.postValue(serverMessageModelFactory?.popServerMessageModel()?.message)
+    }
+
+    fun markServerMessageAsRead() {
+        // Delete currently shown message from database if the same message arrived again in the
+        // meantime.
+        serverMessage.value?.let {
+            serverMessageModelFactory?.delete(it)
+        }
+        // Post the next message. If it is null, then no server message is available
+        serverMessage.postValue(serverMessageModelFactory?.popServerMessageModel()?.message)
+    }
+}

+ 6 - 3
app/src/main/java/ch/threema/app/utils/BitmapUtil.java

@@ -35,6 +35,7 @@ import android.graphics.PorterDuffColorFilter;
 import android.graphics.drawable.BitmapDrawable;
 import android.graphics.drawable.Drawable;
 import android.net.Uri;
+import android.os.NetworkOnMainThreadException;
 import android.provider.MediaStore;
 import android.renderscript.Allocation;
 import android.renderscript.Element;
@@ -466,12 +467,14 @@ public class BitmapUtil {
 						orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED);
 					}
 				} catch (IOException | NegativeArraySizeException e) {
-					logger.debug("Error checking exif");
+					logger.error("Error checking exif", e);
 				} catch (SecurityException e) {
-					logger.debug("Error checking exif: Permission denied");
+					logger.error("Error checking exif: Permission denied", e);
+				} catch (NetworkOnMainThreadException e) {
+					logger.error("Error checking exif: Cannot get it from network", e);
 				}
 			} catch (IllegalStateException e) {
-				logger.debug("Error opening input stream");
+				logger.error("Error opening input stream", e);
 			}
 
 			if (orientation != ExifInterface.ORIENTATION_UNDEFINED) {

+ 3 - 8
app/src/main/java/ch/threema/app/utils/ConfigUtils.java

@@ -432,14 +432,9 @@ public class ConfigUtils {
 		if (newStyle != -1) {
 			emojiStyle = newStyle;
 		} else {
-			if (BuildFlavor.isLibre()) {
-				emojiStyle = EMOJI_ANDROID;
-				return;
-			}
-			emojiStyle = Integer.valueOf(
-				PreferenceManager.getDefaultSharedPreferences(context).
-					getString(context.getString(R.string.preferences__emoji_style),
-						"0"));
+			emojiStyle = Integer.parseInt(
+				PreferenceManager.getDefaultSharedPreferences(context).getString(context.getString(R.string.preferences__emoji_style), "0")
+			);
 		}
 	}
 

+ 6 - 2
app/src/main/java/ch/threema/app/utils/DeviceCookieManagerImpl.java

@@ -32,6 +32,7 @@ import ch.threema.app.services.NotificationService;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.base.utils.Utils;
 import ch.threema.domain.protocol.csp.connection.DeviceCookieManager;
+import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.models.ServerMessageModel;
 
 public class DeviceCookieManagerImpl implements DeviceCookieManager {
@@ -81,10 +82,13 @@ public class DeviceCookieManagerImpl implements DeviceCookieManager {
 			return;
 		}
 
-		logger.info("Device cookie change indiciation received, showing warning message");
+		logger.info("Device cookie change indication received, showing warning message");
 		NotificationService n = serviceManager.getNotificationService();
 		if (n != null) {
-			n.showServerMessage(new ServerMessageModel(ThreemaApplication.getAppContext().getString(R.string.rogue_device_warning), ServerMessageModel.Type.ALERT));
+			ServerMessageModel serverMessageModel = new ServerMessageModel(ThreemaApplication.getAppContext().getString(R.string.rogue_device_warning), ServerMessageModel.TYPE_ALERT);
+			DatabaseServiceNew databaseService = serviceManager.getDatabaseServiceNew();
+			databaseService.getServerMessageModelFactory().storeServerMessageModel(serverMessageModel);
+			n.showServerMessage(serverMessageModel);
 		}
 	}
 

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

@@ -62,7 +62,6 @@ import ch.threema.storage.models.ConversationModel;
 import ch.threema.storage.models.DistributionListMessageModel;
 import ch.threema.storage.models.GroupMessageModel;
 import ch.threema.storage.models.GroupModel;
-import ch.threema.storage.models.ServerMessageModel;
 import ch.threema.storage.models.WebClientSessionModel;
 import ch.threema.storage.models.ballot.BallotChoiceModel;
 import ch.threema.storage.models.ballot.BallotModel;
@@ -89,8 +88,6 @@ public class IntentDataUtil {
 	private static final String INTENT_DATA_GROUP_LIST = "groupl";
 	private static final String INTENT_DATA_DIST_LIST = "distl";
 
-	private static final String INTENT_DATA_SERVER_MESSAGE_TEXT = "server_message_text";
-	private static final String INTENT_DATA_SERVER_MESSAGE_TYPE = "server_message_type";
 	private static final String INTENT_DATA_MESSAGE = "message";
 	private static final String INTENT_DATA_URL = "url";
 	private static final String INTENT_DATA_CONTACTS = "contacts";
@@ -153,11 +150,6 @@ public class IntentDataUtil {
 		}
 	}
 
-	public static void append(ServerMessageModel serverMessageModel, Intent intent) {
-		intent.putExtra(INTENT_DATA_SERVER_MESSAGE_TEXT, serverMessageModel.getMessage());
-		intent.putExtra(INTENT_DATA_SERVER_MESSAGE_TYPE, serverMessageModel.getType().toString());
-	}
-
 	public static void append(AbstractMessageModel abstractMessageModel, Intent intent) {
 		intent.putExtra(INTENT_DATA_ABSTRACT_MESSAGE_ID, abstractMessageModel.getId());
 		intent.putExtra(INTENT_DATA_ABSTRACT_MESSAGE_TYPE, abstractMessageModel.getClass().toString());
@@ -217,15 +209,6 @@ public class IntentDataUtil {
 		return location;
 	}
 
-	public static ServerMessageModel getServerMessageModel(Intent intent) {
-		return new ServerMessageModel(
-				intent.getStringExtra(INTENT_DATA_SERVER_MESSAGE_TEXT),
-				ServerMessageModel.Type.ALERT.toString().equals(intent.getStringExtra(INTENT_DATA_SERVER_MESSAGE_TYPE)) ?
-						ServerMessageModel.Type.ALERT :
-						ServerMessageModel.Type.ERROR
-		);
-	}
-
 	public static Intent createActionIntentLicenseNotAllowed(String message) {
 		Intent intent = new Intent();
 		intent.putExtra(INTENT_DATA_MESSAGE, message);

+ 8 - 4
app/src/main/java/ch/threema/app/utils/MediaAdapterManager.kt

@@ -77,13 +77,17 @@ class MediaAdapterManager(private val mediaAdapterListener: MediaAdapterListener
         items.removeAt(index)
         if (isNotifyListener(notify)) {
             mediaAdapterListener.onItemCountChanged(size())
-            if (size() == 0) {
-                mediaAdapterListener.onAllItemsRemoved()
-                return
-            }
         }
 
         notifyItemRemoved(index, notify)
+
+        // Notify listener that all items have been removed. Note that this must be called after
+        // informing the adapters that an item has been removed.
+        if (size() == 0) {
+            mediaAdapterListener.onAllItemsRemoved()
+            return
+        }
+
         if (currentPosition >= items.size) {
             currentPosition = items.size - 1
         }

+ 5 - 0
app/src/main/java/ch/threema/app/utils/MediaPlayerStateWrapper.java

@@ -33,6 +33,7 @@ import org.slf4j.Logger;
 import java.io.IOException;
 import java.util.EnumSet;
 
+import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import ch.threema.base.utils.LoggingUtil;
 
@@ -349,4 +350,8 @@ public class MediaPlayerStateWrapper {
 			return 1f;
 		}
 	}
+
+	public void setOnSeekCompleteListener(@Nullable MediaPlayer.OnSeekCompleteListener listener) {
+		mediaPlayer.setOnSeekCompleteListener(listener);
+	}
 }

+ 3 - 4
app/src/main/java/ch/threema/app/utils/VideoUtil.java

@@ -92,13 +92,12 @@ public class VideoUtil {
 	}
 
 	public static ExoPlayer getExoPlayer(@NonNull Context context) {
+		DefaultRenderersFactory renderersFactory = new DefaultRenderersFactory(context);
+		renderersFactory.setEnableDecoderFallback(true);
 		if (ConfigUtils.hasAsyncMediaCodecBug()) {
 			// Workaround for https://github.com/google/ExoPlayer/issues/10021
-			DefaultRenderersFactory renderersFactory = new DefaultRenderersFactory(context);
 			renderersFactory.forceDisableMediaCodecAsynchronousQueueing();
-			return new ExoPlayer.Builder(context, renderersFactory).build();
-		} else {
-			return new ExoPlayer.Builder(context).build();
 		}
+		return new ExoPlayer.Builder(context, renderersFactory).build();
 	}
 }

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

@@ -45,6 +45,7 @@ import androidx.constraintlayout.widget.ConstraintSet
 import androidx.core.app.ActivityCompat
 import androidx.core.transition.addListener
 import androidx.core.view.*
+import androidx.lifecycle.lifecycleScope
 import androidx.recyclerview.widget.GridLayoutManager
 import androidx.recyclerview.widget.RecyclerView
 import ch.threema.app.R
@@ -75,10 +76,8 @@ import ch.threema.app.voip.util.VoipUtil
 import ch.threema.app.voip.viewmodel.GroupCallViewModel
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.storage.models.GroupModel
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
+import kotlinx.coroutines.*
+import java.lang.Runnable
 import java.util.*
 import kotlin.concurrent.schedule
 
@@ -222,7 +221,7 @@ class GroupCallActivity : ThreemaActivity(), GenericAlertDialog.DialogClickListe
 
 		views = Views()
 
-		viewModel.getFinishEvents().observe(this, this::handleFinishEvent)
+		observeFinishEvent()
 		viewModel.title.observe(this, this::setTitle)
 		viewModel.subTitle.observe(this, this::setSubTitle)
 		viewModel.statusMessage.observe(this, this::setStatusMessage)
@@ -753,6 +752,16 @@ class GroupCallActivity : ThreemaActivity(), GenericAlertDialog.DialogClickListe
 		}
 	}
 
+	private fun observeFinishEvent() {
+		lifecycleScope.launch {
+			try {
+				handleFinishEvent(viewModel.finishEvent.await())
+			} catch (e: CancellationException) {
+				logger.info("Waiting for finish event cancelled")
+			}
+		}
+	}
+
 	private fun handleFinishEvent(event: GroupCallViewModel.FinishEvent) {
 		logger.info("Finish group call activity: '{}'", event.reason)
 

+ 17 - 5
app/src/main/java/ch/threema/app/voip/groupcall/GroupCallManager.kt

@@ -22,7 +22,6 @@
 package ch.threema.app.voip.groupcall
 
 import androidx.annotation.AnyThread
-import androidx.annotation.UiThread
 import androidx.annotation.WorkerThread
 import ch.threema.app.voip.CallAudioManager
 import ch.threema.app.voip.groupcall.sfu.GroupCallController
@@ -63,16 +62,29 @@ interface GroupCallManager {
      *
      * @param groupId The [LocalGroupId] for the group to observe the call state
      */
-    @UiThread
+    @AnyThread
     fun addGroupCallObserver(groupId: LocalGroupId, observer: GroupCallObserver)
-    @UiThread
+    @AnyThread
     fun addGroupCallObserver(group: GroupModel, observer: GroupCallObserver)
 
-    @UiThread
+    @AnyThread
     fun removeGroupCallObserver(groupId: LocalGroupId, observer: GroupCallObserver)
-    @UiThread
+    @AnyThread
     fun removeGroupCallObserver(group: GroupModel, observer: GroupCallObserver)
 
+    /**
+     * The same as [addGroupCallObserver] with the difference that the observer is notified of updates
+     * of group calls in any group.
+     *
+     * Immediately upon registration the observer is notified with the [GroupCallDescription] of the running
+     * call if any or `null` otherwise.
+     */
+    @AnyThread
+    fun addGeneralGroupCallObserver(observer: GroupCallObserver)
+
+    @AnyThread
+    fun removeGeneralGroupCallObserver(observer: GroupCallObserver)
+
     /**
      * Join a GroupCall that is currently running.
      *

+ 63 - 25
app/src/main/java/ch/threema/app/voip/groupcall/GroupCallManagerImpl.kt

@@ -25,7 +25,6 @@ import android.content.Context
 import androidx.annotation.AnyThread
 import androidx.annotation.WorkerThread
 import androidx.core.content.ContextCompat
-import ch.threema.app.BuildConfig
 import ch.threema.app.ThreemaApplication
 import ch.threema.app.services.*
 import ch.threema.app.utils.ConfigUtils
@@ -50,6 +49,7 @@ import ch.threema.storage.models.ContactModel
 import ch.threema.storage.models.GroupModel
 import ch.threema.storage.models.data.status.GroupCallStatusDataModel
 import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.BufferOverflow
 import kotlinx.coroutines.flow.*
 import org.json.JSONArray
 import org.json.JSONObject
@@ -75,6 +75,19 @@ class GroupCallManagerImpl(
 		private const val ARTIFICIAL_GC_CREATE_WAIT_PERIOD_MILLIS: Long = 2000L
 	}
 
+	private val groupCallStartQueue: MutableSharedFlow<GroupCallStartMessage> = MutableSharedFlow<GroupCallStartMessage>(
+		// Reasonably high buffer capacity because messages might be dropped otherwise.
+		// Normally this should only be relevant when someone was offline for some time
+		// and lots of group calls have been carried out in the meantime.
+		extraBufferCapacity = 256,
+		onBufferOverflow = BufferOverflow.DROP_OLDEST
+	).apply {
+		CoroutineScope(GroupCallThreadUtil.DISPATCHER).launch {
+			collect { processGroupCallStart(it) }
+		}
+	}
+
+	private val generalCallObservers: MutableSet<GroupCallObserver> = Collections.synchronizedSet(mutableSetOf())
 	private val callObservers: MutableMap<LocalGroupId, MutableSet<GroupCallObserver>> = mutableMapOf()
 	private val callRefreshTimers: MutableMap<LocalGroupId, Job> = Collections.synchronizedMap(mutableMapOf())
 
@@ -146,6 +159,15 @@ class GroupCallManagerImpl(
 		}
 	}
 
+	override fun addGeneralGroupCallObserver(observer: GroupCallObserver) {
+		generalCallObservers.add(observer)
+		observer.onGroupCallUpdate(serviceConnection.getCurrentGroupCallController()?.description)
+	}
+
+	override fun removeGeneralGroupCallObserver(observer: GroupCallObserver) {
+		generalCallObservers.remove(observer)
+	}
+
 	@WorkerThread
 	override suspend fun joinCall(group: GroupModel): GroupCallController? {
 		GroupCallThreadUtil.assertDispatcherThread()
@@ -163,7 +185,7 @@ class GroupCallManagerImpl(
 				logger.info("Join existing call with id {}", it.callId)
 				joinAndConfirmCall(it, groupId)
 			}
-		}
+		}?.also { notifyJoinedAndLeftCall(it) }
 	}
 
 	override suspend fun createCall(group: GroupModel): GroupCallController {
@@ -173,6 +195,19 @@ class GroupCallManagerImpl(
 			// there is no group call considered running for this group. Start it!
 			logger.info("Create new group call")
 			createNewCall(group)
+		}.also { notifyJoinedAndLeftCall(it) }
+	}
+
+	private fun notifyJoinedAndLeftCall(call: GroupCallController) {
+		notifyCallObservers(call.description.groupId, call.description)
+		notifyGeneralCallObservers(call.description)
+		CoroutineScope(GroupCallThreadUtil.DISPATCHER).launch {
+			try {
+				call.callLeftSignal.await()
+			} catch (e: Exception) {
+				// noop
+			}
+			notifyGeneralCallObservers(null)
 		}
 	}
 
@@ -365,7 +400,11 @@ class GroupCallManagerImpl(
 		// if it has been started by mistake.
 		// If a call in this group is started by another group member in the meantime, this call
 		// will be joined immediately instead.
-		val chosenCall = waitForChosenCall(group, ARTIFICIAL_GC_CREATE_WAIT_PERIOD_MILLIS)
+		val waitPeriodMillis = when(preferenceService.skipGroupCallCreateDelay()) {
+			true -> 0
+			else -> ARTIFICIAL_GC_CREATE_WAIT_PERIOD_MILLIS
+		}
+		val chosenCall = waitForChosenCall(group, waitPeriodMillis)
 
 		if (chosenCall != null && chosenCall.callId != callId) {
 			callController.leave()
@@ -416,10 +455,6 @@ class GroupCallManagerImpl(
 					signal.complete(call)
 				}
 			}
-
-			override fun onGroupCallStart(groupModel: GroupModel) {
-				// noop
-			}
 		}
 
 		return try {
@@ -463,9 +498,11 @@ class GroupCallManagerImpl(
 
 	@WorkerThread
 	private fun handleGroupCallStart(message: GroupCallStartMessage): Boolean {
-		CoroutineScope(GroupCallThreadUtil.DISPATCHER).launch {
-			processGroupCallStart(message)
-		}
+		groupCallStartQueue.tryEmit(message)
+		// Always mark messages as processed.
+		// If there where loads of sent GroupCallStartMessages while the device was offline
+		// this might lead to "dropped" messages and therefore missing group call states in the
+		// chat. As this affects only older messages this should not be a problem.
 		return true
 	}
 
@@ -548,14 +585,6 @@ class GroupCallManagerImpl(
 
 		logger.debug("Show group call notification")
 		notificationService.addGroupCallNotification(group, callerContactModel)
-
-		notifyGroupCallStartObservers(group)
-	}
-
-	private fun notifyGroupCallStartObservers(group: GroupModel) {
-		synchronized(callObservers) {
-			callObservers[group.localGroupId]?.forEach { it.onGroupCallStart(group) }
-		}
 	}
 
 	/**
@@ -708,7 +737,7 @@ class GroupCallManagerImpl(
 
 	@WorkerThread
 	private fun sendCallInitAsText(callId: CallId, group: GroupModel, callStartData: GroupCallStartData) {
-		if (BuildConfig.DEBUG && preferenceService.isGroupCallSendInitEnabled) {
+		if (preferenceService.isGroupCallSendInitEnabled) {
 			val groupJson = JSONObject()
 			groupJson.put("creator", group.creatorIdentity)
 			groupJson.put("id", Base64.encodeBytes(group.apiGroupId.groupId))
@@ -741,6 +770,19 @@ class GroupCallManagerImpl(
 		}
 	}
 
+	private fun notifyCallObservers(groupId: LocalGroupId, call: GroupCallDescription?) {
+		synchronized(callObservers) {
+			callObservers[groupId]?.forEach { it.onGroupCallUpdate(call) }
+		}
+		notifyGeneralCallObservers(call)
+	}
+
+	private fun notifyGeneralCallObservers(call: GroupCallDescription?) {
+		synchronized(generalCallObservers) {
+			generalCallObservers.forEach { it.onGroupCallUpdate(call) }
+		}
+	}
+
 	/**
 	 * Run the group call refresh steps and return the chosen call if present.
 	 */
@@ -754,9 +796,7 @@ class GroupCallManagerImpl(
 		// Step 6: abort the steps (update the UI)
 		if (chosenCall == null) {
 			chosenCalls.remove(groupId)
-			synchronized(callObservers) {
-				callObservers[groupId]?.forEach { it.onGroupCallUpdate(null) }
-			}
+			notifyCallObservers(groupId, null)
 			return null
 		}
 
@@ -846,9 +886,7 @@ class GroupCallManagerImpl(
 		} else {
 			chosenCalls[groupId] = call
 		}
-		synchronized(callObservers) {
-			callObservers[groupId]?.forEach { it.onGroupCallUpdate(call) }
-		}
+		notifyCallObservers(groupId, call)
 		val groupModel = groupService.getById(groupId.id)
 		if (call == null && groupModel != null) {
 			messageService.createGroupCallStatus(

+ 9 - 6
app/src/main/java/ch/threema/app/voip/groupcall/GroupCallObserver.kt

@@ -22,17 +22,20 @@
 package ch.threema.app.voip.groupcall
 
 import androidx.annotation.AnyThread
-import ch.threema.storage.models.GroupModel
 
 interface GroupCallObserver {
     /**
-     * Called whenever the group call state for a specific group has changed.
+     * Called when there is an update of a group call.
+     * The cases when this is called might differ depending on the subscription used.
      *
-     * @param call The current state of this group's ongoing call or null if there is no ongoing call
+     * If a subscription is made for a specific group it will be called whenever the state of this
+     * group's call changes.
+     *
+     * If a subscription is made for joined calls this method will be called when a call is either joined
+     * or left.
+     *
+     * @param call The description of the ongoing call or null if there is no ongoing call
      */
     @AnyThread
     fun onGroupCallUpdate(call: GroupCallDescription?)
-
-    @AnyThread
-    fun onGroupCallStart(groupModel: GroupModel)
 }

+ 11 - 1
app/src/main/java/ch/threema/app/voip/groupcall/service/GroupCallControllerImpl.kt

@@ -77,6 +77,15 @@ internal class GroupCallControllerImpl (
             }
             it
         }
+    override val descriptionSignal: Deferred<GroupCallDescription> by lazy {
+        CompletableDeferred<GroupCallDescription>().also {
+            CoroutineScope(Dispatchers.Default).launch {
+                descriptionSetSignal.await()
+                it.complete(description)
+            }
+        }
+    }
+
 
     override lateinit var parameters: GroupCallParameters
 
@@ -87,7 +96,8 @@ internal class GroupCallControllerImpl (
     override val callLeftSignal: CompletableDeferred<Unit> = CompletableDeferred()
     override val callDisposedSignal: CompletableDeferred<Unit> = CompletableDeferred()
 
-    override val connectedSignal: CompletableDeferred<Pair<ULong, Set<ParticipantId>>> = CompletableDeferred()
+    override val completableConnectedSignal: CompletableDeferred<Pair<ULong, Set<ParticipantId>>> = CompletableDeferred()
+    override val connectedSignal: Deferred<Pair<ULong, Set<ParticipantId>>> = completableConnectedSignal
 
     override val dislodgedParticipants = MutableSharedFlow<ParticipantId>()
 

+ 26 - 14
app/src/main/java/ch/threema/app/voip/groupcall/service/GroupCallService.kt

@@ -23,6 +23,7 @@ package ch.threema.app.voip.groupcall.service
 
 import android.Manifest
 import android.app.Notification
+import android.app.NotificationManager
 import android.app.PendingIntent
 import android.app.Service
 import android.content.Context
@@ -59,6 +60,7 @@ import ch.threema.base.ThreemaException
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.storage.models.GroupModel
 import kotlinx.coroutines.*
+import java.util.concurrent.atomic.AtomicBoolean
 
 private val logger = LoggingUtil.getThreemaLogger("GroupCallService")
 
@@ -108,6 +110,8 @@ class GroupCallService : Service() {
         }
     }
 
+    private val serviceRunning = AtomicBoolean(false)
+
     private val binder = GroupCallServiceBinder()
 
     private var groupCallController: GroupCallControllerImpl? = null
@@ -145,17 +149,13 @@ class GroupCallService : Service() {
         return binder
     }
 
-    override fun onUnbind(intent: Intent?): Boolean {
-        stopService()
-        return false
-    }
-
     override fun onCreate() {
         super.onCreate()
         initDependencies()
     }
 
     override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        serviceRunning.set(true)
         try {
             handleIntent(intent)
             if (isLeaveCallIntent) {
@@ -221,7 +221,16 @@ class GroupCallService : Service() {
         })
     }
 
-    private fun getForegroundNotification(): Notification {
+    private fun updateNotification(startedAt: Long) {
+        val notification = getForegroundNotification(startedAt)
+        getSystemService(NOTIFICATION_SERVICE).let {
+            if (it is NotificationManager && serviceRunning.get()) {
+                it.notify(NOTIFICATION_ID, notification)
+            }
+        }
+    }
+
+    private fun getForegroundNotification(startedAt: Long = System.currentTimeMillis()): Notification {
         val group = groupService.getById(groupId.id)
         val builder = NotificationBuilderWrapper(
             this, NotificationService.NOTIFICATION_CHANNEL_IN_CALL, null)
@@ -234,10 +243,7 @@ class GroupCallService : Service() {
             .setOngoing(true)
             .setUsesChronometer(true)
             .setShowWhen(true)
-            // TODO(ANDR-1950): Maybe? It will display the time when the call has been started locally
-            //  and not the time the group call has actually been started. This information is not (yet)
-            //  available at this point
-            .setWhen(System.currentTimeMillis())
+            .setWhen(startedAt)
             .setPriority(NotificationCompat.PRIORITY_DEFAULT)
             .setContentIntent(getJoinCallPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
             .addAction(
@@ -301,7 +307,9 @@ class GroupCallService : Service() {
                     groupService
                 )
                 CoroutineScope(GroupCallThreadUtil.DISPATCHER).launch {
-                    it.join(applicationContext, sfuBaseUrl, sfuConnection) { stopService() }
+                    launch { it.join(applicationContext, sfuBaseUrl, sfuConnection) { stopService() } }
+                    val startedAt = it.descriptionSignal.await().startedAt.toLong()
+                    updateNotification(startedAt)
                 }
                 controllerDeferred.complete(it)
                 initAudioManager()
@@ -350,8 +358,13 @@ class GroupCallService : Service() {
 
     private fun stopService() {
         logger.info("Stop service")
-        // TODO(ANDR-1964): When a call is left, this is called twice: one time from leaveCall() and one time from onUnbind()
-        //  it should only be called once, maybe `leaveCall()` can be omitted?
+        serviceRunning.set(false)
+        stopSelf()
+    }
+
+    override fun onDestroy() {
+        logger.info("Destroy GroupCallService")
+        super.onDestroy()
         val exception = ThreemaException("GroupCallService has been stopped")
         // If controllerDeferred and callEnded are not yet completed, they are completed exceptionally.
         // If they already _are_ completed, nothing will happen when calling `completeExceptionally`
@@ -363,7 +376,6 @@ class GroupCallService : Service() {
         audioManager = null
         getJoinCallPendingIntent(PendingIntent.FLAG_NO_CREATE)?.cancel()
         getLeaveCallPendingIntent(PendingIntent.FLAG_NO_CREATE)?.cancel()
-        stopSelf()
     }
 
     inner class GroupCallServiceBinder : Binder() {

+ 3 - 6
app/src/main/java/ch/threema/app/voip/groupcall/sfu/GroupCall.kt

@@ -22,7 +22,6 @@
 package ch.threema.app.voip.groupcall.sfu
 
 import androidx.annotation.WorkerThread
-import ch.threema.app.voip.groupcall.GroupCallDescription
 import ch.threema.app.voip.groupcall.sfu.connection.GroupCallConnectionState
 import ch.threema.app.voip.groupcall.sfu.webrtc.RemoteCtx
 import kotlinx.coroutines.CompletableDeferred
@@ -30,15 +29,14 @@ import kotlinx.coroutines.Deferred
 import kotlinx.coroutines.flow.Flow
 import org.webrtc.EglBase
 
-internal interface GroupCall {
+internal interface GroupCall : GroupCallController {
     /**
      * A signal that indicates whether a call is valid and can move on to the CONNECTED state after
      * connecting.
      */
     val callConfirmedSignal: Deferred<Unit>
-    val callId: CallId
-    val callLeftSignal: Deferred<Unit>
-    val connectedSignal: CompletableDeferred<Pair<ULong, Set<ParticipantId>>>
+
+    val completableConnectedSignal: CompletableDeferred<Pair<ULong, Set<ParticipantId>>>
 
     /**
      * In some cases the [GroupCallController] can decide that a participant should be treated as if
@@ -49,7 +47,6 @@ internal interface GroupCall {
      */
     val dislodgedParticipants: Flow<ParticipantId>
 
-    var description: GroupCallDescription
     var parameters: GroupCallParameters
     var dependencies: GroupCallDependencies
 

+ 2 - 0
app/src/main/java/ch/threema/app/voip/groupcall/sfu/GroupCallController.kt

@@ -25,6 +25,7 @@ import androidx.annotation.AnyThread
 import androidx.annotation.UiThread
 import androidx.annotation.WorkerThread
 import ch.threema.app.voip.groupcall.GroupCallDescription
+import ch.threema.app.voip.groupcall.sfu.connection.GroupCallConnectionState
 import kotlinx.coroutines.Deferred
 import kotlinx.coroutines.flow.Flow
 import org.webrtc.EglBase
@@ -73,6 +74,7 @@ interface GroupCallController {
     val eglBase: EglBase
 
     var description: GroupCallDescription
+    val descriptionSignal: Deferred<GroupCallDescription>
 
     var microphoneActive: Boolean
 

+ 12 - 4
app/src/main/java/ch/threema/app/voip/groupcall/sfu/connection/Connecting.kt

@@ -45,6 +45,11 @@ class Connecting internal constructor(
     private val certificate: RtcCertificatePem,
     private val joinResponse: JoinResponseBody
 ) : GroupCallConnectionState(StateName.CONNECTING, call) {
+    private companion object {
+        // If there are fewer than this amount of remote participants in a call when joining
+        // the microphone will not be muted upon join.
+        private const val MUTE_MICROPHONE_PARTICIPANT_THRESHOLD = 4
+    }
 
     @WorkerThread
     override fun getStateProviders() = listOf(
@@ -75,9 +80,6 @@ class Connecting internal constructor(
             me,
             ctx.localAudioVideoContext
         )
-        // set initial video/audio states
-        participant.cameraActive = false
-        participant.microphoneActive = true
 
         call.context = GroupCallContext(
             ctx, participant
@@ -95,7 +97,13 @@ class Connecting internal constructor(
 
         logger.trace("Waiting for connected signal")
         return withTimeout(TIMEOUT_CONNECTED_SIGNAL_MILLIS) {
-            call.connectedSignal.complete(joinResponse.startedAt to connectedSignal.await())
+            val participantIds = connectedSignal.await()
+
+            // set initial video/audio states
+            participant.cameraActive = false
+            participant.microphoneActive = participantIds.size < MUTE_MICROPHONE_PARTICIPANT_THRESHOLD
+
+            call.completableConnectedSignal.complete(joinResponse.startedAt to participantIds)
             logger.trace("Waiting for call confirmation")
             call.callConfirmedSignal.await()
             Connected(call, participant)

+ 1 - 1
app/src/main/java/ch/threema/app/voip/groupcall/sfu/connection/Failed.kt

@@ -34,7 +34,7 @@ class Failed internal constructor(call: GroupCall, val reason: Throwable) : Grou
         GroupCallThreadUtil.assertDispatcherThread()
 
         // Make sure the [connectedSignal] is completed in case someone is waiting for it
-        call.connectedSignal.completeExceptionally(reason)
+        call.completableConnectedSignal.completeExceptionally(reason)
 
         logger.error("Call failed, tearing down\n{}", reason.stackTraceToString())
         call.teardown()

+ 2 - 5
app/src/main/java/ch/threema/app/voip/groupcall/sfu/webrtc/ConnectionCtx.kt

@@ -83,7 +83,6 @@ internal class ConnectionCtx(
 
             val peerConnectionCtx = createPeerConnection(
                 certificate,
-                !call.parameters.ipv6Enabled,
                 factory
             )
 
@@ -114,11 +113,10 @@ internal class ConnectionCtx(
         @WorkerThread
         private fun createPeerConnection(
             certificate: RtcCertificatePem,
-            disableIpv6: Boolean,
             factory: FactoryCtx
         ): PeerConnectionCtx {
             // Configuration for the peer connection
-            val configuration = getPeerConnectionConfiguration(certificate, disableIpv6)
+            val configuration = getPeerConnectionConfiguration(certificate)
 
             val observer = WrappedPeerConnectionObserver()
             val dependencies = PeerConnectionDependencies.builder(observer)
@@ -133,7 +131,7 @@ internal class ConnectionCtx(
         }
 
         @WorkerThread
-        private fun getPeerConnectionConfiguration(certificate: RtcCertificatePem, disableIpv6: Boolean): PeerConnection.RTCConfiguration {
+        private fun getPeerConnectionConfiguration(certificate: RtcCertificatePem): PeerConnection.RTCConfiguration {
             // Order taken from RTCConfiguration constructor. Docs for each parameter can be
             // found here:
             // https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/api/peer_connection_interface.h;bpv=1;bpt=1;l=311?q=turnPortPrunePolicy&ss=chromium%2Fchromium%2Fsrc&gsn=RTCConfigurationType&gs=kythe%3A%2F%2Fchromium.googlesource.com%2Fchromium%2Fsrc%3Flang%3Dc%252B%252B%3Fpath%3Dsrc%2Fthird_party%2Fwebrtc%2Fapi%2Fpeer_connection_interface.h%23EnglTqBUUO_2RhVLtcib-vrLxQfj1_fRrBgF1-1K3tE
@@ -151,7 +149,6 @@ internal class ConnectionCtx(
                 it.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY
                 it.turnPortPrunePolicy = PeerConnection.PortPrunePolicy.PRUNE_BASED_ON_PRIORITY
                 it.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
-                it.disableIpv6 = disableIpv6
                 it.cryptoOptions = CryptoOptions.builder()
                     .setEnableGcmCryptoSuites(true)
                     .setEnableAes128Sha1_32CryptoCipher(false)

+ 8 - 3
app/src/main/java/ch/threema/app/voip/services/VoipStateService.java

@@ -25,7 +25,6 @@ import android.app.Notification;
 import android.app.NotificationManager;
 import android.app.PendingIntent;
 import android.content.BroadcastReceiver;
-import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
@@ -114,6 +113,7 @@ import static ch.threema.app.notifications.NotificationBuilderWrapper.VIBRATE_PA
 import static ch.threema.app.notifications.NotificationBuilderWrapper.VIBRATE_PATTERN_SILENT;
 import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_CALL;
 import static ch.threema.app.utils.IntentDataUtil.PENDING_INTENT_FLAG_IMMUTABLE;
+import static ch.threema.app.utils.IntentDataUtil.PENDING_INTENT_FLAG_MUTABLE;
 import static ch.threema.app.voip.activities.CallActivity.EXTRA_ACCEPT_INCOMING_CALL;
 import static ch.threema.app.voip.services.CallRejectWorkerKt.KEY_CALL_ID;
 import static ch.threema.app.voip.services.CallRejectWorkerKt.KEY_CONTACT_IDENTITY;
@@ -187,6 +187,7 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 
 	// Pending intents
 	private @Nullable PendingIntent acceptIntent;
+	private final @Nullable PendingIntent mediaButtonPendingIntent;
 
 	// Connection status
 	private boolean connectionAcquired = false;
@@ -224,6 +225,10 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 		this.notificationManagerCompat = NotificationManagerCompat.from(appContext);
 		this.notificationManager = (NotificationManager) appContext.getSystemService(Context.NOTIFICATION_SERVICE);
 		this.audioManager = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE);
+
+		Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON);
+		mediaButtonIntent.setClass(appContext, VoipMediaButtonReceiver.class);
+		this.mediaButtonPendingIntent = PendingIntent.getBroadcast(appContext, 0, mediaButtonIntent, PENDING_INTENT_FLAG_MUTABLE);
 	}
 
 	//region Logging
@@ -324,12 +329,12 @@ public class VoipStateService implements AudioManager.OnAudioFocusChangeListener
 
 		// Ensure bluetooth media button receiver is registered when a call starts
 		if (newState.isRinging() || newState.isInitializing()) {
-			audioManager.registerMediaButtonEventReceiver(new ComponentName(appContext, VoipMediaButtonReceiver.class));
+			audioManager.registerMediaButtonEventReceiver(mediaButtonPendingIntent);
 		}
 
 		// Ensure bluetooth media button receiver is deregistered when a call ends
 		if (newState.isDisconnecting() || newState.isIdle()) {
-			audioManager.unregisterMediaButtonEventReceiver(new ComponentName(appContext, VoipMediaButtonReceiver.class));
+			audioManager.unregisterMediaButtonEventReceiver(mediaButtonPendingIntent);
 		}
 
 		long callId = oldState.getCallId();

+ 7 - 12
app/src/main/java/ch/threema/app/voip/viewmodel/GroupCallViewModel.kt

@@ -89,8 +89,8 @@ class GroupCallViewModel(application: Application) : AndroidViewModel(applicatio
 	private val callRunning = MutableLiveData(false)
 	fun isCallRunning(): LiveData<Boolean> = Transformations.distinctUntilChanged(callRunning)
 
-	private val finishEvents = MutableLiveData<FinishEvent>()
-	fun getFinishEvents(): LiveData<FinishEvent> = finishEvents
+	private val completableFinishEvent = CompletableDeferred<FinishEvent>()
+	val finishEvent: Deferred<FinishEvent> = completableFinishEvent
 
 	private val eglBaseAndParticipants = MutableLiveData<Pair<EglBase, Set<Participant>>>()
 	fun getEglBaseAndParticipants(): LiveData<Pair<EglBase, Set<Participant>>> = eglBaseAndParticipants
@@ -141,11 +141,6 @@ class GroupCallViewModel(application: Application) : AndroidViewModel(applicatio
 		startTime.postValue(call?.getRunningSince())
 	}
 
-	@AnyThread
-	override fun onGroupCallStart(groupModel: GroupModel) {
-		logger.trace("Group call start")
-	}
-
 	@UiThread
 	fun setGroupId(groupId: LocalGroupId) {
 		val previousGroupId = this.groupId.value
@@ -179,7 +174,7 @@ class GroupCallViewModel(application: Application) : AndroidViewModel(applicatio
 			joinJob?.cancel()
 			logger.info("Join call aborted")
 		}
-		finishEvents.postValue(getFinishEvent(FinishEvent.Reason.LEFT))
+		completableFinishEvent.complete(getFinishEvent(FinishEvent.Reason.LEFT))
 		callRunning.postValue(false)
 	}
 
@@ -194,7 +189,7 @@ class GroupCallViewModel(application: Application) : AndroidViewModel(applicatio
 						val controller = joinOrCreateCall(it, intention)
 
 						if (controller == null) {
-							finishEvents.postValue(FinishEvent(FinishEvent.Reason.NO_SUCH_CALL))
+							completableFinishEvent.complete(FinishEvent(FinishEvent.Reason.NO_SUCH_CALL))
 						} else {
 							completeJoining(controller)
 						}
@@ -204,7 +199,7 @@ class GroupCallViewModel(application: Application) : AndroidViewModel(applicatio
 						groupCallManager.abortCurrentCall()
 					} catch (e: Exception) {
 						logger.error("Error while joining call", e)
-						finishEvents.postValue(mapExceptionToFinishEvent(e))
+						completableFinishEvent.complete(mapExceptionToFinishEvent(e))
 						callRunning.postValue(false)
 					}
 				}
@@ -307,12 +302,12 @@ class GroupCallViewModel(application: Application) : AndroidViewModel(applicatio
 	@UiThread
 	private fun observeCallLeftSignal() {
 		viewModelScope.launch {
-			finishEvents.value = try {
+			completableFinishEvent.complete(try {
 				callController.callLeftSignal.await()
 				getFinishEvent(FinishEvent.Reason.LEFT)
 			} catch (e: Exception) {
 				mapExceptionToFinishEvent(e)
-			}
+			})
 			callRunning.value = false
 		}
 	}

+ 2 - 2
app/src/main/java/ch/threema/app/webclient/services/instance/message/receiver/TextMessageCreateHandler.java

@@ -150,7 +150,7 @@ public class TextMessageCreateHandler extends MessageCreateHandler {
 									ListenerManager.serverMessageListeners.handle(new ListenerManager.HandleListener<ServerMessageListener>() {
 										@Override
 										public void handle(ServerMessageListener listener) {
-											listener.onError(new ServerMessageModel(alertMessage, ServerMessageModel.Type.ERROR));
+											listener.onError(new ServerMessageModel(alertMessage, ServerMessageModel.TYPE_ERROR));
 										}
 									});
 									break;
@@ -164,7 +164,7 @@ public class TextMessageCreateHandler extends MessageCreateHandler {
 									ListenerManager.serverMessageListeners.handle(new ListenerManager.HandleListener<ServerMessageListener>() {
 										@Override
 										public void handle(ServerMessageListener listener) {
-											listener.onError(new ServerMessageModel(alertMessage, ServerMessageModel.Type.ALERT));
+											listener.onError(new ServerMessageModel(alertMessage, ServerMessageModel.TYPE_ALERT));
 										}
 									});
 									break;

+ 11 - 5
app/src/main/java/ch/threema/app/workers/ThreemaSafeUploadWorker.kt

@@ -30,7 +30,6 @@ import ch.threema.app.managers.ListenerManager
 import ch.threema.app.managers.ServiceManager
 import ch.threema.app.services.PreferenceService
 import ch.threema.app.threemasafe.ThreemaSafeService
-import ch.threema.base.ThreemaException
 import ch.threema.base.utils.LoggingUtil
 import java.util.*
 import java.util.concurrent.TimeUnit
@@ -83,7 +82,13 @@ class ThreemaSafeUploadWorker(context: Context, workerParameters: WorkerParamete
 
         try {
             threemaSafeService.createBackup(forceUpdate)
-        } catch (e: ThreemaException) {
+            // When the backup has been successfully uploaded or does not need to be uploaded, then
+            // we ignore previous errors.
+            preferenceService.threemaSafeErrorDate = null
+        } catch (e: ThreemaSafeService.ThreemaSafeUploadException) {
+            if (preferenceService.threemaSafeErrorDate == null && e.isUploadNeeded) {
+                preferenceService.threemaSafeErrorDate = Date()
+            }
             showWarningNotification()
             logger.error("Threema Safe upload failed", e)
             success = false
@@ -101,11 +106,12 @@ class ThreemaSafeUploadWorker(context: Context, workerParameters: WorkerParamete
     }
 
     private fun showWarningNotification() {
-        val backupDate = preferenceService!!.threemaSafeBackupDate
+        val errorDate = preferenceService!!.threemaSafeErrorDate
         val aWeekAgo = Date(System.currentTimeMillis() - DateUtils.WEEK_IN_MILLIS)
-        if (backupDate != null && backupDate.before(aWeekAgo)) {
+        if (errorDate != null && errorDate.before(aWeekAgo)) {
+            val lastBackupDate = preferenceService.threemaSafeBackupDate
             val notificationService = serviceManager!!.notificationService
-            notificationService?.showSafeBackupFailed(((System.currentTimeMillis() - backupDate.time) / DateUtils.DAY_IN_MILLIS).toInt())
+            notificationService?.showSafeBackupFailed(((System.currentTimeMillis() - lastBackupDate.time) / DateUtils.DAY_IN_MILLIS).toInt())
         }
     }
 }

+ 34 - 0
app/src/main/java/ch/threema/app/workers/WorkSyncWorker.kt

@@ -25,6 +25,7 @@ import android.content.Context
 import android.content.SharedPreferences.Editor
 import android.widget.Toast
 import androidx.annotation.StringRes
+import androidx.appcompat.app.AppCompatActivity
 import androidx.core.app.NotificationCompat
 import androidx.preference.PreferenceManager
 import androidx.work.*
@@ -99,6 +100,39 @@ class WorkSyncWorker(private val context: Context, workerParameters: WorkerParam
                 .apply { setInputData(data) }
                 .build()
         }
+
+        /**
+         * Start a one time work sync request.
+         *
+         * @param onSuccess is run when the work sync request was successful
+         * @param onFail    is run when the work sync request was unsuccessful
+         */
+        fun performOneTimeWorkSync(
+            activity: AppCompatActivity,
+            onSuccess: Runnable,
+            onFail: Runnable
+        ) {
+            val workerTag = "OneTimeWorkSyncWorker"
+            val workRequest = buildOneTimeWorkRequest(
+                refreshRestrictionsOnly = false,
+                forceUpdate = true,
+                tag = workerTag
+            )
+            val workManager = WorkManager.getInstance(ThreemaApplication.getAppContext())
+            workManager.getWorkInfoByIdLiveData(workRequest.id)
+                .observe(activity) { workInfo: WorkInfo ->
+                    if (workInfo.state == WorkInfo.State.SUCCEEDED) {
+                        onSuccess.run()
+                    } else if (workInfo.state == WorkInfo.State.FAILED) {
+                        onFail.run()
+                    }
+                }
+            workManager.enqueueUniqueWork(
+                ThreemaApplication.WORKER_WORK_SYNC,
+                ExistingWorkPolicy.REPLACE,
+                workRequest
+            )
+        }
     }
 
     init {

+ 39 - 4
app/src/main/java/ch/threema/storage/DatabaseServiceNew.java

@@ -37,6 +37,7 @@ import org.slf4j.Logger;
 import java.io.File;
 import java.io.IOException;
 
+import androidx.annotation.NonNull;
 import ch.threema.app.exceptions.DatabaseMigrationFailedException;
 import ch.threema.app.exceptions.DatabaseMigrationLockedException;
 import ch.threema.app.services.UpdateSystemService;
@@ -108,6 +109,7 @@ import ch.threema.app.services.systemupdate.SystemUpdateToVersion78;
 import ch.threema.app.services.systemupdate.SystemUpdateToVersion79;
 import ch.threema.app.services.systemupdate.SystemUpdateToVersion8;
 import ch.threema.app.services.systemupdate.SystemUpdateToVersion80;
+import ch.threema.app.services.systemupdate.SystemUpdateToVersion81;
 import ch.threema.app.services.systemupdate.SystemUpdateToVersion9;
 import ch.threema.app.utils.FileUtil;
 import ch.threema.app.utils.RuntimeUtil;
@@ -134,6 +136,7 @@ import ch.threema.storage.factories.IncomingGroupJoinRequestModelFactory;
 import ch.threema.storage.factories.MessageModelFactory;
 import ch.threema.storage.factories.ModelFactory;
 import ch.threema.storage.factories.OutgoingGroupJoinRequestModelFactory;
+import ch.threema.storage.factories.ServerMessageModelFactory;
 import ch.threema.storage.factories.WebClientSessionModelFactory;
 
 public class DatabaseServiceNew extends SQLiteOpenHelper {
@@ -142,7 +145,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 	public static final String DATABASE_NAME = "threema.db";
 	public static final String DATABASE_NAME_V4 = "threema4.db";
 	public static final String DATABASE_BACKUP_EXT = ".backup";
-	private static final int DATABASE_VERSION = SystemUpdateToVersion80.VERSION;
+	private static final int DATABASE_VERSION = SystemUpdateToVersion81.VERSION;
 
 	private final Context context;
 	private final String key;
@@ -169,6 +172,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 	private OutgoingGroupJoinRequestModelFactory outgoingGroupJoinRequestModelFactory;
 	private IncomingGroupJoinRequestModelFactory incomingGroupJoinRequestModelFactory;
 	private GroupCallModelFactory groupCallModelFactory;
+	private ServerMessageModelFactory serverMessageModelFactory;
 
 	public DatabaseServiceNew(final Context context,
 	                          final String databaseKey,
@@ -269,7 +273,8 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 			this.getGroupInviteModelFactory(),
 			this.getIncomingGroupJoinRequestModelFactory(),
 			this.getOutgoingGroupJoinRequestModelFactory(),
-			this.getGroupCallModelFactory()
+			this.getGroupCallModelFactory(),
+			this.getServerMessageModelFactory(),
 		}) {
 			String[] createTableStatement = f.getStatements();
 			if(createTableStatement != null) {
@@ -282,6 +287,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		}
 	}
 
+	@NonNull
 	public ContactModelFactory getContactModelFactory() {
 		if(this.contactModelFactory == null) {
 			this.contactModelFactory = new ContactModelFactory(this);
@@ -289,6 +295,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.contactModelFactory;
 	}
 
+	@NonNull
 	public MessageModelFactory getMessageModelFactory() {
 		if(this.messageModelFactory == null) {
 			this.messageModelFactory = new MessageModelFactory(this);
@@ -296,6 +303,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.messageModelFactory;
 	}
 
+	@NonNull
 	public GroupModelFactory getGroupModelFactory() {
 		if(this.groupModelFactory == null) {
 			this.groupModelFactory = new GroupModelFactory(this);
@@ -303,6 +311,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.groupModelFactory;
 	}
 
+	@NonNull
 	public GroupMemberModelFactory getGroupMemberModelFactory() {
 		if(this.groupMemberModelFactory == null) {
 			this.groupMemberModelFactory = new GroupMemberModelFactory(this);
@@ -310,6 +319,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.groupMemberModelFactory;
 	}
 
+	@NonNull
 	public GroupMessageModelFactory getGroupMessageModelFactory() {
 		if(this.groupMessageModelFactory == null) {
 			this.groupMessageModelFactory = new GroupMessageModelFactory(this);
@@ -317,6 +327,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.groupMessageModelFactory;
 	}
 
+	@NonNull
 	public DistributionListModelFactory getDistributionListModelFactory() {
 		if(this.distributionListModelFactory == null) {
 			this.distributionListModelFactory = new DistributionListModelFactory(this);
@@ -324,6 +335,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.distributionListModelFactory;
 	}
 
+	@NonNull
 	public DistributionListMemberModelFactory getDistributionListMemberModelFactory() {
 		if(this.distributionListMemberModelFactory == null) {
 			this.distributionListMemberModelFactory = new DistributionListMemberModelFactory(this);
@@ -331,6 +343,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.distributionListMemberModelFactory;
 	}
 
+	@NonNull
 	public DistributionListMessageModelFactory getDistributionListMessageModelFactory() {
 		if(this.distributionListMessageModelFactory == null) {
 			this.distributionListMessageModelFactory = new DistributionListMessageModelFactory(this);
@@ -338,6 +351,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.distributionListMessageModelFactory;
 	}
 
+	@NonNull
 	public GroupRequestSyncLogModelFactory getGroupRequestSyncLogModelFactory() {
 		if(this.groupRequestSyncLogModelFactory == null) {
 			this.groupRequestSyncLogModelFactory = new GroupRequestSyncLogModelFactory(this);
@@ -345,6 +359,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.groupRequestSyncLogModelFactory;
 	}
 
+	@NonNull
 	public BallotModelFactory getBallotModelFactory() {
 		if(this.ballotModelFactory == null) {
 			this.ballotModelFactory = new BallotModelFactory(this);
@@ -352,6 +367,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.ballotModelFactory;
 	}
 
+	@NonNull
 	public BallotChoiceModelFactory getBallotChoiceModelFactory() {
 		if (this.ballotChoiceModelFactory == null) {
 			this.ballotChoiceModelFactory = new BallotChoiceModelFactory(this);
@@ -359,7 +375,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.ballotChoiceModelFactory;
 	}
 
-
+	@NonNull
 	public BallotVoteModelFactory getBallotVoteModelFactory() {
 		if(this.ballotVoteModelFactory == null) {
 			this.ballotVoteModelFactory = new BallotVoteModelFactory(this);
@@ -367,7 +383,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.ballotVoteModelFactory;
 	}
 
-
+	@NonNull
 	public IdentityBallotModelFactory getIdentityBallotModelFactory() {
 		if(this.identityBallotModelFactory == null) {
 			this.identityBallotModelFactory = new IdentityBallotModelFactory(this);
@@ -375,6 +391,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.identityBallotModelFactory;
 	}
 
+	@NonNull
 	public GroupBallotModelFactory getGroupBallotModelFactory() {
 		if(this.groupBallotModelFactory == null) {
 			this.groupBallotModelFactory = new GroupBallotModelFactory(this);
@@ -382,6 +399,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.groupBallotModelFactory;
 	}
 
+	@NonNull
 	public GroupMessagePendingMessageIdModelFactory getGroupMessagePendingMessageIdModelFactory() {
 		if(this.groupMessagePendingMessageIdModelFactory == null) {
 			this.groupMessagePendingMessageIdModelFactory = new GroupMessagePendingMessageIdModelFactory(this);
@@ -389,6 +407,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.groupMessagePendingMessageIdModelFactory;
 	}
 
+	@NonNull
 	public WebClientSessionModelFactory getWebClientSessionModelFactory() {
 		if(this.webClientSessionModelFactory == null) {
 			this.webClientSessionModelFactory = new WebClientSessionModelFactory(this);
@@ -396,6 +415,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.webClientSessionModelFactory;
 	}
 
+	@NonNull
 	public ConversationTagFactory getConversationTagFactory() {
 		if(this.conversationTagFactory == null) {
 			this.conversationTagFactory = new ConversationTagFactory(this);
@@ -403,6 +423,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.conversationTagFactory;
 	}
 
+	@NonNull
 	public GroupInviteModelFactory getGroupInviteModelFactory() {
 		if(this.groupInviteModelFactory == null) {
 			this.groupInviteModelFactory = new GroupInviteModelFactory(this);
@@ -410,6 +431,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.groupInviteModelFactory;
 	}
 
+	@NonNull
 	public IncomingGroupJoinRequestModelFactory getIncomingGroupJoinRequestModelFactory() {
 		if (this.incomingGroupJoinRequestModelFactory == null) {
 			this.incomingGroupJoinRequestModelFactory = new IncomingGroupJoinRequestModelFactory(this);
@@ -417,6 +439,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.incomingGroupJoinRequestModelFactory;
 	}
 
+	@NonNull
 	public OutgoingGroupJoinRequestModelFactory getOutgoingGroupJoinRequestModelFactory() {
 		if (this.outgoingGroupJoinRequestModelFactory == null) {
 			this.outgoingGroupJoinRequestModelFactory = new OutgoingGroupJoinRequestModelFactory(this);
@@ -424,6 +447,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.outgoingGroupJoinRequestModelFactory;
 	}
 
+	@NonNull
 	public GroupCallModelFactory getGroupCallModelFactory() {
 		if (this.groupCallModelFactory == null) {
 			this.groupCallModelFactory = new GroupCallModelFactory(this);
@@ -431,6 +455,14 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		return this.groupCallModelFactory;
 	}
 
+	@NonNull
+	public ServerMessageModelFactory getServerMessageModelFactory() {
+		if (this.serverMessageModelFactory == null) {
+			this.serverMessageModelFactory = new ServerMessageModelFactory(this);
+		}
+		return this.serverMessageModelFactory;
+	}
+
 	// Note: Enable this to allow database downgrades.
 	//
 	//@Override
@@ -668,6 +700,9 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		if (oldVersion < SystemUpdateToVersion80.VERSION) {
 			this.updateSystemService.addUpdate(new SystemUpdateToVersion80(sqLiteDatabase));
 		}
+		if (oldVersion < SystemUpdateToVersion81.VERSION) {
+			this.updateSystemService.addUpdate(new SystemUpdateToVersion81(sqLiteDatabase));
+		}
 	}
 
 	public void executeNull() throws SQLiteException {

+ 98 - 0
app/src/main/java/ch/threema/storage/factories/ServerMessageModelFactory.kt

@@ -0,0 +1,98 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2023 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.storage.factories
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteException
+import ch.threema.base.utils.LoggingUtil
+import ch.threema.storage.CursorHelper
+import ch.threema.storage.DatabaseServiceNew
+import ch.threema.storage.models.ServerMessageModel
+import java.sql.SQLException
+
+private val logger = LoggingUtil.getThreemaLogger("ServerMessageModelFactory")
+
+class ServerMessageModelFactory(databaseService: DatabaseServiceNew) :
+    ModelFactory(databaseService, ServerMessageModel.TABLE) {
+
+    fun storeServerMessageModel(serverMessageModel: ServerMessageModel) {
+        val contentValues = ContentValues()
+        contentValues.put(ServerMessageModel.COLUMN_MESSAGE, serverMessageModel.message)
+        contentValues.put(ServerMessageModel.COLUMN_TYPE, serverMessageModel.type)
+        try {
+            databaseService.writableDatabase.insertOrThrow(tableName, null, contentValues)
+        } catch (e: SQLException) {
+            logger.error("Could not store server message", e)
+        }
+    }
+
+    fun popServerMessageModel(): ServerMessageModel? {
+        val cursor = databaseService.readableDatabase.query(
+            ServerMessageModel.TABLE,
+            arrayOf(ServerMessageModel.COLUMN_MESSAGE, ServerMessageModel.COLUMN_TYPE),
+            null,
+            null,
+            null,
+            null,
+            null,
+            "1"
+        )
+        if (cursor != null && cursor.moveToFirst()) {
+            val cursorHelper = CursorHelper(cursor, columnIndexCache)
+            return convertAndDelete(cursorHelper)
+        }
+        return null
+    }
+
+    fun delete(message: String) {
+        databaseService.writableDatabase.delete(
+            ServerMessageModel.TABLE,
+            "${ServerMessageModel.COLUMN_MESSAGE}=?",
+            arrayOf(message)
+        )
+    }
+
+    private fun convertAndDelete(c: CursorHelper): ServerMessageModel? {
+        return try {
+            val message = c.getString(ServerMessageModel.COLUMN_MESSAGE) ?: ""
+            val type = c.getInt(ServerMessageModel.COLUMN_TYPE) ?: -1
+            val messageModel = if (message.isNotBlank() && type >= 0) {
+                ServerMessageModel(message, type)
+            } else {
+                logger.info("Invalid message '{}' or type '{}'", message, type)
+                null
+            }
+            delete(message)
+            messageModel
+        } catch (e: SQLiteException) {
+            logger.error("Could not load server message model", e)
+            null
+        }
+    }
+
+    override fun getStatements(): Array<String> = arrayOf(
+        "CREATE TABLE `${ServerMessageModel.TABLE}` (" +
+                "`${ServerMessageModel.COLUMN_MESSAGE}` VARCHAR PRIMARY KEY ON CONFLICT REPLACE," +
+                "`${ServerMessageModel.COLUMN_TYPE}` INTEGER" +
+                ")"
+    )
+}

+ 22 - 6
app/src/main/java/ch/threema/storage/models/ServerMessageModel.java

@@ -21,15 +21,30 @@
 
 package ch.threema.storage.models;
 
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+import androidx.annotation.IntDef;
+
 public class ServerMessageModel {
-	public enum Type {
-		ALERT, ERROR
-	}
+	/** The table name */
+	public static final String TABLE = "server_messages";
+	/** The message as string */
+	public static final String COLUMN_MESSAGE = "message";
+	/** The message type */
+	public static final String COLUMN_TYPE = "type";
+
+	@Retention(RetentionPolicy.SOURCE)
+	@IntDef({TYPE_ALERT, TYPE_ERROR})
+	public @interface ServerMessageModelType {}
+
+	public static final int TYPE_ALERT = 0;
+	public static final int TYPE_ERROR = 1;
 
 	private final String message;
-	private final Type type;
+	private final @ServerMessageModelType int type;
 
-	public ServerMessageModel(String message, Type type) {
+	public ServerMessageModel(String message, @ServerMessageModelType int type) {
 		this.message = message;
 		this.type = type;
 	}
@@ -38,7 +53,8 @@ public class ServerMessageModel {
 		return this.message;
 	}
 
-	public Type getType() {
+	@ServerMessageModelType
+	public int getType() {
 		return this.type;
 	}
 

+ 5 - 0
app/src/main/res/drawable/ic_bring_to_front.xml

@@ -0,0 +1,5 @@
+<vector android:height="24dp" android:tint="#FFFFFF"
+    android:viewportHeight="24" android:viewportWidth="24"
+    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M3,13h2v-2L3,11v2zM3,17h2v-2L3,15v2zM5,21v-2L3,19c0,1.1 0.89,2 2,2zM3,9h2L5,7L3,7v2zM15,21h2v-2h-2v2zM19,3L9,3c-1.11,0 -2,0.9 -2,2v10c0,1.1 0.89,2 2,2h10c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM19,15L9,15L9,5h10v10zM11,21h2v-2h-2v2zM7,21h2v-2L7,19v2z"/>
+</vector>

+ 15 - 14
app/src/main/res/layout/item_message_list.xml

@@ -2,6 +2,7 @@
 <com.google.android.material.card.MaterialCardView
 	xmlns:android="http://schemas.android.com/apk/res/android"
 	xmlns:app="http://schemas.android.com/apk/res-auto"
+	xmlns:tools="http://schemas.android.com/tools"
 	android:id="@+id/list_item"
 	android:layout_width="match_parent"
 	android:layout_height="@dimen/messagelist_item_height"
@@ -65,7 +66,6 @@
 				android:layout_toRightOf="@+id/mute_status"
 				android:ellipsize="end"
 				android:singleLine="true"
-				android:text="Title"
 				android:textAppearance="@style/Threema.TextAppearance.List.FirstLine"/>
 
 			<ImageView
@@ -138,7 +138,6 @@
 					android:layout_marginTop="2sp"
 					android:layout_toLeftOf="@id/delivery"
 					android:singleLine="true"
-					android:text="Date"
 					android:textAppearance="@style/Threema.TextAppearance.List.ThirdLine" />
 
 				<ch.threema.app.emojis.EmojiTextView
@@ -148,7 +147,6 @@
 					android:layout_alignBaseline="@id/date"
 					android:layout_alignWithParentIfMissing="true"
 					android:singleLine="true"
-					android:text="Member: "
 					android:textAppearance="@style/Threema.TextAppearance.List.SecondLine"/>
 
 				<ImageView
@@ -175,7 +173,6 @@
 					android:layout_toRightOf="@id/attachment"
 					android:ellipsize="none"
 					android:singleLine="true"
-					android:text="Subject"
 					android:textAppearance="@style/Threema.TextAppearance.List.SecondLine"/>
 
 			</RelativeLayout>
@@ -187,12 +184,12 @@
 				android:layout_below="@id/from"
 				android:visibility="gone">
 
-				<TextView
-					android:id="@+id/ongoing_call_text"
+				<Chronometer
+					android:id="@+id/group_call_duration"
 					android:layout_width="wrap_content"
 					android:layout_height="wrap_content"
 					android:layout_alignWithParentIfMissing="true"
-					android:text="@string/voip_gc_ongoing_call"
+					android:layout_alignBaseline="@+id/ongoing_call_text"
 					android:maxLines="1"
 					android:textAppearance="@style/Threema.TextAppearance.List.SecondLine.GroupCall" />
 
@@ -200,18 +197,22 @@
 					android:id="@+id/ongoing_call_divider"
 					android:layout_width="wrap_content"
 					android:layout_height="wrap_content"
-					android:layout_alignBaseline="@id/ongoing_call_text"
-					android:layout_toRightOf="@id/ongoing_call_text"
+					android:layout_alignBaseline="@+id/ongoing_call_text"
+					android:layout_toRightOf="@id/group_call_duration"
+					android:layout_marginLeft="2dp"
+					android:layout_marginRight="2dp"
 					android:maxLines="1"
-					android:text=" - "
-					android:textAppearance="@style/Threema.TextAppearance.List.SecondLine.GroupCall" />
+					android:text="|"
+					android:textAppearance="@style/Threema.TextAppearance.List.SecondLine.GroupCall"
+					tools:ignore="HardcodedText" />
 
-				<Chronometer
-					android:id="@+id/group_call_duration"
+				<TextView
+					android:id="@+id/ongoing_call_text"
 					android:layout_width="wrap_content"
 					android:layout_height="wrap_content"
-					android:layout_alignBaseline="@id/ongoing_call_text"
+					android:text="@string/voip_gc_ongoing_call"
 					android:layout_toRightOf="@id/ongoing_call_divider"
+					android:ellipsize="end"
 					android:maxLines="1"
 					android:textAppearance="@style/Threema.TextAppearance.List.SecondLine.GroupCall" />
 

+ 92 - 70
app/src/main/res/layout/popup_paint_selection.xml

@@ -1,82 +1,104 @@
 <?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-              android:layout_width="wrap_content"
-              android:layout_height="wrap_content"
-              android:orientation="horizontal">
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+	android:layout_width="wrap_content"
+	android:layout_height="wrap_content"
+	android:orientation="horizontal"
+	android:background="@android:color/transparent"
+	xmlns:app="http://schemas.android.com/apk/res-auto">
 
-	<FrameLayout
-		android:id="@+id/remove_layout"
-		android:layout_width="wrap_content"
-		android:layout_height="match_parent"
-		android:clickable="true"
-		android:focusable="false"
-		android:background="@drawable/listitem_background_selector_noripple"
-		android:paddingLeft="16dp"
-		android:paddingRight="16dp"
-		android:paddingTop="8dp"
-		android:paddingBottom="8dp">
-
-	<TextView
-		android:id="@+id/remove"
+	<androidx.cardview.widget.CardView
 		android:layout_width="wrap_content"
 		android:layout_height="wrap_content"
-		android:layout_gravity="center_vertical"
-		android:clickable="false"
-		android:text="@string/remove"
-		android:textAllCaps="true"
-		android:textSize="14sp"
-		android:textStyle="bold"/>
+		android:layout_marginBottom="@dimen/emoji_popup_cardview_margin_bottom"
+		android:layout_marginTop="@dimen/emoji_popup_cardview_margin_top"
+		android:layout_marginLeft="@dimen/emoji_popup_cardview_margin_horizontal"
+		android:layout_marginRight="@dimen/emoji_popup_cardview_margin_horizontal"
+		app:cardCornerRadius="6dp"
+		app:cardElevation="4dp">
 
-	</FrameLayout>
+		<LinearLayout
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:layout_margin="8dp"
+			android:orientation="horizontal">
 
-	<FrameLayout
-		android:id="@+id/flip_layout"
-		android:layout_width="wrap_content"
-		android:layout_height="match_parent"
-		android:clickable="true"
-		android:focusable="false"
-		android:background="@drawable/listitem_background_selector_noripple"
-		android:paddingLeft="16dp"
-		android:paddingRight="16dp"
-		android:paddingTop="8dp"
-		android:paddingBottom="8dp">
+			<ImageView
+				android:id="@+id/remove_paint"
+				android:layout_width="@dimen/ackdec_emoji_size"
+				android:layout_height="@dimen/ackdec_emoji_size"
+				android:padding="@dimen/ackdec_popup_content_margin"
+				android:src="@drawable/ic_delete"
+				android:contentDescription="@string/delete"
+				android:background="?attr/selectableItemBackgroundBorderless"
+				android:clickable="true"
+				android:focusable="true"
+				app:tint="?attr/textColorSecondary" />
 
-	<TextView
-		android:id="@+id/flip"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		android:layout_gravity="center_vertical"
-		android:clickable="false"
-		android:text="@string/flip"
-		android:textAllCaps="true"
-		android:textSize="14sp"
-		android:textStyle="bold"/>
+			<View
+				android:id="@+id/flip_separator"
+				android:layout_width="1dp"
+				android:layout_height="match_parent"
+				android:layout_marginLeft="@dimen/ackdec_popup_content_margin"
+				android:layout_marginRight="@dimen/ackdec_popup_content_margin"
+				android:background="?divider_color"
+				/>
 
-	</FrameLayout>
+			<ImageView
+				android:id="@+id/flip_paint"
+				android:layout_width="@dimen/ackdec_emoji_size"
+				android:layout_height="@dimen/ackdec_emoji_size"
+				android:padding="@dimen/ackdec_popup_content_margin"
+				android:src="@drawable/ic_flip_outline"
+				android:contentDescription="@string/flip"
+				android:background="?attr/selectableItemBackgroundBorderless"
+				android:clickable="true"
+				android:focusable="true"
+				app:tint="?attr/textColorSecondary" />
 
-	<FrameLayout
-		android:id="@+id/tofront_layout"
-		android:layout_width="wrap_content"
-		android:layout_height="match_parent"
-		android:clickable="true"
-		android:focusable="false"
-		android:background="@drawable/listitem_background_selector_noripple"
-		android:paddingLeft="16dp"
-		android:paddingRight="16dp"
-		android:paddingTop="8dp"
-		android:paddingBottom="8dp">
+			<View
+				android:id="@+id/bring_to_front_separator"
+				android:layout_width="1dp"
+				android:layout_height="match_parent"
+				android:layout_marginLeft="@dimen/ackdec_popup_content_margin"
+				android:layout_marginRight="@dimen/ackdec_popup_content_margin"
+				android:background="?divider_color"
+				/>
 
-		<TextView
-			android:id="@+id/tofront"
-			android:layout_width="wrap_content"
-			android:layout_height="wrap_content"
-			android:layout_gravity="center_vertical"
-			android:clickable="false"
-			android:text="@string/to_front"
-			android:textAllCaps="true"
-			android:textSize="14sp"
-			android:textStyle="bold"/>
+			<ImageView
+				android:id="@+id/bring_to_front_paint"
+				android:layout_width="@dimen/ackdec_emoji_size"
+				android:layout_height="@dimen/ackdec_emoji_size"
+				android:padding="@dimen/ackdec_popup_content_margin"
+				android:src="@drawable/ic_bring_to_front"
+				android:contentDescription="@string/to_front"
+				android:background="?attr/selectableItemBackgroundBorderless"
+				android:clickable="true"
+				android:focusable="true"
+				app:tint="?attr/textColorSecondary" />
+
+			<View
+				android:id="@+id/color_separator"
+				android:layout_width="1dp"
+				android:layout_height="match_parent"
+				android:layout_marginLeft="@dimen/ackdec_popup_content_margin"
+				android:layout_marginRight="@dimen/ackdec_popup_content_margin"
+				android:background="?divider_color"
+				/>
+
+			<ImageView
+				android:id="@+id/color_paint"
+				android:layout_width="@dimen/ackdec_emoji_size"
+				android:layout_height="@dimen/ackdec_emoji_size"
+				android:padding="@dimen/ackdec_popup_content_margin"
+				android:src="@drawable/ic_color_lens_24px"
+				android:contentDescription="@string/palette"
+				android:background="?attr/selectableItemBackgroundBorderless"
+				android:clickable="true"
+				android:focusable="true"
+				app:tint="?attr/textColorSecondary" />
+
+		</LinearLayout>
 
-	</FrameLayout>
+	</androidx.cardview.widget.CardView>
 
-</LinearLayout>
+</FrameLayout>

+ 24 - 7
app/src/main/res/layout/view_ongoing_call_notice.xml

@@ -1,5 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
-<merge xmlns:android="http://schemas.android.com/apk/res/android">
+<merge xmlns:tools="http://schemas.android.com/tools"
+	xmlns:android="http://schemas.android.com/apk/res/android">
 
 	<RelativeLayout
 		android:id="@+id/button_layout"
@@ -38,24 +39,40 @@
 				android:ellipsize="end"
 				android:maxLines="1"/>
 
-			<TextView
-				android:id="@+id/participants_count"
+
+			<Chronometer
+				android:id="@+id/call_duration"
 				android:layout_width="wrap_content"
 				android:layout_height="wrap_content"
 				android:layout_alignParentLeft="true"
 				android:layout_below="@+id/call_text"
-				android:layout_marginRight="8dp"
 				android:maxLines="1"
 				android:textColor="?attr/textColorSecondary"
 				android:textSize="12sp" />
 
-			<Chronometer
-				android:id="@+id/call_duration"
+			<TextView
+				android:id="@+id/ongoing_call_divider"
 				android:layout_width="wrap_content"
 				android:layout_height="wrap_content"
-				android:layout_toRightOf="@+id/participants_count"
+				android:layout_toRightOf="@id/call_duration"
 				android:layout_alignWithParentIfMissing="true"
 				android:layout_below="@+id/call_text"
+				android:layout_marginLeft="2dp"
+				android:layout_marginRight="2dp"
+				android:text="|"
+				android:maxLines="1"
+				android:textColor="?attr/textColorSecondary"
+				android:textSize="12sp"
+				android:visibility="gone"
+				tools:ignore="HardcodedText" />
+
+			<TextView
+				android:id="@+id/participants_count"
+				android:layout_width="wrap_content"
+				android:layout_height="wrap_content"
+				android:layout_below="@+id/call_text"
+				android:layout_toRightOf="@+id/ongoing_call_divider"
+				android:layout_alignWithParentIfMissing="true"
 				android:maxLines="1"
 				android:textColor="?attr/textColorSecondary"
 				android:textSize="12sp" />

+ 1 - 1
app/src/main/res/menu/action_compose_message.xml

@@ -26,7 +26,7 @@
 		android:orderInCategory="20"
 		android:title="@string/copy_message_action"
 		app:iconTint="?attr/colorAccent"
-		app:showAsAction="ifRoom"/>
+		app:showAsAction="always"/>
 	<item
 		android:id="@+id/menu_message_save"
 		android:icon="@drawable/ic_save_outline"

+ 100 - 1
app/src/main/res/values-be-rBY/strings.xml

@@ -498,6 +498,7 @@
     <string name="really_remove_wallpapers">Вы сапраўды хочаце выдаліць усе шпалеры?</string>
     <string name="wallpapers_removed">Шпалеры выдалены</string>
     <string name="invalid_backup">Няправільныя даныя рэз. копіі. Не ўдалося аднавіць.</string>
+    <string name="invalid_zip_restore_failed">Аднавіць не атрымалася.Несапраўдны файл рэзервовай копіі: %1$s</string>
     <string name="revocation_explain">Пароль, які вы ўвядзеце тут, дазваляе ануляваць ваш ID на https://myid.threema.ch/revoke у выпадку, калі вы страцілі свой тэлефон ці ён быў скрадзены</string>
     <string name="no_unread_messages">Няма непрачыт. паведамленняў або актывавана блакіроўка PIN</string>
     <string name="send_media">Адпр. мультымед</string>
@@ -1010,6 +1011,11 @@
     <string name="open_in_maps_app">Адк-ць у праграме \"Мапы\"</string>
     <string name="delete">Выдаліць</string>
     <string name="continue_recording">Працягнуць запіс</string>
+    <string name="whatsnew_title">Навінка: супольныя званкі %1$s</string>
+    <string name="whatsnew_headline"><![CDATA[<p>Супольныя званкі са скразным шыфраваннем цяпер даступныя ў %1$s для Android.</p>
+ <p>Каб пачаць званок, адкрыйце супольны чат і краніце значок камеры ўверсе.</p>
+ <p>Усе ўдзельнікі суполкі атрымаюць апавяшчэнне і змогуць самі вырашыць, ці жадаюць яны далучыцца да званка.</p>
+ <p><b>Звярніце ўвагу:</b> для iOS-дадатку функцыя супольнага званка будзе рэалізавана пазней.</p>]]></string>
     <string name="tap_to_start">Націсьніце тут, каб запусціць %s.</string>
     <string name="two_years">2 года</string>
     <string name="invalid_backup_path">Нядзейнічае.шлях да рэз.копіі</string>
@@ -1061,7 +1067,7 @@
     <string name="work_life_dnd_active">Цяпер непрацоўны час</string>
     <string name="pencil">Аловак</string>
     <string name="warning">Увага!</string>
-    <string name="password_remember_warning">Запомніце, што вы ўводзіце тут! Паколькі %s не захоўвае паролі на сэрверах, мы не зможам дапамагчы вам, калі вы забудзецеся свой PIN-код або парольную фразу.</string>
+    <string name="password_remember_warning">Запомніце, што вы тут уводзіце!  Паколькі %s не захоўвае ніякіх пароляў на серверах, мы не можам дапамагчы вам, калі вы забудзеце PIN-код або пароль.</string>
     <string name="safe_backup_tap_to_restart">Націсніце на апавяшчэнне, каб перазапусціць праграму зараз.</string>
     <string name="send_to_support">Адправіць у службу падтрымкі Threema</string>
     <string name="menu_legal">Юрыд. інф.</string>
@@ -1416,6 +1422,9 @@
     <string name="forward_security_mode_4dh">Поўны</string>
     <string name="forward_security_mode_2dh">Аднабаковы</string>
     <string name="forward_security_explanation">Perfect Forward Secrecy (PFS) абараняе запісанае паведамленне ад дэшыфравання заднім лікам, нават калі доўгатэрміновы ключ шыфравання скампраметаваны.\n\nОпцыю можна ўключыць, калі праграма з абодвух бакоў падтрымлівае PFS.</string>
+    <string name="group_call_inactivity_left">Суполкавы выклік пакінуты з-за бяздзейнасці</string>
+    <string name="missing_permission_external_storage">Немагчыма скапіяваць файл. Паспрабуйце наступныя крокі:\n 1. Адкрыйце праграму налад.\n 2. Перайдзіце ў раздзел «Праграмы» і абярыце «Доступ да спецыяльных праграм».\n 3. Націсніце на «Доступ да ўсіх файлаў».\n 4. Націсніце на тры кропкі ў правым верхнім куце і абярыце «Паказаць сістэму».\n 5. Націсніце на «Знешняе сховішча» і пераканайцеся, што «Дазволены доступ для кіравання ўсімі файламі».\n 6. Паспрабуйце яшчэ раз аднавіць рэзервовую копію.</string>
+    <string name="forward_security_downgraded_status_message">Гэты кантакт пачаў выкарыстоўваць версію праграмы, якая не падтрымлівае Perfect Forward Secrecy.  З гэтага часу паведамленні будуць адпраўляцца без PFS.</string>
     <plurals name="contacts_counter_label">
         <item quantity="few">%d кантактаў</item>
         <item quantity="many">%d кантакты</item>
@@ -1446,6 +1455,96 @@
         <item quantity="few">Зыходная мова не ўтрымлівае гэтай формы множнага ліку</item>
         <item quantity="many">Зыходная мова не ўтрымлівае гэтай формы множнага ліку</item>
     </plurals>
+    <plurals name="new_messages">
+        <item quantity="few">%d новых паведамленняў</item>
+        <item quantity="many">%d новых паведамленняў</item>
+        <item quantity="one">%d новае паведамленне</item>
+        <item quantity="other">%d новых паведамленняў</item>
+    </plurals>
+    <plurals name="file_saved">
+        <item quantity="few">%d файлы паспяхова захаваны.</item>
+        <item quantity="many">%d файлы паспяхова захаваны.</item>
+        <item quantity="one">%d файл паспяхова захаваны.</item>
+        <item quantity="other">%d файлы паспяхова захаваны.</item>
+    </plurals>
+    <plurals name="ballot_really_delete">
+        <item quantity="few">Выдаліць апытанні</item>
+        <item quantity="many">Выдаліць апытанні</item>
+        <item quantity="one" tools:ignore="ImpliedQuantity">Выдаліць апытанне</item>
+        <item quantity="other">Выдаліць апытанні</item>
+    </plurals>
+    <plurals name="ballot_really_delete_text">
+        <item quantity="few">Вы сапраўды хочаце выдаліць %1$d апытанні? Вы не зможаце аднавіць галасы.</item>
+        <item quantity="many">Вы сапраўды хочаце выдаліць %1$d апытанні? Вы не зможаце аднавіць галасы.</item>
+        <item quantity="one">Вы сапраўды хочаце выдаліць %1$d апытанне? Вы не зможаце аднавіць галасы.</item>
+        <item quantity="other">Вы сапраўды хочаце выдаліць %1$d апытанні? Вы не зможаце аднавіць галасы.</item>
+    </plurals>
+    <plurals name="message_deleted">
+        <item quantity="few">%d паведамленні выдалены</item>
+        <item quantity="many">%d паведамленні выдалены</item>
+        <item quantity="one">%d паведамленне выдалена</item>
+        <item quantity="other">%d паведамленні выдалены</item>
+    </plurals>
+    <plurals name="notifications_for_x_hours">
+        <item quantity="few">На працягу %d гадзін</item>
+        <item quantity="many">На працягу %d гадзін</item>
+        <item quantity="one">На %d гадзіну</item>
+        <item quantity="other">На працягу %d гадзін</item>
+    </plurals>
+    <plurals name="some_contacts_not_deleted">
+        <item quantity="few">Немагчыма выдаліць %d кантакты, таму што яны ўсё яшчэ ўваходзяць у суполку.</item>
+        <item quantity="many">Немагчыма выдаліць %d кантакты, таму што яны ўсё яшчэ ўваходзяць у суполку.</item>
+        <item quantity="one">%d кантакт не можа быць выдалены, таму што ён усё яшчэ ўваходзіць у суполку.</item>
+        <item quantity="other">Немагчыма выдаліць %d кантакты, таму што яны ўсё яшчэ ўваходзяць у суполку.</item>
+    </plurals>
+    <plurals name="message_archived">
+        <item quantity="few">%d чатаў заархівавана</item>
+        <item quantity="many">%d чатаў заархівавана</item>
+        <item quantity="one">%d чат заархіваваны</item>
+        <item quantity="other">%d чатаў заархівавана</item>
+    </plurals>
+    <plurals name="webclient_running_sessions">
+        <item quantity="few">%d сеансаў працуе</item>
+        <item quantity="many">%d сеансаў працуе</item>
+        <item quantity="one">%d сеанс працуе</item>
+        <item quantity="other">%d сеансаў працуе</item>
+    </plurals>
+    <plurals name="really_delete_outgoing_request">
+        <item quantity="few">Мала</item>
+        <item quantity="many">Многа</item>
+        <item quantity="one" tools:ignore="ImpliedQuantity">Адзін</item>
+        <item quantity="other">Іншае</item>
+    </plurals>
+    <plurals name="really_delete_incoming_request">
+        <item quantity="few">Вы сапраўды хочаце выдаліць %d супольныя запыты?
+ Вы не можаце адказваць на запыты пасля выдалення.</item>
+        <item quantity="many">Вы сапраўды хочаце выдаліць %d супольныя запыты?
+ Вы не можаце адказваць на запыты пасля выдалення.</item>
+        <item quantity="one">Вы сапраўды хочаце выдаліць %d супольны запыт?
+ Вы не можаце адказваць на запыты пасля выдалення.</item>
+        <item quantity="other">Вы сапраўды хочаце выдаліць %d супольныя запыты?
+ Вы не можаце адказваць на запыты пасля выдалення.</item>
+    </plurals>
+    <plurals name="really_delete_group_link">
+        <item quantity="few">Вы сапраўды жадаеце выдаліць %d спасылкі на суполку?
+ Людзі больш не змогуць праз яго адпраўляць запыты.
+ Раней атрыманыя запыты ўсё яшчэ могуць быць прыняты або адхілены.</item>
+        <item quantity="many">Вы сапраўды жадаеце выдаліць %d спасылкі на суполку?
+ Людзі больш не змогуць праз яго адпраўляць запыты.
+ Раней атрыманыя запыты ўсё яшчэ могуць быць прыняты або адхілены.</item>
+        <item quantity="one">Вы сапраўды хочаце выдаліць %d спасылку на суполку?
+ Людзі больш не змогуць праз яго адпраўляць запыты.
+ Раней атрыманыя запыты ўсё яшчэ могуць быць прыняты або адхілены.</item>
+        <item quantity="other">Вы сапраўды жадаеце выдаліць %d спасылкі на суполку?
+ Людзі больш не змогуць праз яго адпраўляць запыты.
+ Раней атрыманыя запыты ўсё яшчэ могуць быць прыняты або адхілены.</item>
+    </plurals>
+    <plurals name="chat_deleted">
+        <item quantity="few">%d чатаў выдалена.</item>
+        <item quantity="many">%d чаты выдалены.</item>
+        <item quantity="one">%d чат выдалены.</item>
+        <item quantity="other">%d чатаў выдалена.</item>
+    </plurals>
     <plurals name="forward_security_messages_skipped">
         <item quantity="few">%d паведамленняў было страчана пасля апошняга паведамлення.</item>
         <item quantity="many">%d паведамленняў было страчана пасля апошняга паведамлення.</item>

+ 2 - 0
app/src/main/res/values-be-rBY/voip_strings.xml

@@ -90,6 +90,8 @@
     <string name="voip_gc_in_call">У супольным выкліку</string>
     <string name="voip_gc_sfu_not_available">Сервер недаступны, паўтарыце спробу пазней</string>
     <string name="voip_gc_call_error">Не атрымалася пачаць супольны выклік</string>
+    <string name="voip_gc_call_start_error">Не атрымалася пачаць супольны выклік</string>
+    <string name="voip_gc_call_already_ended">Супольны выклік ужо скончыўся</string>
     <string name="voip_gc_call_full_generic">Дасягнуты максімум: больш удзельнікаў не можа далучыцца да гэтага супольнага выкліку.</string>
     <string name="voip_gc_call_full_n">Дасягнута максімальная колькасць удзельнікаў: %1$d, вы не можаце далучыцца да гэтага супольнага выкліку.</string>
     <plurals name="n_participants_in_call">

+ 1 - 0
app/src/main/res/values-be-rBY/webclient_strings.xml

@@ -33,6 +33,7 @@
     <string name="webclient_cannot_start">Не ўдаецца запусціць сесію</string>
     <string name="webclient_constrained_by_mdm">Сэрвер не зацверджаны адміністратарам.</string>
     <string name="webclient_clear_all_sessions">Ачысціць усе сесіі</string>
+    <string name="webclient_clear_all_sessions_confirm">Вы ўпэўнены, што хочаце спыніць і выдаліць усе сеансы?</string>
     <string name="webclient_prefs_debug_tool_summary">Запусціце гэты сродак для адладкі непаладак з настройкай злучэння з праграмай для ПК або вэб-кліентам</string>
     <string name="webclient_diagnostics">Дыягностыка праграмы для ПК/вэб-кл.</string>
     <string name="webclient_diagnostics_start">Пуск</string>

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

@@ -520,6 +520,7 @@ automaticamente in caso di inattività dopo un intervallo predefinito (solo cara
     <string name="really_remove_wallpapers">Vuoi davvero rimuovere tutte le immagini di sfondo?</string>
     <string name="wallpapers_removed">Immagini di sfondo rimosse</string>
     <string name="invalid_backup">I dati del backup Android non sono validi. Impossibile procedere al ripristino.</string>
+    <string name="invalid_zip_restore_failed">Ripristino non riuscito. File di backup non valido: %1$s</string>
     <string name="revocation_explain">La password qui inserita ti permetterà di revocare il tuo ID all\'indirizzo https://myid.threema.ch/revoke in caso di smarrimento o furto della password stessa</string>
     <string name="no_unread_messages">Non sono presenti messaggi non letti o il blocco PIN è attivo</string>
     <string name="send_media">Invia media</string>

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

@@ -483,7 +483,7 @@ http://www.7-zip.org または https://itunes.apple.com/us/app/the-unarchiver/id
     <string name="wearable_reply">返信</string>
     <string name="wearable_reply_label">%s に返信</string>
     <string name="message_acknowledged">«賛成» 送信</string>
-    <string name="push_disable_text">続行すると、システムのプッシュサービスの代わりに、「Threemaプッシュ」が使用されます。これはデジタルフットプリントを減らすのに役に立ちますが、アプリをバックグラウンドで実行できるようにデバイスを設定する必要があります。デバイスの設定で対応するオプションが見つからない場合、デバイスの製造元のサポートと連絡を取り、設定のサポートを受けてください。</string>
+    <string name="push_disable_text">続行すると、システムのプッシュサービスの代わりに、「Threemaプッシュ」が使用されます。これはデジタルフットプリントを減らすのに役に立ちますが、アプリをバックグラウンドで実行できるように端末を設定する必要があります。端末の設定で対応するオプションが見つからない場合、端末の製造元のサポートと連絡を取り、設定のサポートを受けてください。</string>
     <string name="ballot_intermediate_results_show">途中経過を表示</string>
     <string name="converting_video">動画を処理中</string>
     <string name="video_size_small">高 (省データ)</string>
@@ -1477,6 +1477,9 @@ https://myid.threema.ch/revoke に入力することで ID を削除すること
     <plurals name="some_contacts_not_deleted">
         <item quantity="other">%d 件の連絡先は、まだグループに属しているため、削除できませんでした。</item>
     </plurals>
+    <plurals name="message_archived">
+        <item quantity="other">%d 件のトークを受け取りました</item>
+    </plurals>
     <plurals name="webclient_running_sessions">
         <item quantity="other">%d セッションを実行中</item>
     </plurals>
@@ -1489,6 +1492,9 @@ https://myid.threema.ch/revoke に入力することで ID を削除すること
 今後、このリンクを介した参加申請は送信できなくなります。
 ただし、以前に受信した参加申請は、引き続き承認/拒否できます。</item>
     </plurals>
+    <plurals name="chat_deleted">
+        <item quantity="other">%d 件のトークを削除しました</item>
+    </plurals>
     <plurals name="num_archived_chats">
         <item quantity="other">%d 件のアーカイブされたトーク</item>
     </plurals>

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

@@ -123,6 +123,7 @@
 	<string name="preferences__threema_safe_server_password" translatable="false">pref_threema_safe_server_password</string>
 	<string name="preferences__threema_safe_backup_date" translatable="false">pref_threema_safe_backup_date</string>
 	<string name="preferences__threema_safe_error_code" translatable="false">pref_threema_safe_error_code</string>
+	<string name="preferences__threema_safe_create_error_date" translatable="false">pref_threema_safe_error_date</string>
 	<string name="preferences__threema_safe_enabled" translatable="false">pref_threema_safe_enabled</string>
 	<string name="preferences__background_data" translatable="false">pref_background_data</string>
 	<string name="preferences__threema_safe_server_retention" translatable="false">pref_safe_server_retention</string>
@@ -192,6 +193,7 @@
 	<string name="preferences__group_calls_enable" translatable="false">pref_group_calls_enable</string>
 	<string name="preferences__tooltip_gc_camera" translatable="false">pref_tooltip_gc_camera</string>
 	<string name="preferences__group_call_send_init" translatable="false">pref_gc_send_init</string>
+	<string name="preferences__group_call_skip_delay" translatable="false">pref_gc_skip_delay</string>
 	<string name="preferences__group_calls_tooltip_shown" translatable="false">pref_gc_tooltip_shown</string>
 	<string name="preferences__group_calls_ringtone" translatable="false">pref_gc_ringtone</string>
 	<string name="preferences__group_calls_vibration" translatable="false">pref_gc_vibration</string>

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

@@ -33,6 +33,10 @@
 			android:key="@string/preferences__group_call_send_init"
 			android:title="Send group call init"
 			android:summary="If enabled, a text message containing encoded call info will be sent in the group when a call is started." />
+		<CheckBoxPreference
+			android:key="@string/preferences__group_call_skip_delay"
+			android:title="Skip group call start delay"
+			android:summary="If enabled the artificial delay for group call creation will be skiped and the call created immediately" />
 	</PreferenceCategory>
 	<PreferenceCategory
 		android:key="pref_key_various"

+ 7 - 0
app/src/onprem/res/xml/app_restrictions.xml

@@ -292,4 +292,11 @@
 		android:restrictionType="bool"
 		android:title="@string/restriction_disable_work_directory"/>
 
+	<restriction
+		android:defaultValue="false"
+		android:description="@string/restriction_disable_group_calls_desc"
+		android:key="th_disable_group_calls"
+		android:restrictionType="bool"
+		android:title="@string/restriction_disable_group_calls"/>
+
 </restrictions>

+ 7 - 0
app/src/red/res/xml/app_restrictions.xml

@@ -271,4 +271,11 @@
 		android:restrictionType="string"
 		android:title="@string/restriction_safe_password_message"/>
 
+	<restriction
+		android:defaultValue="false"
+		android:description="@string/restriction_disable_group_calls_desc"
+		android:key="th_disable_group_calls"
+		android:restrictionType="bool"
+		android:title="@string/restriction_disable_group_calls"/>
+
 </restrictions>

+ 3 - 3
app/src/test/java/ch/threema/architecture/StorageLayerTest.java

@@ -36,9 +36,7 @@ import ch.threema.storage.models.data.MessageDataInterface;
 import ch.threema.storage.models.data.media.MediaMessageDataInterface;
 
 import static ch.threema.architecture.ArchitectureDefinitions.PACKAGE_STORAGE;
-import static ch.threema.architecture.ArchitectureDefinitions.getLayeredArchitecture;
 import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
-import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
 
 @RunWith(ArchUnitRunner.class)
 @AnalyzeClasses(packages = PACKAGE_STORAGE, importOptions = { ArchitectureTestUtils.DoNotIncludeAndroidTests.class })
@@ -49,7 +47,8 @@ public class StorageLayerTest {
 			.and().areNotAnonymousClasses()
 			.and().areNotInnerClasses()
 			.and().areNotNestedClasses()
-			.should().haveSimpleNameEndingWith("Factory");
+			.should().haveSimpleNameEndingWith("Factory")
+			.orShould().haveSimpleNameEndingWith("FactoryKt");
 
 	@ArchTest
 	public static final ArchRule factoriesExtendModelFactory =
@@ -57,6 +56,7 @@ public class StorageLayerTest {
 			.and().areNotAnonymousClasses()
 			.and().areNotInnerClasses()
 			.and().areNotNestedClasses()
+			.and().haveSimpleNameEndingWith("Factory") // This excludes kotlin classes as they cannot be checked to be assignable to a java class
 			.should().beAssignableTo(ModelFactory.class);
 
 	@ArchTest

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно