瀏覽代碼

Version 4.6

Threema 4 年之前
父節點
當前提交
0b6543eafe
共有 100 個文件被更改,包括 2839 次插入1457 次删除
  1. 4 4
      README.md
  2. 2 1
      THANKS.md
  3. 200 87
      app/build.gradle
  4. 1 1
      app/proguard-project.txt
  5. 10 4
      app/src/androidTest/java/ch/threema/app/backuprestore/csv/BackupServiceTest.java
  6. 74 0
      app/src/androidTest/java/ch/threema/app/processors/MessageAckProcessorTest.java
  7. 244 0
      app/src/androidTest/java/ch/threema/app/processors/MessageProcessorTest.java
  8. 308 0
      app/src/androidTest/java/ch/threema/app/service/GroupInviteServiceTest.java
  9. 71 0
      app/src/androidTest/java/ch/threema/app/testutils/CaptureLogcatOnTestFailureRule.java
  10. 65 2
      app/src/androidTest/java/ch/threema/app/testutils/TestHelpers.java
  11. 40 0
      app/src/androidTest/java/ch/threema/app/testutils/ThreemaAssert.java
  12. 2 2
      app/src/androidTest/java/ch/threema/app/utils/BackgroundErrorNotificationTest.java
  13. 1 1
      app/src/androidTest/java/ch/threema/app/voip/VoipStatusMessageTest.java
  14. 1 1
      app/src/androidTest/java/ch/threema/app/webclient/converter/MessageTest.java
  15. 1 1
      app/src/google_services_based/java/ch/threema/app/push/PushRegistrationWorker.java
  16. 1 1
      app/src/google_services_based/java/ch/threema/app/push/PushService.java
  17. 1 1
      app/src/google_services_based/java/ch/threema/app/wearable/WearableHandler.java
  18. 1 1
      app/src/hms_services_based/java/ch/threema/app/push/PushRegistrationWorker.java
  19. 1 1
      app/src/hms_services_based/java/ch/threema/app/push/PushService.java
  20. 33 14
      app/src/main/AndroidManifest.xml
  21. 6 29
      app/src/main/java/ch/threema/app/BuildFlavor.java
  22. 1 4
      app/src/main/java/ch/threema/app/NamedFileProvider.java
  23. 0 147
      app/src/main/java/ch/threema/app/RecipientChooserTargetService.java
  24. 105 53
      app/src/main/java/ch/threema/app/ThreemaApplication.java
  25. 19 6
      app/src/main/java/ch/threema/app/actions/LocationMessageSendAction.java
  26. 12 5
      app/src/main/java/ch/threema/app/actions/TextMessageSendAction.java
  27. 37 94
      app/src/main/java/ch/threema/app/activities/AddContactActivity.java
  28. 45 35
      app/src/main/java/ch/threema/app/activities/AppLinksActivity.java
  29. 6 1
      app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java
  30. 8 14
      app/src/main/java/ch/threema/app/activities/DirectoryActivity.java
  31. 135 63
      app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java
  32. 2 2
      app/src/main/java/ch/threema/app/activities/ExportIDActivity.java
  33. 1 6
      app/src/main/java/ch/threema/app/activities/ExportIDResultActivity.java
  34. 3 2
      app/src/main/java/ch/threema/app/activities/GroupAddActivity.java
  35. 155 109
      app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java
  36. 38 9
      app/src/main/java/ch/threema/app/activities/HomeActivity.java
  37. 1 1
      app/src/main/java/ch/threema/app/activities/IdentityListActivity.java
  38. 3 6
      app/src/main/java/ch/threema/app/activities/ImagePaintActivity.java
  39. 11 18
      app/src/main/java/ch/threema/app/activities/MapActivity.java
  40. 1 1
      app/src/main/java/ch/threema/app/activities/MediaGalleryActivity.java
  41. 1 31
      app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java
  42. 1 1
      app/src/main/java/ch/threema/app/activities/PinLockActivity.java
  43. 4 9
      app/src/main/java/ch/threema/app/activities/QRCodeZoomActivity.java
  44. 30 11
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  45. 18 42
      app/src/main/java/ch/threema/app/activities/SendMediaActivity.java
  46. 1 1
      app/src/main/java/ch/threema/app/activities/StopPassphraseServiceActivity.java
  47. 2 10
      app/src/main/java/ch/threema/app/activities/TextChatBubbleActivity.java
  48. 1 0
      app/src/main/java/ch/threema/app/activities/ThreemaActivity.java
  49. 4 4
      app/src/main/java/ch/threema/app/activities/ThreemaToolbarActivity.java
  50. 2 11
      app/src/main/java/ch/threema/app/activities/WhatsNew2Activity.java
  51. 5 11
      app/src/main/java/ch/threema/app/activities/WhatsNewActivity.java
  52. 53 9
      app/src/main/java/ch/threema/app/activities/ballot/BallotMatrixActivity.java
  53. 5 4
      app/src/main/java/ch/threema/app/activities/ballot/BallotOverviewActivity.java
  54. 1 1
      app/src/main/java/ch/threema/app/activities/ballot/BallotWizardActivity.java
  55. 338 0
      app/src/main/java/ch/threema/app/activities/wizard/WizardBackupRestoreActivity.java
  56. 7 7
      app/src/main/java/ch/threema/app/activities/wizard/WizardBaseActivity.java
  57. 2 0
      app/src/main/java/ch/threema/app/activities/wizard/WizardFingerPrintActivity.java
  58. 7 5
      app/src/main/java/ch/threema/app/activities/wizard/WizardIDRestoreActivity.java
  59. 18 83
      app/src/main/java/ch/threema/app/activities/wizard/WizardIntroActivity.java
  60. 32 262
      app/src/main/java/ch/threema/app/activities/wizard/WizardSafeRestoreActivity.java
  61. 1 1
      app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java
  62. 12 10
      app/src/main/java/ch/threema/app/adapters/ContactDetailAdapter.java
  63. 52 9
      app/src/main/java/ch/threema/app/adapters/ContactListAdapter.java
  64. 6 6
      app/src/main/java/ch/threema/app/adapters/DirectoryAdapter.java
  65. 121 20
      app/src/main/java/ch/threema/app/adapters/GroupDetailAdapter.java
  66. 2 2
      app/src/main/java/ch/threema/app/adapters/MessageListAdapter.java
  67. 1 1
      app/src/main/java/ch/threema/app/adapters/decorators/AnimGifChatAdapterDecorator.java
  68. 6 5
      app/src/main/java/ch/threema/app/adapters/decorators/BallotChatAdapterDecorator.java
  69. 6 13
      app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java
  70. 1 1
      app/src/main/java/ch/threema/app/adapters/decorators/FileChatAdapterDecorator.java
  71. 3 3
      app/src/main/java/ch/threema/app/adapters/decorators/TextChatAdapterDecorator.java
  72. 1 3
      app/src/main/java/ch/threema/app/adapters/decorators/VideoChatAdapterDecorator.java
  73. 3 0
      app/src/main/java/ch/threema/app/archive/ArchiveActivity.java
  74. 3 3
      app/src/main/java/ch/threema/app/asynctasks/AddContactAsyncTask.java
  75. 8 0
      app/src/main/java/ch/threema/app/asynctasks/DeleteDistributionListAsyncTask.java
  76. 1 1
      app/src/main/java/ch/threema/app/asynctasks/DeleteIdentityAsyncTask.java
  77. 8 0
      app/src/main/java/ch/threema/app/asynctasks/DeleteMyGroupAsyncTask.java
  78. 56 12
      app/src/main/java/ch/threema/app/asynctasks/EmptyChatAsyncTask.java
  79. 8 0
      app/src/main/java/ch/threema/app/asynctasks/LeaveGroupAsyncTask.java
  80. 17 5
      app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java
  81. 1 1
      app/src/main/java/ch/threema/app/backuprestore/csv/Helper.java
  82. 19 9
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java
  83. 2 1
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreSettings.java
  84. 3 0
      app/src/main/java/ch/threema/app/backuprestore/csv/Tags.java
  85. 8 15
      app/src/main/java/ch/threema/app/camera/CameraActivity.java
  86. 0 3
      app/src/main/java/ch/threema/app/camera/CameraFragment.java
  87. 1 5
      app/src/main/java/ch/threema/app/camera/CameraUtil.java
  88. 1 3
      app/src/main/java/ch/threema/app/camera/VideoEditView.java
  89. 1 4
      app/src/main/java/ch/threema/app/camera/ZoomView.java
  90. 2 2
      app/src/main/java/ch/threema/app/dialogs/ContactEditDialog.java
  91. 29 12
      app/src/main/java/ch/threema/app/dialogs/MessageDetailDialog.java
  92. 1 1
      app/src/main/java/ch/threema/app/dialogs/NewContactDialog.java
  93. 32 4
      app/src/main/java/ch/threema/app/dialogs/PasswordEntryDialog.java
  94. 109 0
      app/src/main/java/ch/threema/app/dialogs/PublicKeyDialog.java
  95. 4 2
      app/src/main/java/ch/threema/app/dialogs/SMSVerificationDialog.java
  96. 73 56
      app/src/main/java/ch/threema/app/dialogs/SelectorDialog.java
  97. 8 2
      app/src/main/java/ch/threema/app/dialogs/ShowOnceDialog.java
  98. 2 5
      app/src/main/java/ch/threema/app/dialogs/SimpleStringAlertDialog.java
  99. 2 7
      app/src/main/java/ch/threema/app/dialogs/TextEntryDialog.java
  100. 43 1
      app/src/main/java/ch/threema/app/dialogs/WizardDialog.java

+ 4 - 4
README.md

@@ -103,7 +103,7 @@ Threema employee.
 
 ## <a name="build-variants"></a>Build Variants
 
-There are currently six product flavors:
+There are currently nine product flavors:
 
 | Flavor              | Description                                   | License Checks |
 | ------------------- | --------------------------------------------- | -------------- |
@@ -194,10 +194,10 @@ to understand the design concepts.
 
 Code related to the core functionality (e.g., connecting to the chat server,
 encrypting messages, etc.) can be found in the
-`app/src/main/java/ch/threema/client/` directory.
+`domain/src/main/java/ch/threema/` directory.
 
-The code of the actual Android app is mainly located in the
-`app/src/main/java/ch/threema/app/` directory.
+The code of the actual Android app is located in the
+`app/src/main/java/ch/threema/` directory.
 
 
 ## <a name="contributions"></a>Contributions

+ 2 - 1
THANKS.md

@@ -3,13 +3,14 @@
 The following people have contributed to Threema for Android through GitHub:
 
 * [das-g] ([#2])
-* [oemel09] ([#3], [#7])
+* [oemel09] ([#3], [#7], [#8])
 
 Thank you!
 
 [das-g]: https://github.com/das-g
 [oemel09]: https://github.com/oemel09
 
+[#8]: https://github.com/threema-ch/threema-android/pull/8
 [#2]: https://github.com/threema-ch/threema-android/pull/2
 [#3]: https://github.com/threema-ch/threema-android/pull/3
 [#7]: https://github.com/threema-ch/threema-android/pull/7

+ 200 - 87
app/build.gradle

@@ -1,8 +1,17 @@
 plugins {
-    id "org.sonarqube" version "3.0"
+    id 'org.sonarqube'
 }
 
 apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+// rules to allow test running with a real android jar implementation of URI, Uri.Builder for group link encoding
+apply plugin: 'de.mobilej.unmock'
+unMock {
+    keepStartingWith "android.net.Uri"
+    keep "android.net.Uri"
+    keepAndRename "java.nio.charset.Charsets" to "xjava.nio.charset.Charsets"
+    keepStartingWith "libcore."
+}
 
 // only apply the plugin if we are dealing with a AppGallery build
 if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")) {
@@ -11,7 +20,7 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 }
 
 // version codes
-def app_version = "4.59"
+def app_version = "4.6"
 def beta_suffix = "" // with leading dash
 
 /**
@@ -71,33 +80,37 @@ def keystores = [
     debug: findKeystore("debug"),
     release: findKeystore("threema"),
     hms_release: findKeystore("threema_hms"),
+    onprem_release: findKeystore("onprem"),
+    red_release: findKeystore("red"),
 ]
 
 android {
-    // NOTE: When adjusting compileSdkVersion or buildToolsVersion,
-    //       make sure to adjust them in `scripts/Dockerfile` as well!
-    compileSdkVersion 29
-    buildToolsVersion '29.0.3'
+    // NOTE: When adjusting compileSdkVersion, buildToolsVersion or ndkVersion,
+    //       make sure to adjust them in `scripts/Dockerfile` and
+    //       `.gitlab-ci.yml` as well!
+    compileSdkVersion 30
+    buildToolsVersion '30.0.3'
+    ndkVersion '21.1.6352462'
 
     defaultConfig {
-        minSdkVersion 19
+        minSdkVersion 21
         //noinspection OldTargetApi
-        targetSdkVersion 29
+        targetSdkVersion 30
         vectorDrawables.useSupportLibrary = true
         applicationId "ch.threema.app"
         testApplicationId 'ch.threema.app.test'
-        versionCode 699
+        versionCode 705
         versionName "${app_version}${beta_suffix}"
         resValue "string", "app_name", "Threema"
         // package name used for sync adapter
         resValue "string", "package_name", applicationId
         resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.profile"
         resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.call"
-        resValue "integer", "max_group_size", "256"
-        resValue "string", "shop_download_filename", "Threema-update.apk"
+        buildConfigField "int", "MAX_GROUP_SIZE", "256"
         buildConfigField "String", "CHAT_SERVER_PREFIX", "\"g-\""
-        buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.\""
+        buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.g-\""
         buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.threema.ch\""
+        buildConfigField "int[]", "CHAT_SERVER_PORTS", "{5222, 443}"
         buildConfigField "String", "MEDIA_PATH", "\"Threema\""
         buildConfigField "boolean", "CHAT_SERVER_GROUPS", "true"
         buildConfigField "boolean", "DISABLE_CERT_PINNING", "false"
@@ -105,18 +118,35 @@ android {
         buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x45, (byte) 0x0b, (byte) 0x97, (byte) 0x57, (byte) 0x35, (byte) 0x27, (byte) 0x9f, (byte) 0xde, (byte) 0xcb, (byte) 0x33, (byte) 0x13, (byte) 0x64, (byte) 0x8f, (byte) 0x5f, (byte) 0xc6, (byte) 0xee, (byte) 0x9f, (byte) 0xf4, (byte) 0x36, (byte) 0x0e, (byte) 0xa9, (byte) 0x2a, (byte) 0x8c, (byte) 0x17, (byte) 0x51, (byte) 0xc6, (byte) 0x61, (byte) 0xe4, (byte) 0xc0, (byte) 0xd8, (byte) 0xc9, (byte) 0x09 }"
         buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0xda, (byte) 0x7c, (byte) 0x73, (byte) 0x79, (byte) 0x8f, (byte) 0x97, (byte) 0xd5, (byte) 0x87, (byte) 0xc3, (byte) 0xa2, (byte) 0x5e, (byte) 0xbe, (byte) 0x0a, (byte) 0x91, (byte) 0x41, (byte) 0x7f, (byte) 0x76, (byte) 0xdb, (byte) 0xcc, (byte) 0xcd, (byte) 0xda, (byte) 0x29, (byte) 0x30, (byte) 0xe6, (byte) 0xa9, (byte) 0x09, (byte) 0x0a, (byte) 0xf6, (byte) 0x2e, (byte) 0xba, (byte) 0x6f, (byte) 0x15 }"
         buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
+        buildConfigField "String", "DIRECTORY_SERVER_URL", "\"https://apip.threema.ch/\""
+        buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "\"https://ds-apip.threema.ch/\""
+        buildConfigField "String", "WORK_SERVER_URL", "null"
+        buildConfigField "String", "WORK_SERVER_IPV6_URL", "null"
+        buildConfigField "String", "BLOB_SERVER_DOWNLOAD_URL", "\"https://blobp-{blobIdPrefix}.threema.ch/{blobId}\""
+        buildConfigField "String", "BLOB_SERVER_DOWNLOAD_IPV6_URL", "\"https://ds-blobp-{blobIdPrefix}.threema.ch/{blobId}\""
+        buildConfigField "String", "BLOB_SERVER_DONE_URL", "\"https://blobp-{blobIdPrefix}.threema.ch/{blobId}/done\""
+        buildConfigField "String", "BLOB_SERVER_DONE_IPV6_URL", "\"https://ds-blobp-{blobIdPrefix}.threema.ch/{blobId}/done\""
+        buildConfigField "String", "BLOB_SERVER_UPLOAD_URL", "\"https://blobp-upload.threema.ch/upload\""
+        buildConfigField "String", "BLOB_SERVER_UPLOAD_IPV6_URL", "\"https://ds-blobp-upload.threema.ch/upload\""
+        buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.threema.ch/\""
+        buildConfigField "String", "SAFE_SERVER_URL", "\"https://safe-%h.threema.ch/\""
+        buildConfigField "String", "ONPREM_ID_PREFIX", "\"O\""
+
+        buildConfigField "String[]", "ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", "null"
         buildConfigField "boolean", "SEND_CONSUMED_DELIVERY_RECEIPTS", "false"
 
         // config fields for action URLs / deep links
         buildConfigField "String", "uriScheme", "\"threema\""
         buildConfigField "String", "actionUrl", "\"go.threema.ch\""
         buildConfigField "String", "contactActionUrl", "\"threema.id\""
+        buildConfigField "String", "groupLinkActionUrl", "\"threema.group\""
 
         // duplicated for manifest
         manifestPlaceholders = [
             uriScheme: "threema",
-            actionUrl: "go.threema.ch",
-            contactActionUrl: "threema.id"
+            contactActionUrl: "threema.id",
+            groupLinkActionUrl: "threema.group",
+            actionUrl: "go.threema.ch"
         ]
 
         ndk {
@@ -149,146 +179,170 @@ android {
 
     flavorDimensions "default"
     productFlavors {
-
-
         none { }
-        store_google {
-            resValue "string", "shop_download_filename", ""
+        store_google { }
+        store_threema {
+            resValue "string", "shop_download_filename", "Threema-update.apk"
         }
-        store_threema { }
         store_google_work {
             versionName "${app_version}k${beta_suffix}"
             applicationId "ch.threema.app.work"
             testApplicationId 'ch.threema.app.work.test'
-            resValue "string", "package_name", applicationId
             resValue "string", "app_name", "Threema Work"
+
             resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.profile"
             resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.call"
-            resValue "integer", "max_group_size", "256"
-            resValue "string", "shop_download_filename", ""
             buildConfigField "String", "CHAT_SERVER_PREFIX", "\"w-\""
-            buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.\""
-            buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.threema.ch\""
+            buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.w-\""
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaWork\""
-            buildConfigField "boolean", "CHAT_SERVER_GROUPS", "true"
+            buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.threema.ch/\""
+            buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-apip-work.threema.ch/\""
 
             // config fields for action URLs / deep links
             buildConfigField "String", "uriScheme", "\"threemawork\""
             buildConfigField "String", "actionUrl", "\"work.threema.ch\""
-            buildConfigField "String", "contactActionUrl", "\"threema.id\""
 
             manifestPlaceholders = [
                 uriScheme: "threemawork",
                 actionUrl: "work.threema.ch",
-                contactActionUrl: "threema.id"
             ]
         }
         sandbox {
             applicationId "ch.threema.app.sandbox"
             testApplicationId 'ch.threema.app.sandbox.test'
-
-            resValue "string", "package_name", applicationId
             resValue "string", "app_name", "Threema Sandbox"
 
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaSandbox\""
             buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
             buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
+            buildConfigField "String", "DIRECTORY_SERVER_URL", "\"https://apip.test.threema.ch/\""
+            buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "\"https://ds-apip.test.threema.ch/\""
+            buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.test.threema.ch/\""
         }
         sandbox_work {
             versionName "${app_version}k${beta_suffix}"
             applicationId "ch.threema.app.sandbox.work"
             testApplicationId 'ch.threema.app.sandbox.work.test'
-
-            resValue "string", "package_name", applicationId
             resValue "string", "app_name", "Threema Sandbox Work"
+
             resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.profile"
             resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.call"
-            resValue "integer", "max_group_size", "256"
-            resValue "string", "shop_download_filename", ""
-
             buildConfigField "String", "CHAT_SERVER_PREFIX", "\"w-\""
-            buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.\""
-            buildConfigField "String", "MEDIA_PATH", "\"ThreemaWorkSandbox\""
-            buildConfigField "boolean", "CHAT_SERVER_GROUPS", "true"
-
+            buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.w-\""
             buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
+            buildConfigField "String", "MEDIA_PATH", "\"ThreemaWorkSandbox\""
             buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
 
+            buildConfigField "String", "DIRECTORY_SERVER_URL", "\"https://apip.test.threema.ch/\""
+            buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "\"https://ds-apip.test.threema.ch/\""
+            buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.test.threema.ch/\""
+            buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-apip-work.test.threema.ch/\""
+            buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.test.threema.ch/\""
+
             // config fields for action URLs / deep links
             buildConfigField "String", "uriScheme", "\"threemawork\""
             buildConfigField "String", "actionUrl", "\"work.threema.ch\""
-            buildConfigField "String", "contactActionUrl", "\"threema.id\""
 
             manifestPlaceholders = [
-                uriScheme: "threemawork",
-                actionUrl: "work.threema.ch",
-                contactActionUrl: "threema.id"
+                uriScheme       : "threemawork",
+                actionUrl       : "work.threema.ch",
             ]
         }
+        onprem {
+            versionName "${app_version}o${beta_suffix}"
+            applicationId "ch.threema.app.onprem"
+            testApplicationId 'ch.threema.app.onprem.test'
+            resValue "string", "app_name", "Threema OnPrem"
+
+            resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.onprem.profile"
+            resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.onprem.call"
+            buildConfigField "int", "MAX_GROUP_SIZE", "256"
+            buildConfigField "String", "CHAT_SERVER_PREFIX", "\"\""
+            buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"\""
+            buildConfigField "String", "CHAT_SERVER_SUFFIX", "null"
+            buildConfigField "String", "MEDIA_PATH", "\"ThreemaOnPrem\""
+            buildConfigField "boolean", "CHAT_SERVER_GROUPS", "false"
+
+            buildConfigField "byte[]", "SERVER_PUBKEY", "null"
+            buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "null"
+            buildConfigField "String", "DIRECTORY_SERVER_URL", "null"
+            buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "null"
+            buildConfigField "String", "BLOB_SERVER_DOWNLOAD_URL", "null"
+            buildConfigField "String", "BLOB_SERVER_DOWNLOAD_IPV6_URL", "null"
+            buildConfigField "String", "BLOB_SERVER_DONE_URL", "null"
+            buildConfigField "String", "BLOB_SERVER_DONE_IPV6_URL", "null"
+            buildConfigField "String", "BLOB_SERVER_UPLOAD_URL", "null"
+            buildConfigField "String", "BLOB_SERVER_UPLOAD_IPV6_URL", "null"
+            buildConfigField "String[]", "ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", "new String[] {\"ek1qBp4DyRmLL9J5sCmsKSfwbsiGNB4veDAODjkwe/k=\", \"Hrk8aCjwKkXySubI7CZ3y9Sx+oToEHjNkGw98WSRneU=\", \"5pEn1T/5bhecNWrp9NgUQweRfgVtu/I8gRb3VxGP7k4=\"}"
+
+            // config fields for action URLs / deep links
+            buildConfigField "String", "uriScheme", "\"threemaonprem\""
+            buildConfigField "String", "actionUrl", "\"onprem.threema.ch\""
+
+            manifestPlaceholders = [
+                uriScheme: "threemaonprem",
+                actionUrl: "onprem.threema.ch",
+            ]
+        }
+
         red { // Essentially like sandbox work, but with a different icon and accent color, used for internal testing
             versionName "${app_version}r${beta_suffix}"
             applicationId "ch.threema.app.red"
             testApplicationId 'ch.threema.app.red.test'
-
-            resValue "string", "package_name", applicationId
             resValue "string", "app_name", "Threema Red"
+
             resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.redwork.profile"
             resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.redwork.call"
-            resValue "integer", "max_group_size", "256"
-            resValue "string", "shop_download_filename", ""
 
             buildConfigField "String", "CHAT_SERVER_PREFIX", "\"w-\""
-            buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.\""
-            buildConfigField "String", "MEDIA_PATH", "\"ThreemaRed\""
-            buildConfigField "boolean", "CHAT_SERVER_GROUPS", "true"
-
+            buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.w-\""
             buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.test.threema.ch\""
+            buildConfigField "String", "MEDIA_PATH", "\"ThreemaRed\""
             buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
             buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0x5a, (byte) 0x98, (byte) 0xf2, (byte) 0x3d, (byte) 0xe6, (byte) 0x56, (byte) 0x05, (byte) 0xd0, (byte) 0x50, (byte) 0xdc, (byte) 0x00, (byte) 0x64, (byte) 0xbe, (byte) 0x07, (byte) 0xdd, (byte) 0xdd, (byte) 0x81, (byte) 0x1d, (byte) 0xa1, (byte) 0x16, (byte) 0xa5, (byte) 0x43, (byte) 0xce, (byte) 0x43, (byte) 0xaa, (byte) 0x26, (byte) 0x87, (byte) 0xd1, (byte) 0x9f, (byte) 0x20, (byte) 0xaf, (byte) 0x3c }"
+            buildConfigField "String", "DIRECTORY_SERVER_URL", "\"https://apip.test.threema.ch/\""
+            buildConfigField "String", "DIRECTORY_SERVER_IPV6_URL", "\"https://ds-apip.test.threema.ch/\""
+            buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.test.threema.ch/\""
+            buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-apip-work.test.threema.ch/\""
+            buildConfigField "String", "AVATAR_FETCH_URL", "\"https://avatar.test.threema.ch/\""
+
             buildConfigField "boolean", "SEND_CONSUMED_DELIVERY_RECEIPTS", "true"
 
             // config fields for action URLs / deep links
-            buildConfigField "String", "uriScheme", "\"threemawork\""
-            buildConfigField "String", "actionUrl", "\"work.threema.ch\""
-            buildConfigField "String", "contactActionUrl", "\"threema.id\""
+            buildConfigField "String", "uriScheme", "\"threemared\""
+            buildConfigField "String", "actionUrl", "\"red.threema.ch\""
 
             manifestPlaceholders = [
-                uriScheme: "threemawork",
-                actionUrl: "work.threema.ch",
-                contactActionUrl: "threema.id"
+                uriScheme: "threemared",
+                actionUrl: "red.threema.ch",
             ]
         }
         hms {
             applicationId "ch.threema.app.hms"
-            resValue "string", "package_name", applicationId
         }
         hms_work {
             versionName "${app_version}k${beta_suffix}"
             applicationId "ch.threema.app.work.hms"
             testApplicationId 'ch.threema.app.work.test.hms'
-            resValue "string", "package_name", applicationId
             resValue "string", "app_name", "Threema Work"
+
             resValue "string", "contacts_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.profile"
             resValue "string", "call_mime_type", "vnd.android.cursor.item/vnd.ch.threema.app.work.call"
-            resValue "integer", "max_group_size", "256"
-            resValue "string", "shop_download_filename", ""
             buildConfigField "String", "CHAT_SERVER_PREFIX", "\"w-\""
-            buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.\""
-            buildConfigField "String", "CHAT_SERVER_SUFFIX", "\".0.threema.ch\""
+            buildConfigField "String", "CHAT_SERVER_IPV6_PREFIX", "\"ds.w-\""
             buildConfigField "String", "MEDIA_PATH", "\"ThreemaWork\""
-            buildConfigField "boolean", "CHAT_SERVER_GROUPS", "true"
+            buildConfigField "String", "WORK_SERVER_URL", "\"https://apip-work.threema.ch/\""
+            buildConfigField "String", "WORK_SERVER_IPV6_URL", "\"https://ds-apip-work.threema.ch/\""
 
             // config fields for action URLs / deep links
             buildConfigField "String", "uriScheme", "\"threemawork\""
             buildConfigField "String", "actionUrl", "\"work.threema.ch\""
-            buildConfigField "String", "contactActionUrl", "\"threema.id\""
 
             manifestPlaceholders = [
                 uriScheme: "threemawork",
                 actionUrl: "work.threema.ch",
-                contactActionUrl: "threema.id"
             ]
         }
     }
@@ -326,17 +380,40 @@ android {
         } else {
             logger.warn("No hms keystore found. Falling back to locally generated keystore.")
         }
+
+        // Onprem release config
+        if (keystores.onprem_release != null) {
+            onprem_release {
+                storeFile file(keystores.onprem_release.storeFile)
+                storePassword keystores.onprem_release.storePassword
+                keyAlias keystores.onprem_release.keyAlias
+                keyPassword keystores.onprem_release.keyPassword
+            }
+        } else {
+            logger.warn("No onprem keystore found. Falling back to locally generated keystore.")
+        }
+
+        // Red release config
+        if (keystores.red_release != null) {
+            red_release {
+                storeFile file(keystores.red_release.storeFile)
+                storePassword keystores.red_release.storePassword
+                keyAlias keystores.red_release.keyAlias
+                keyPassword keystores.red_release.keyPassword
+            }
+        } else {
+            logger.warn("No red keystore found. Falling back to locally generated keystore.")
+        }
     }
 
     sourceSets {
-        none {
-            java.srcDir 'src/google_services_based/java'
-            manifest.srcFile 'src/store_google/AndroidManifest.xml'
-        }
         main {
             assets.srcDirs = ['assets']
             jniLibs.srcDirs = ['libs']
         }
+        none {
+            java.srcDir 'src/google_services_based/java'
+        }
         store_google {
             java.srcDir 'src/google_services_based/java'
         }
@@ -353,6 +430,9 @@ android {
             java.srcDir 'src/hms_services_based/java'
             res.srcDir 'src/store_google_work/res'
         }
+        onprem {
+            java.srcDir 'src/google_services_based/java'
+        }
         sandbox {
             java.srcDir 'src/google_services_based/java'
             manifest.srcFile 'src/store_google/AndroidManifest.xml'
@@ -374,6 +454,7 @@ android {
             jniDebuggable false
             multiDexEnabled true
             multiDexKeepProguard file('multidex-keep.pro')
+            testCoverageEnabled false
 
             if (keystores['debug'] != null) {
                 signingConfig signingConfigs.debug
@@ -393,7 +474,6 @@ android {
                 productFlavors.store_google.signingConfig signingConfigs.release
                 productFlavors.store_google_work.signingConfig signingConfigs.release
                 productFlavors.store_threema.signingConfig signingConfigs.release
-                productFlavors.red.signingConfig signingConfigs.release
                 productFlavors.sandbox.signingConfig signingConfigs.release
                 productFlavors.sandbox_work.signingConfig signingConfigs.release
                 productFlavors.none.signingConfig signingConfigs.release
@@ -403,9 +483,31 @@ android {
                 productFlavors.hms.signingConfig signingConfigs.hms_release
                 productFlavors.hms_work.signingConfig signingConfigs.hms_release
             }
+
+            if (keystores['onprem_release'] != null) {
+                productFlavors.onprem.signingConfig signingConfigs.onprem_release
+            }
+
+            if (keystores['red_release'] != null) {
+                productFlavors.red.signingConfig signingConfigs.red_release
+            }
+        }
+    }
+
+    // Only build relevant buildType / flavor combinations
+    variantFilter { variant ->
+        def names = variant.flavors*.name
+
+        if (
+            variant.buildType.name == "release" && (
+                names.contains("sandbox") || names.contains("sandbox_work")
+            )
+        ) {
+            setIgnore(true)
         }
     }
 
+
     externalNativeBuild {
         ndkBuild {
             path 'jni/Android.mk'
@@ -465,6 +567,7 @@ android {
         exclude 'META-INF/license.txt'
         exclude 'META-INF/dependencies.txt'
         exclude 'META-INF/LGPL2.1'
+        exclude '**/*.proto'
         // fix https://stackoverflow.com/questions/42739916/aarch64-linux-android-strip-file-missing
         doNotStrip '*/mips/*.so'
         doNotStrip '*/mips64/*.so'
@@ -512,6 +615,8 @@ dependencies {
         //resolutionStrategy.failOnVersionConflict()
     }
 
+    implementation project(':domain')
+
     implementation 'net.zetetic:android-database-sqlcipher:4.4.3'
 
     implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
@@ -521,7 +626,7 @@ dependencies {
     implementation 'com.mapbox.mapboxsdk:mapbox-android-sdk:9.2.1'
     // commons-io >2.6 requires android 8
     implementation 'commons-io:commons-io:2.6'
-    implementation 'org.slf4j:slf4j-api:1.7.30'
+    implementation "org.slf4j:slf4j-api:$slf4j_version"
     implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.23'
     implementation 'com.theartofdev.edmodo:android-image-cropper:2.8.0'
     implementation 'com.datatheorem.android.trustkit:trustkit:1.1.5'
@@ -535,11 +640,11 @@ dependencies {
     implementation 'androidx.palette:palette:1.0.0'
     implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
     implementation 'androidx.appcompat:appcompat:1.3.1'
-    implementation 'androidx.constraintlayout:constraintlayout:2.1.0'
+    implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
     implementation 'androidx.biometric:biometric:1.1.0'
-    implementation "androidx.work:work-runtime:2.5.0"
+    implementation "androidx.work:work-runtime:2.6.0"
     implementation 'androidx.fragment:fragment:1.3.6'
-    implementation 'androidx.activity:activity:1.2.4'
+    implementation 'androidx.activity:activity:1.3.1'
     implementation 'androidx.sqlite:sqlite:2.1.0'
     implementation "androidx.concurrent:concurrent-futures:1.1.0"
     implementation "androidx.camera:camera-camera2:1.0.2"
@@ -548,6 +653,7 @@ dependencies {
     implementation "androidx.camera:camera-view:1.0.0-alpha25"
     implementation 'androidx.multidex:multidex:2.0.1'
     implementation "androidx.lifecycle:lifecycle-viewmodel:2.3.1"
+    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1"
     implementation "androidx.lifecycle:lifecycle-livedata:2.3.1"
     implementation "androidx.lifecycle:lifecycle-runtime:2.3.1"
     implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.3.1"
@@ -558,11 +664,10 @@ dependencies {
     implementation "androidx.paging:paging-runtime:3.0.1"
 
     implementation 'com.google.android.material:material:1.4.0'
-    implementation 'com.google.android.exoplayer:exoplayer-core:2.13.3'
-    implementation 'com.google.android.exoplayer:exoplayer-ui:2.13.3'
-    implementation 'com.google.protobuf:protobuf-javalite:3.9.1'
+    implementation 'com.google.android.exoplayer:exoplayer-core:2.15.1'
+    implementation 'com.google.android.exoplayer:exoplayer-ui:2.15.1'
     implementation 'com.google.zxing:core:3.3.3' // zxing 3.4 crashes on kitkat
-    implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.26'
+    implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.37'
 
     // webclient dependencies
     implementation 'org.msgpack:msgpack-core:0.8.22!!'
@@ -572,14 +677,14 @@ dependencies {
     implementation 'net.sourceforge.streamsupport:streamsupport-cfuture:1.7.2'
 
     // Google Assistant Voice Action verification library
-    implementation(name:'libgsaverification-client', ext:'aar')
+    implementation(name: 'libgsaverification-client', ext: 'aar')
 
     implementation('org.saltyrtc:saltyrtc-client:0.14.2') {
         exclude group: 'org.json'
     }
 
     implementation 'org.saltyrtc:chunked-dc:1.0.1'
-    implementation 'ch.threema:webrtc-android:91.0.1'
+    implementation 'ch.threema:webrtc-android:94.0.0'
     implementation('org.saltyrtc:saltyrtc-task-webrtc:0.18.1') {
         exclude module: 'saltyrtc-client'
     }
@@ -588,11 +693,18 @@ dependencies {
     implementation 'com.github.bumptech.glide:glide:4.12.0'
     annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
 
+    // kotlin
+    implementation "androidx.core:core-ktx:1.3.2"
+    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
+    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3"
+
+
     // use leak canary in debug builds
 //    debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.5'
 
     // test dependencies
     testImplementation 'junit:junit:4.12'
+    testImplementation(testFixtures(project(":domain")))
 
     // use powermock instead of mockito. it support mocking static classes.
     def mockitoVersion = '2.0.7'
@@ -604,6 +716,9 @@ dependencies {
     // add JSON support to tests without mocking
     testImplementation 'org.json:json:20160212'
 
+    testImplementation 'com.tngtech.archunit:archunit-junit4:0.18.0'
+
+    androidTestImplementation(testFixtures(project(":domain")))
     androidTestImplementation 'androidx.test:rules:1.2.0'
     androidTestImplementation 'tools.fastlane:screengrab:2.0.0', {
         exclude group: 'androidx.annotation', module: 'annotation'
@@ -652,6 +767,7 @@ dependencies {
         store_googleImplementation(dependency) { excludes.each { exclude it } }
         store_google_workImplementation(dependency) { excludes.each { exclude it } }
         store_threemaImplementation(dependency) { excludes.each { exclude it } }
+        onpremImplementation(dependency) { excludes.each { exclude it } }
         sandboxImplementation(dependency) { excludes.each { exclude it } }
         sandbox_workImplementation(dependency) { excludes.each { exclude it } }
         redImplementation(dependency) { excludes.each { exclude it } }
@@ -677,16 +793,13 @@ dependencies {
 
 sonarqube {
     properties {
-        property "sonar.projectKey", "android-client"
-        property "sonar.projectName", "Threema for Android"
         property "sonar.sources", "src/main/, ../scripts/, ../scripts-internal/"
-        // Exclusion notes:
-        // - Protobuf code is generated
-        // - Java Client code (including jnacl) is already being checked by SonarQube separately
-        property "sonar.exclusions", "src/main/java/ch/threema/protobuf/**, src/main/java/ch/threema/client/**, src/test/java/ch/threema/client/**, src/main/java/ch/threema/base/**, src/main/java/ch/threema/localcrypto/**, src/test/java/ch/threema/localcrypto/**, src/main/java/com/neilalexander/jnacl/**"
-        property "sonar.tests", "src/test/"
+        property "sonar.exclusions", "src/main/java/ch/threema/localcrypto/**, src/test/java/ch/threema/localcrypto/**"
+        property "sonar.tests", "src/testNoneDebug/"
         property "sonar.sourceEncoding", "UTF-8"
         property "sonar.verbose", "true"
+        property 'sonar.projectKey', 'android-client'
+        property 'sonar.projectName', 'Threema for Android'
     }
 }
 

+ 1 - 1
app/proguard-project.txt

@@ -252,4 +252,4 @@ public static <fields>;
 # Adding this line fixes the problem. It's most probably a bug in the R8 optimizer
 # that's triggered by this file, the NonceFactory wasn't modified since 2017...
 # Maybe we can remove this again with a future build toolchain.
--keep class ch.threema.client.NonceFactory { *; }
+-keep class ch.threema.base.crypto.NonceFactory { *; }

+ 10 - 4
app/src/androidTest/java/ch/threema/app/backuprestore/csv/BackupServiceTest.java

@@ -24,6 +24,7 @@ package ch.threema.app.backuprestore.csv;
 import android.Manifest;
 import android.content.Context;
 import android.content.Intent;
+import android.os.Build;
 import android.util.Log;
 
 import net.lingala.zip4j.ZipFile;
@@ -35,6 +36,7 @@ import org.apache.commons.io.IOUtils;
 import org.junit.Assert;
 import org.junit.Before;
 import org.junit.BeforeClass;
+import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -47,13 +49,12 @@ import java.util.List;
 import java.util.Objects;
 
 import androidx.annotation.NonNull;
-import androidx.core.content.ContextCompat;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.LargeTest;
 import androidx.test.rule.GrantPermissionRule;
 import ch.threema.app.DangerousTest;
-import ch.threema.app.TestHelpers;
+import ch.threema.app.testutils.TestHelpers;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.backuprestore.BackupRestoreDataConfig;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
@@ -67,7 +68,7 @@ import ch.threema.app.services.MessageService;
 import ch.threema.app.services.ballot.BallotService;
 import ch.threema.app.utils.CSVReader;
 import ch.threema.app.utils.CSVRow;
-import ch.threema.client.IdentityBackupDecoder;
+import ch.threema.domain.identitybackup.IdentityBackupDecoder;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.data.status.VoipStatusDataModel;
 import java8.util.stream.StreamSupport;
@@ -75,6 +76,7 @@ import java8.util.stream.StreamSupport;
 @RunWith(AndroidJUnit4.class)
 @LargeTest
 @DangerousTest // Deletes data and possibly identity
+@Ignore("because this test broke with API version switch introduced in 7ed52bcfedd0bdcd2924ae14afe7ccb7bdc52c7a") // TODO(ANDR-1483)
 public class BackupServiceTest {
 	private final static String PASSWORD = "ubnpwrgujioasdfi0932";
 	private static final String TAG = "BackupServiceTest";
@@ -148,7 +150,11 @@ public class BackupServiceTest {
 		intent.putExtra(BackupService.EXTRA_BACKUP_RESTORE_DATA_CONFIG, config);
 
 		// Start service
-		ContextCompat.startForegroundService(appContext, intent);
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+			appContext.startForegroundService(intent);
+		}
+
+		appContext.startService(intent);
 		Assert.assertTrue(TestHelpers.iServiceRunning(appContext, BackupService.class));
 
 		// Wait for service to stop

+ 74 - 0
app/src/androidTest/java/ch/threema/app/processors/MessageAckProcessorTest.java

@@ -0,0 +1,74 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2021 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.processors;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Objects;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import ch.threema.app.ThreemaApplication;
+import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.services.MessageService;
+import ch.threema.app.testutils.CaptureLogcatOnTestFailureRule;
+import ch.threema.domain.models.MessageId;
+import ch.threema.domain.models.QueueMessageId;
+
+@RunWith(AndroidJUnit4.class)
+@LargeTest
+public class MessageAckProcessorTest {
+	// Test rules
+	@Rule public CaptureLogcatOnTestFailureRule captureLogcatOnTestFailureRule = new CaptureLogcatOnTestFailureRule();
+
+	// Services
+	private MessageService messageService;
+
+	// Message ack processor
+	private MessageAckProcessor messageAckProcessor;
+
+	@Before
+	public void setUp() throws Exception {
+		// Load services
+		final ServiceManager serviceManager = Objects.requireNonNull(ThreemaApplication.getServiceManager());
+		this.messageService = serviceManager.getMessageService();
+
+		// Create processor
+		this.messageAckProcessor = new MessageAckProcessor();
+		this.messageAckProcessor.setMessageService(this.messageService);
+	}
+
+	/**
+	 * Ensure that {@link MessageAckProcessor#wasRecentlyAcked(MessageId)} works.
+	 */
+	@Test
+	public void wasRecentlyAcked() {
+		final QueueMessageId queueMessageId = new QueueMessageId(new MessageId(), "09BNNVR2");
+		Assert.assertFalse(this.messageAckProcessor.wasRecentlyAcked(queueMessageId.getMessageId()));
+		this.messageAckProcessor.processAck(queueMessageId);
+		Assert.assertTrue(this.messageAckProcessor.wasRecentlyAcked(queueMessageId.getMessageId()));
+	}
+}

+ 244 - 0
app/src/androidTest/java/ch/threema/app/processors/MessageProcessorTest.java

@@ -0,0 +1,244 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2021 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.processors;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TestName;
+import org.junit.runner.RunWith;
+
+import java.util.Objects;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import ch.threema.app.ThreemaApplication;
+import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.services.ContactService;
+import ch.threema.app.services.FileService;
+import ch.threema.app.services.GroupService;
+import ch.threema.app.services.IdListService;
+import ch.threema.app.services.MessageService;
+import ch.threema.app.services.NotificationService;
+import ch.threema.app.services.PreferenceService;
+import ch.threema.app.services.ballot.BallotService;
+import ch.threema.app.services.group.GroupInviteService;
+import ch.threema.app.services.group.GroupJoinResponseService;
+import ch.threema.app.services.group.IncomingGroupJoinRequestService;
+import ch.threema.app.testutils.CaptureLogcatOnTestFailureRule;
+import ch.threema.app.testutils.TestHelpers;
+import ch.threema.app.testutils.ThreemaAssert;
+import ch.threema.app.voip.services.VoipStateService;
+import ch.threema.base.ThreemaException;
+import ch.threema.base.crypto.NonceFactory;
+import ch.threema.base.utils.Utils;
+import ch.threema.domain.helpers.InMemoryContactStore;
+import ch.threema.domain.helpers.InMemoryIdentityStore;
+import ch.threema.domain.helpers.InMemoryNonceStore;
+import ch.threema.domain.models.Contact;
+import ch.threema.domain.models.MessageId;
+import ch.threema.domain.protocol.csp.ProtocolDefines;
+import ch.threema.domain.protocol.csp.coders.MessageBox;
+import ch.threema.domain.protocol.csp.coders.MessageCoder;
+import ch.threema.domain.protocol.csp.connection.MessageProcessorInterface.ProcessIncomingResult;
+import ch.threema.domain.protocol.csp.messages.DeliveryReceiptMessage;
+import ch.threema.domain.stores.ContactStore;
+import ch.threema.domain.stores.IdentityStoreInterface;
+
+@RunWith(AndroidJUnit4.class)
+@LargeTest
+public class MessageProcessorTest {
+	// Test rules
+	@Rule public TestName name = new TestName();
+	@Rule public CaptureLogcatOnTestFailureRule captureLogcatOnTestFailureRule = new CaptureLogcatOnTestFailureRule();
+
+	private final static Contact TEST_CONTACT_1 = new Contact("09BNNVR2", Utils.hexStringToByteArray("e4613bbe5408d342fdabc3edf4509d1a3aecd7cb0598773987eef8400e74c81a"));
+	private final static Contact TEST_CONTACT_2 = new Contact("0BSXZ4P8", Utils.hexStringToByteArray("dee1cd341de88f783a768941eac702951c8bbb21e836da4a43ab8f3776fc0a65"));
+
+	// Services
+	private MessageService messageService;
+	private ContactService contactService;
+	private PreferenceService preferenceService;
+	private GroupService groupService;
+	private GroupInviteService groupInviteService;
+	private GroupJoinResponseService groupJoinResponseService;
+	private IncomingGroupJoinRequestService incomingGroupJoinRequestService;
+	private IdListService blackListService;
+	private BallotService ballotService;
+	private FileService fileService;
+	private NotificationService notificationService;
+	private VoipStateService voipStateService;
+	private NonceFactory nonceFactory;
+
+	// Stores
+	private IdentityStoreInterface identityStore;
+	private IdentityStoreInterface identityStore2;
+	private ContactStore contactStore;
+
+	// Message processor
+	private MessageProcessor messageProcessor;
+
+	@Before
+	public void setUp() throws Exception {
+		// Load services
+		final ServiceManager serviceManager = Objects.requireNonNull(ThreemaApplication.getServiceManager());
+		this.messageService = serviceManager.getMessageService();
+		this.contactService = serviceManager.getContactService();
+		this.preferenceService = serviceManager.getPreferenceService();
+		this.groupService = serviceManager.getGroupService();
+		this.groupInviteService = serviceManager.getGroupInviteService();
+		this.groupJoinResponseService = serviceManager.getGroupJoinResponseService();
+		this.incomingGroupJoinRequestService = serviceManager.getIncomingGroupJoinRequestService();
+		this.blackListService = serviceManager.getBlackListService();
+		this.ballotService = serviceManager.getBallotService();
+		this.fileService = serviceManager.getFileService();
+		this.notificationService = serviceManager.getNotificationService();
+		this.voipStateService = serviceManager.getVoipStateService();
+		this.nonceFactory = new NonceFactory(new InMemoryNonceStore());
+
+		// Create in-memory stores
+		this.contactStore = new InMemoryContactStore();
+
+		// Create two identities
+		this.identityStore = new InMemoryIdentityStore(
+			"07N3PDDA",
+			"86",
+			Utils.hexStringToByteArray("e2c457e6f90b9e4dd1f9feedb078382d1dbd1e64c616b2e8ac8ae28b8cede36e"),
+			"07N3PDDA");
+		this.identityStore2 = new InMemoryIdentityStore(
+			"07ZKBCYU",
+			"3a",
+			Utils.hexStringToByteArray("4a7983f3e4dc7d5d1a591a94dfc03b16b94a4ca5a15e4e68c3bdba4dd030dd3e"),
+			"07ZKBCYU"
+		);
+
+		// Store contacts (including ourselves, so we can encrypt messages to ourselves)
+		this.contactStore.addContact(TEST_CONTACT_1);
+		this.contactStore.addContact(TEST_CONTACT_2);
+		this.contactStore.addContact(new Contact(this.identityStore.getIdentity(), this.identityStore.getPublicKey()));
+		this.contactStore.addContact(new Contact(this.identityStore2.getIdentity(), this.identityStore2.getPublicKey()));
+
+		// Create message processor
+		this.messageProcessor = new MessageProcessor(
+			this.messageService,
+			this.contactService,
+			this.identityStore,
+			this.contactStore,
+			this.preferenceService,
+			this.groupService,
+			this.groupJoinResponseService,
+			this.incomingGroupJoinRequestService,
+			this.blackListService,
+			this.ballotService,
+			this.fileService,
+			this.notificationService,
+			this.voipStateService
+		);
+	}
+
+	/**
+	 * Return logcat logs for this test (without clearing the log).
+	 */
+	private String getLogs() {
+		final String testName = this.name.getMethodName() + "(" + MessageProcessorTest.class.getName() + ")";
+		return TestHelpers.getTestLogs(testName);
+	}
+
+	/**
+	 * When a message is processed that is not directed at us, processing fails.
+	 */
+	@Test
+	@Ignore("because getLogs does not work consistently.") // TODO(ANDR-1484)
+	public void messageForOtherIdentity() throws ThreemaException {
+		final MessageBox boxmsg = new MessageBox();
+		boxmsg.setFromIdentity(TEST_CONTACT_1.getIdentity());
+		boxmsg.setToIdentity(TEST_CONTACT_2.getIdentity());
+		boxmsg.setBox(new byte[] { 0, 1, 2, 3 });
+		boxmsg.setNonce(this.nonceFactory.next());
+		final ProcessIncomingResult result = this.messageProcessor.processIncomingMessage(boxmsg);
+		Assert.assertFalse(result.processed);
+		final String logs = this.getLogs();
+		ThreemaAssert.assertContains(logs, "BadMessageException: Message is not for own identity, cannot decode");
+	}
+
+	/**
+	 * When processing an invalid box, no exception should be thrown.
+	 */
+	@Test
+	@Ignore("because getLogs does not work consistently.") // TODO(ANDR-1484)
+	public void processInvalidBox() throws ThreemaException {
+		final MessageBox boxmsg = new MessageBox();
+		boxmsg.setFromIdentity(TEST_CONTACT_1.getIdentity());
+		boxmsg.setToIdentity(this.identityStore.getIdentity());
+		boxmsg.setBox(new byte[] { 0, 1, 2, 3 });
+		boxmsg.setNonce(this.nonceFactory.next());
+		final ProcessIncomingResult result = this.messageProcessor.processIncomingMessage(boxmsg);
+		Assert.assertFalse(result.processed);
+		final String logs = this.getLogs();
+		ThreemaAssert.assertContains(logs, "ch.threema.domain.protocol.csp.messages.BadMessageException: Decryption of message from");
+	}
+
+	/**
+	 * Process a delivery receipt for an unknown message.
+	 *
+	 * Because the confirmed message is not known, a log is created,
+	 * but nothing in the database is changed.
+	 */
+	@Test
+	@Ignore("because getLogs does not work consistently.") // TODO(ANDR-1484)
+	public void processDeliveryReceiptForUnknownMessage() throws ThreemaException {
+		// Message IDs
+		final MessageId deliveryMessageId = new MessageId();
+		final MessageId deliveredMessageId = new MessageId();
+
+		// Create message box
+		final DeliveryReceiptMessage msg = new DeliveryReceiptMessage();
+		msg.setMessageId(deliveryMessageId);
+		msg.setFromIdentity(this.identityStore2.getIdentity());
+		msg.setToIdentity(this.identityStore.getIdentity());
+		msg.setReceiptType(ProtocolDefines.DELIVERYRECEIPT_MSGRECEIVED);
+		msg.setReceiptMessageIds(new MessageId[] { deliveredMessageId });
+
+		MessageCoder messageCoder = new MessageCoder(this.contactStore, this.identityStore2);
+		final MessageBox boxmsg = messageCoder.encode(msg, this.nonceFactory);
+
+		// Process message
+		final ProcessIncomingResult result = this.messageProcessor.processIncomingMessage(boxmsg);
+		Assert.assertTrue(result.processed);
+
+		// Assert log messages
+		final String logs = this.getLogs();
+		ThreemaAssert.assertContains(
+			logs,
+			"MessageProcessor: Incoming message " + deliveryMessageId.toString()
+				+ " from " + this.identityStore2.getIdentity()
+				+ " to " + this.identityStore.getIdentity()
+				+ " (type " + Utils.byteToHex((byte) ProtocolDefines.MSGTYPE_DELIVERY_RECEIPT, false, true) + ")"
+		);
+		ThreemaAssert.assertContains(
+			logs,
+			"MessageServiceImpl: Updated message state (DELIVERED) for unknown message with id " + deliveredMessageId.toString()
+		);
+	}
+}

+ 308 - 0
app/src/androidTest/java/ch/threema/app/service/GroupInviteServiceTest.java

@@ -0,0 +1,308 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2021 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.service;
+
+import android.accounts.Account;
+import android.accounts.AccountManagerCallback;
+import android.net.Uri;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.Date;
+
+import androidx.annotation.Nullable;
+import ch.threema.app.BuildConfig;
+import ch.threema.app.ThreemaApplication;
+import ch.threema.app.exceptions.FileSystemNotPresentException;
+import ch.threema.app.services.GroupService;
+import ch.threema.app.services.UserService;
+import ch.threema.app.services.group.GroupInviteService;
+import ch.threema.app.services.group.GroupInviteServiceImpl;
+import ch.threema.app.services.license.LicenseService;
+import ch.threema.domain.protocol.csp.messages.group.GroupInviteData;
+import ch.threema.domain.protocol.csp.messages.group.GroupInviteToken;
+import ch.threema.localcrypto.MasterKeyLockedException;
+import ch.threema.protobuf.url_payloads.GroupInvite;
+import ch.threema.storage.DatabaseServiceNew;
+import ch.threema.storage.models.group.GroupInviteModel;
+
+public class GroupInviteServiceTest {
+
+	private GroupService groupService;
+	private GroupInviteService groupInviteService;
+
+	static final String TEST_GROUP_NAME = "A nice little group";
+	static final String TEST_INVITE_NAME = "New unnamed link";
+	static String TEST_IDENTITY = "ECHOECHO";
+	static final GroupInvite.InviteType TEST_INVITE_TYPE_AUTOMATIC = GroupInvite.InviteType.AUTOMATIC;
+	static GroupInviteToken TEST_TOKEN_VALID;
+	static GroupInviteModel TEST_INVITE_MODEL;
+	static String TEST_ENCODED_INVITE = "RUNIT0VDSE86MDAwMTAyMDMwNDA1MDYwNzA4MDkwYTBiMGMwZDBlMGY6QSBuaWNlIGxpdHRsZSBncm91cDow";
+
+	static {
+		try {
+			TEST_TOKEN_VALID = new GroupInviteToken(new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15});
+			TEST_INVITE_MODEL = new GroupInviteModel.Builder()
+				.withGroupName(TEST_GROUP_NAME)
+				.withInviteName(TEST_INVITE_NAME)
+				.withToken(TEST_TOKEN_VALID)
+				.withManualConfirmation(false)
+				.build();
+		} catch (GroupInviteToken.InvalidGroupInviteTokenException | GroupInviteModel.MissingRequiredArgumentsException e) {
+			e.printStackTrace();
+		}
+	}
+
+	private final GroupInviteData TEST_INVITE_DATA = new GroupInviteData(
+		TEST_IDENTITY,
+		TEST_TOKEN_VALID,
+		TEST_GROUP_NAME,
+		TEST_INVITE_TYPE_AUTOMATIC
+	);
+
+	@Before
+	public void setUp() {
+		// create new implementation while only implementing getIdentity with the TEST_IDENTITY because Powermock cannot be used in androidTest scope
+		UserService userService = new UserService() {
+			@Override
+			public void createIdentity(byte[] newRandomSeed) throws Exception {
+
+			}
+
+			@Override
+			public void removeIdentity() throws Exception {
+
+			}
+
+			@Override
+			public Account getAccount() {
+				return null;
+			}
+
+			@Override
+			public Account getAccount(boolean createIfNotExists) {
+				return null;
+			}
+
+			@Override
+			public boolean checkAccount() {
+				return false;
+			}
+
+			@Override
+			public boolean enableAccountAutoSync(boolean enable) {
+				return false;
+			}
+
+			@Override
+			public void removeAccount() {
+
+			}
+
+			@Override
+			public boolean removeAccount(AccountManagerCallback<Boolean> callback) {
+				return false;
+			}
+
+			@Override
+			public boolean hasIdentity() {
+				return false;
+			}
+
+			@Override
+			public String getIdentity() {
+				return TEST_IDENTITY;
+			}
+
+			@Override
+			public boolean isMe(String identity) {
+				return false;
+			}
+
+			@Override
+			public byte[] getPublicKey() {
+				return new byte[0];
+			}
+
+			@Override
+			public byte[] getPrivateKey() {
+				return new byte[0];
+			}
+
+			@Override
+			public String getLinkedEmail() {
+				return null;
+			}
+
+			@Override
+			public String getLinkedMobileE164() {
+				return null;
+			}
+
+			@Override
+			public String getLinkedMobile() {
+				return null;
+			}
+
+			@Override
+			public String getLinkedMobile(boolean returnPendingNumber) {
+				return null;
+			}
+
+			@Override
+			public void linkWithEmail(String email) throws Exception {
+
+			}
+
+			@Override
+			public void unlinkEmail() throws Exception {
+
+			}
+
+			@Override
+			public int getEmailLinkingState() {
+				return 0;
+			}
+
+			@Override
+			public void checkEmailLinkState() {
+
+			}
+
+			@Override
+			public Date linkWithMobileNumber(String number) throws Exception {
+				return null;
+			}
+
+			@Override
+			public void makeMobileLinkCall() throws Exception {
+
+			}
+
+			@Override
+			public void unlinkMobileNumber() throws Exception {
+
+			}
+
+			@Override
+			public boolean verifyMobileNumber(String code) throws Exception {
+				return false;
+			}
+
+			@Override
+			public int getMobileLinkingState() {
+				return 0;
+			}
+
+			@Override
+			public long getMobileLinkingTime() {
+				return 0;
+			}
+
+			@Override
+			public String getPublicNickname() {
+				return null;
+			}
+
+			@Nullable
+			@Override
+			public String setPublicNickname(String publicNickname) {
+				return null;
+			}
+
+			@Override
+			public boolean isTyping(String toIdentity, boolean isTyping) {
+				return false;
+			}
+
+			@Override
+			public boolean restoreIdentity(String backupString, String password) throws Exception {
+				return false;
+			}
+
+			@Override
+			public boolean restoreIdentity(String identity, byte[] privateKey, byte[] publicKey) throws Exception {
+				return false;
+			}
+
+			@Override
+			public void setPolicyResponse(String responseData, String signature, int policyErrorCode) {
+
+			}
+
+			@Override
+			public void setCredentials(LicenseService.Credentials credentials) {
+
+			}
+
+			@Override
+			public boolean sendFlags() {
+				return false;
+			}
+
+			@Override
+			public boolean setRevocationKey(String revocationKey) {
+				return false;
+			}
+
+			@Override
+			public Date getLastRevocationKeySet() {
+				return null;
+			}
+
+			@Override
+			public void checkRevocationKey(boolean force) {
+
+			}
+		};
+		try {
+			this.groupService = ThreemaApplication.getServiceManager().getGroupService();
+		} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
+			e.printStackTrace();
+		}
+		DatabaseServiceNew databaseServiceNew = ThreemaApplication.getServiceManager().getDatabaseServiceNew();
+		this.groupInviteService = new GroupInviteServiceImpl(userService, this.groupService, databaseServiceNew);
+	}
+
+	@Test
+	public void testEncodeDecodeGroupInvite() {
+		Uri encodedGroupInvite = groupInviteService.encodeGroupInviteLink(TEST_INVITE_MODEL);
+
+		Assert.assertEquals("https", encodedGroupInvite.getScheme());
+		Assert.assertEquals(BuildConfig.groupLinkActionUrl, encodedGroupInvite.getAuthority());
+		Assert.assertEquals("/join", encodedGroupInvite.getPath());
+		Assert.assertEquals(TEST_ENCODED_INVITE, encodedGroupInvite.getEncodedFragment());
+	}
+
+	@Test
+	public void testDecodeGroupInvite() throws IOException, GroupInviteToken.InvalidGroupInviteTokenException {
+		GroupInviteData inviteDataFromDecodedUri = groupInviteService.decodeGroupInviteLink(TEST_ENCODED_INVITE);
+
+		Assert.assertEquals(TEST_INVITE_DATA.getAdminIdentity(),  inviteDataFromDecodedUri.getAdminIdentity());
+		Assert.assertEquals(TEST_INVITE_DATA.getToken(), inviteDataFromDecodedUri.getToken());
+		Assert.assertEquals(TEST_INVITE_DATA.getGroupName(), inviteDataFromDecodedUri.getGroupName());
+		Assert.assertEquals(TEST_INVITE_DATA.getInviteType(), inviteDataFromDecodedUri.getInviteType());
+	}
+}

+ 71 - 0
app/src/androidTest/java/ch/threema/app/testutils/CaptureLogcatOnTestFailureRule.java

@@ -0,0 +1,71 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2021 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.testutils;
+
+import org.junit.AssumptionViolatedException;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+/**
+ * Capture adb logcat on test failure.
+ *
+ * Based on https://www.braze.com/resources/articles/logcat-junit-android-tests
+ */
+public class CaptureLogcatOnTestFailureRule implements TestRule {
+	private static final String LOGCAT_HEADER = "\n================ Logcat Output ================\n";
+	private static final String STACKTRACE_HEADER = "\n================ Stacktrace ================\n";
+	private static final String ORIGINAL_CLASS_HEADER = "\nOriginal class: ";
+
+	@Override
+	public Statement apply(Statement base, Description description) {
+		return new Statement() {
+			@Override
+			public void evaluate() throws Throwable {
+				// Before test, clear logcat
+				TestHelpers.clearLogcat();
+
+				try {
+					// Run statement
+					base.evaluate();
+				} catch (Throwable originalThrowable) {
+					if (originalThrowable instanceof AssumptionViolatedException) {
+						throw originalThrowable;
+					}
+
+					// Fetch logcat logs
+					final String testName = description.getMethodName() + "(" + description.getClassName() + ")";
+					final String logcatLogs = TestHelpers.getTestLogs(testName);
+
+					// Throw updated throwable
+					final String thrownMessage = originalThrowable.getMessage()
+						+ ORIGINAL_CLASS_HEADER + originalThrowable.getClass().getName()
+						+ LOGCAT_HEADER + logcatLogs
+						+ STACKTRACE_HEADER;
+					final Throwable modifiedThrowable = new Throwable(thrownMessage);
+					modifiedThrowable.setStackTrace(originalThrowable.getStackTrace());
+					throw modifiedThrowable;
+				}
+			}
+		};
+	}
+}

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

@@ -19,13 +19,17 @@
  * along with this program. If not, see <https://www.gnu.org/licenses/>.
  */
 
-package ch.threema.app;
+package ch.threema.app.testutils;
 
 import android.app.ActivityManager;
 import android.app.ActivityManager.RunningServiceInfo;
 import android.content.Context;
 import android.util.Log;
 
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+
 import androidx.annotation.NonNull;
 import androidx.test.uiautomator.By;
 import androidx.test.uiautomator.BySelector;
@@ -33,7 +37,7 @@ import androidx.test.uiautomator.UiDevice;
 import androidx.test.uiautomator.Until;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.UserService;
-import ch.threema.client.Utils;
+import ch.threema.base.utils.Utils;
 
 import static org.junit.Assert.assertNotNull;
 
@@ -90,4 +94,63 @@ public class TestHelpers {
 		Log.i(TAG, "Test identity restored: " + identity);
 		return identity;
 	}
+
+	public static void clearLogcat() {
+		try {
+			Runtime.getRuntime().exec(new String[] { "logcat", "-c" });
+		} catch (IOException e) {
+			Log.e(TAG, "Could not clear logcat", e);
+		}
+	}
+
+	/**
+	 * Return adb logs since the start of the specified test.
+	 *
+	 * Based on https://www.braze.com/resources/articles/logcat-junit-android-tests
+	 */
+	public static String getTestLogs(@NonNull String testName) {
+		final StringBuilder logLines = new StringBuilder();
+
+		// Process id is used to filter messages
+		final String currentProcessId = Integer.toString(android.os.Process.myPid());
+
+		// A snippet of text that uniquely determines where the relevant logs start in the logcat
+		final String testStartMessage = "TestRunner: started: " + testName;
+
+		// When true, write every line from the logcat buffer to the string builder
+		boolean recording = false;
+
+		// Logcat command:
+		//   -d asks the command to completely dump to our buffer, then return
+		//   -v threadtime sets the output log format
+		final String[] command = new String[] { "logcat", "-d", "-v", "threadtime" };
+
+		BufferedReader bufferedReader = null;
+		try {
+			final Process process = Runtime.getRuntime().exec(command);
+			bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
+			String line;
+			while ((line = bufferedReader.readLine()) != null) {
+				if (line.contains(testStartMessage)) {
+					recording = true;
+				}
+				if (recording) {
+					logLines.append(line);
+					logLines.append('\n');
+				}
+			}
+		} catch (IOException e) {
+			Log.e(TAG, "Failed to run logcat command", e);
+		} finally {
+			if (bufferedReader != null) {
+				try {
+					bufferedReader.close();
+				} catch (IOException e) {
+					Log.e(TAG, "Failed to close buffered reader", e);
+				}
+			}
+		}
+
+		return logLines.toString();
+	}
 }

+ 40 - 0
app/src/androidTest/java/ch/threema/app/testutils/ThreemaAssert.java

@@ -0,0 +1,40 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2021 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.testutils;
+
+import org.junit.Assert;
+
+import androidx.annotation.NonNull;
+
+/**
+ * Better assertions.
+ */
+public class ThreemaAssert {
+	private final static String START = "=== Start ===\n";
+	private final static String END = "\n=== End ===";
+
+	public static void assertContains(@NonNull String haystack, @NonNull CharSequence needle) {
+		if (!haystack.contains(needle)) {
+			Assert.fail("Substring '" + needle + "' not found in the following string:\n" + START + haystack + END);
+		}
+	}
+}

+ 2 - 2
app/src/androidTest/java/ch/threema/app/utils/BackgroundErrorNotificationTest.java

@@ -48,7 +48,7 @@ import androidx.test.uiautomator.UiObject2;
 import androidx.test.uiautomator.Until;
 import ch.threema.app.R;
 import ch.threema.app.ScreenshotTakingRule;
-import ch.threema.app.TestHelpers;
+import ch.threema.app.testutils.TestHelpers;
 import ch.threema.app.notifications.BackgroundErrorNotification;
 
 import static org.junit.Assert.assertEquals;
@@ -106,7 +106,7 @@ public class BackgroundErrorNotificationTest {
 		TestHelpers.openNotificationArea(mDevice);
 
 		// Verify notification contents
-		final BySelector titleSelector = By.res("android:id/title").text("Error: T1tl3");
+		final BySelector titleSelector = By.res("android:id/title").text(context.getString(R.string.error) + ": T1tl3");
 		final BySelector bodySelector = By.text("The body of the notification");
 		assertNotNull("Notification title not found", mDevice.wait(Until.findObject(titleSelector), 1000));
 		assertNotNull("Notification text not found", mDevice.wait(Until.findObject(bodySelector), 1000));

+ 1 - 1
app/src/androidTest/java/ch/threema/app/voip/VoipStatusMessageTest.java

@@ -33,7 +33,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.platform.app.InstrumentationRegistry;
 import ch.threema.app.R;
 import ch.threema.app.utils.MessageUtil;
-import ch.threema.client.voip.VoipCallAnswerData;
+import ch.threema.domain.protocol.csp.messages.voip.VoipCallAnswerData;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.MessageModel;
 import ch.threema.storage.models.data.status.VoipStatusDataModel;

+ 1 - 1
app/src/androidTest/java/ch/threema/app/webclient/converter/MessageTest.java

@@ -37,7 +37,7 @@ import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 import androidx.test.filters.SmallTest;
-import ch.threema.client.file.FileData;
+import ch.threema.domain.protocol.csp.messages.file.FileData;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.MessageModel;
 import ch.threema.storage.models.data.media.FileDataModel;

+ 1 - 1
app/src/google_services_based/java/ch/threema/app/push/PushRegistrationWorker.java

@@ -36,7 +36,7 @@ import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 import ch.threema.app.utils.PushUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.client.ProtocolDefines;
+import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class PushRegistrationWorker extends Worker {
 	private final Logger logger = LoggerFactory.getLogger(PushRegistrationWorker.class);

+ 1 - 1
app/src/google_services_based/java/ch/threema/app/push/PushService.java

@@ -41,7 +41,7 @@ import androidx.annotation.NonNull;
 import ch.threema.app.utils.PushUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.client.ProtocolDefines;
+import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class PushService extends FirebaseMessagingService {
 	private static final Logger logger = LoggerFactory.getLogger(PushService.class);

+ 1 - 1
app/src/google_services_based/java/ch/threema/app/wearable/WearableHandler.java

@@ -53,7 +53,7 @@ import ch.threema.app.voip.services.CallRejectService;
 import ch.threema.app.voip.services.VoipCallService;
 import ch.threema.app.voip.services.VoipStateService;
 import ch.threema.app.voip.util.VoipUtil;
-import ch.threema.client.voip.VoipCallAnswerData;
+import ch.threema.domain.protocol.csp.messages.voip.VoipCallAnswerData;
 import ch.threema.storage.models.ContactModel;
 
 import static ch.threema.app.voip.services.VoipCallService.EXTRA_CALL_ID;

+ 1 - 1
app/src/hms_services_based/java/ch/threema/app/push/PushRegistrationWorker.java

@@ -36,7 +36,7 @@ import androidx.work.Worker;
 import androidx.work.WorkerParameters;
 import ch.threema.app.utils.PushUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.client.ProtocolDefines;
+import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class PushRegistrationWorker extends Worker {
 	private final Logger logger = LoggerFactory.getLogger(PushRegistrationWorker.class);

+ 1 - 1
app/src/hms_services_based/java/ch/threema/app/push/PushService.java

@@ -43,7 +43,7 @@ import ch.threema.app.ThreemaApplication;
 import ch.threema.app.utils.PushUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.client.ProtocolDefines;
+import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 import static ch.threema.app.push.PushRegistrationWorker.APP_ID_CONFIG_FIELD;
 import static ch.threema.app.push.PushRegistrationWorker.TOKEN_SCOPE;

+ 33 - 14
app/src/main/AndroidManifest.xml

@@ -182,11 +182,9 @@
 			android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
 			android:windowSoftInputMode="stateUnchanged">
 			<intent-filter android:label="@string/threema_contact"
-			               tools:ignore="AppLinkUrlError">
+					tools:ignore="AppLinkUrlError">
 				<action android:name="android.intent.action.VIEW"/>
-
 				<category android:name="android.intent.category.DEFAULT"/>
-
 				<data android:mimeType="@string/contacts_mime_type"/>
 			</intent-filter>
 		</activity>
@@ -236,7 +234,7 @@
 			</intent-filter>
 			<meta-data
 				android:name="android.service.chooser.chooser_target_service"
-				android:value=".RecipientChooserTargetService"/>
+				android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
 		</activity>
 		<activity
 			android:name=".activities.RecipientListBaseActivity"
@@ -395,6 +393,26 @@
 			android:theme="@style/Theme.Threema.TransparentStatusbar"
 			android:configChanges="uiMode"
 			android:windowSoftInputMode="stateHidden" />
+		<activity
+			android:name=".qrscanner.activity.BaseQrScannerActivity"
+			android:theme="@style/Theme.Threema.Translucent"/>
+		<activity
+			android:name=".grouplinks.AddGroupLinkBottomSheet"
+			android:theme="@style/Theme.Threema.Translucent" />
+		<activity
+			android:name=".grouplinks.GroupLinkQrCodeActivity"
+			android:theme="@style/Theme.Threema.WithToolbar"/>
+		<activity
+			android:name=".grouplinks.GroupLinkOverviewActivity"
+			android:theme="@style/Theme.Threema.WithToolbar"/>
+		<activity
+			android:name=".grouplinks.OutgoingGroupRequestActivity"
+			android:parentActivityName=".activities.HomeActivity"
+			android:theme="@style/Theme.Threema.WithToolbar"/>
+		<activity
+			android:name=".grouplinks.IncomingGroupRequestActivity"
+			android:parentActivityName=".activities.HomeActivity"
+			android:theme="@style/Theme.Threema.WithToolbar"/>
 		<activity
 			android:name=".activities.DistributionListAddActivity"
 			android:theme="@style/Theme.Threema.WithToolbar"
@@ -570,11 +588,16 @@
 			android:screenOrientation="sensorPortrait"
 			android:theme="@style/Theme.Threema.Wizard"/>
 		<activity
-			android:name=".activities.wizard.WizardRestoreIDActivity"
+			android:name=".activities.wizard.WizardIDRestoreActivity"
 			android:screenOrientation="sensorPortrait"
 			android:theme="@style/Theme.Threema.Wizard"/>
 		<activity
-			android:name=".activities.wizard.WizardRestoreMainActivity"
+			android:name=".activities.wizard.WizardSafeRestoreActivity"
+			android:screenOrientation="sensorPortrait"
+			android:theme="@style/Theme.Threema.Wizard"
+			android:windowSoftInputMode="stateAlwaysHidden"/>
+		<activity
+			android:name=".activities.wizard.WizardBackupRestoreActivity"
 			android:screenOrientation="sensorPortrait"
 			android:theme="@style/Theme.Threema.Wizard"
 			android:windowSoftInputMode="stateAlwaysHidden"/>
@@ -676,6 +699,10 @@
 					android:scheme="https"
 					android:host="${contactActionUrl}"
 					android:pathPattern="/.*"/>
+				<data
+					android:scheme="https"
+					android:host="${groupLinkActionUrl}"
+					android:pathPattern="/join#\.*"/>
 			</intent-filter>
 		</activity>
 		<activity
@@ -777,14 +804,6 @@
 			android:permission="android.permission.BIND_JOB_SERVICE"
 			android:enabled="true"
 			android:exported="false"/>
-		<service
-			android:name=".RecipientChooserTargetService"
-			android:label="@string/app_name"
-			android:permission="android.permission.BIND_CHOOSER_TARGET_SERVICE">
-			<intent-filter>
-				<action android:name="android.service.chooser.ChooserTargetService"/>
-			</intent-filter>
-		</service>
 		<service
 			android:name=".jobs.ReConnectJobService"
 			android:permission="android.permission.BIND_JOB_SERVICE"/>

+ 6 - 29
app/src/main/java/ch/threema/app/BuildFlavor.java

@@ -28,19 +28,17 @@ public class BuildFlavor {
 	private final static String FLAVOR_STORE_GOOGLE_WORK = "store_google_work";
 	private final static String FLAVOR_SANDBOX = "sandbox";
 	private final static String FLAVOR_SANDBOX_WORK = "sandbox_work";
+	private final static String FLAVOR_ONPREM = "onprem";
 	private final static String FLAVOR_RED = "red";
 	private final static String FLAVOR_HMS = "hms";
 	private final static String FLAVOR_HMS_WORK = "hms_work";
 
 	public enum LicenseType {
-		NONE, GOOGLE, SERIAL, GOOGLE_WORK, HMS, HMS_WORK
+		NONE, GOOGLE, SERIAL, GOOGLE_WORK, HMS, HMS_WORK, ONPREM
 	}
 
 	private static boolean initialized = false;
 	private static LicenseType licenseType = null;
-	private static int serverPort = 5222;
-	private static int serverPortAlt = 443;
-	private static boolean sandbox = false;
 	private static String name = null;
 
 	/**
@@ -52,33 +50,11 @@ public class BuildFlavor {
 		return licenseType;
 	}
 
-	/**
-	 * Server Port
-	 * @return
-	 */
-	public static int getServerPort() {
-		init();
-		return serverPort;
-	}
-	/**
-	 * Server Port
-	 * @return
-	 */
-	public static int getServerPortAlt() {
-		init();
-		return serverPortAlt;
-	}
-
 	public static String getName() {
 		init();
 		return name;
 	}
 
-	public static boolean isSandbox() {
-		init();
-		return sandbox;
-	}
-
 	private static void init() {
 		if(!initialized) {
 
@@ -100,17 +76,18 @@ public class BuildFlavor {
 					name = "Work";
 					break;
 				case FLAVOR_SANDBOX:
-					sandbox = true;
 					name = "Sandbox";
 					licenseType = LicenseType.NONE;
 					break;
 				case FLAVOR_SANDBOX_WORK:
-					sandbox = true;
 					name = "Sandbox Work";
 					licenseType = LicenseType.GOOGLE_WORK;
 					break;
+				case FLAVOR_ONPREM:
+					name = "OnPrem";
+					licenseType = LicenseType.ONPREM;
+					break;
 				case FLAVOR_RED:
-					sandbox = true;
 					name = "Red";
 					licenseType = LicenseType.GOOGLE_WORK;
 					break;

+ 1 - 4
app/src/main/java/ch/threema/app/NamedFileProvider.java

@@ -28,9 +28,7 @@ import android.content.pm.ProviderInfo;
 import android.content.res.XmlResourceParser;
 import android.database.Cursor;
 import android.database.MatrixCursor;
-
 import android.net.Uri;
-import android.os.Build;
 import android.os.Environment;
 import android.provider.OpenableColumns;
 import android.text.TextUtils;
@@ -317,8 +315,7 @@ public class NamedFileProvider extends FileProvider {
 					if (externalCacheDirs.length > 0) {
 						target = externalCacheDirs[0];
 					}
-				} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
-					&& TAG_EXTERNAL_MEDIA.equals(tag)) {
+				} else if (TAG_EXTERNAL_MEDIA.equals(tag)) {
 					File[] externalMediaDirs = context.getExternalMediaDirs();
 					if (externalMediaDirs.length > 0) {
 						target = externalMediaDirs[0];

+ 0 - 147
app/src/main/java/ch/threema/app/RecipientChooserTargetService.java

@@ -1,147 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2016-2021 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app;
-
-import android.annotation.TargetApi;
-import android.content.ComponentName;
-import android.content.IntentFilter;
-import android.graphics.Bitmap;
-import android.graphics.drawable.Icon;
-import android.os.Build;
-import android.os.Bundle;
-import android.service.chooser.ChooserTarget;
-import android.service.chooser.ChooserTargetService;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
-
-import androidx.annotation.Nullable;
-import ch.threema.app.activities.RecipientListActivity;
-import ch.threema.app.managers.ServiceManager;
-import ch.threema.app.services.ContactService;
-import ch.threema.app.services.ConversationService;
-import ch.threema.app.services.GroupService;
-import ch.threema.app.services.PreferenceService;
-import ch.threema.app.utils.IntentDataUtil;
-import ch.threema.app.utils.NameUtil;
-import ch.threema.base.ThreemaException;
-import ch.threema.storage.models.ConversationModel;
-
-@TargetApi(Build.VERSION_CODES.M)
-public class RecipientChooserTargetService extends ChooserTargetService {
-	private static final Logger logger = LoggerFactory.getLogger(RecipientChooserTargetService.class);
-
-	private static final int MAX_CONVERSATIONS = 8;
-	private PreferenceService preferenceService;
-
-	@Override
-	public @Nullable List<ChooserTarget> onGetChooserTargets(
-		ComponentName targetActivityName,
-		IntentFilter matchedFilter
-	) {
-		logger.debug("onGetChooserTargets");
-
-		final ServiceManager serviceManager = ThreemaApplication.getServiceManager();
-		if (serviceManager == null) {
-			return null;
-		}
-
-		ConversationService conversationService = null;
-		GroupService groupService = null;
-		ContactService contactService = null;
-		preferenceService = null;
-
-		try {
-			conversationService = serviceManager.getConversationService();
-			groupService = serviceManager.getGroupService();
-			contactService = serviceManager.getContactService();
-			preferenceService = serviceManager.getPreferenceService();
-		} catch (ThreemaException e) {
-			logger.error("Exception", e);
-		}
-
-		if (conversationService == null || groupService == null || contactService == null || preferenceService == null) {
-			return null;
-		}
-
-		if (!preferenceService.isDirectShare()) {
-			// only enable this feature if sync contacts is enabled (privacy risk)
-			return null;
-		}
-
-		final ConversationService.Filter filter = new ConversationService.Filter() {
-			@Override
-			public boolean onlyUnread() {
-				return false;
-			}
-
-			@Override
-			public boolean noDistributionLists() {
-				return true;
-			}
-
-			@Override
-			public boolean noHiddenChats() { return preferenceService.isPrivateChatsHidden(); }
-
-			@Override
-			public boolean noInvalid() {
-				return true;
-			}
-		};
-
-		final List<ConversationModel> conversations = conversationService.getAll(false, filter);
-		int length = Math.min(conversations.size(), MAX_CONVERSATIONS);
-
-		final ComponentName componentName = new ComponentName(
-			getPackageName(),
-			Objects.requireNonNull(RecipientListActivity.class.getCanonicalName())
-		);
-
-		final List<ChooserTarget> targets = new ArrayList<>();
-		for (int i = 0; i < length; i++) {
-			final String title;
-			final Bitmap avatar;
-			final Bundle extras = new Bundle();
-			final ConversationModel conversationModel = conversations.get(i);
-
-			if (conversationModel.isGroupConversation()) {
-				title = NameUtil.getDisplayName(conversationModel.getGroup(), groupService);
-				avatar = groupService.getAvatar(conversationModel.getGroup(), false);
-				extras.putInt(IntentDataUtil.INTENT_DATA_GROUP_ID, conversationModel.getGroup().getId());
-			} else {
-				title = NameUtil.getDisplayNameOrNickname(conversationModel.getContact(), true);
-				avatar = contactService.getAvatar(conversationModel.getContact(), false);
-				extras.putString(IntentDataUtil.INTENT_DATA_IDENTITY, conversationModel.getContact().getIdentity());
-			}
-
-			final Icon icon = Icon.createWithBitmap(avatar);
-			final float score = ((float) MAX_CONVERSATIONS - ((float) i / 2)) / (float) MAX_CONVERSATIONS;
-
-			targets.add(new ChooserTarget(title, icon, score, componentName, extras));
-		}
-		return targets;
-	}
-}

+ 105 - 53
app/src/main/java/ch/threema/app/ThreemaApplication.java

@@ -24,9 +24,7 @@ package ch.threema.app;
 import android.annotation.SuppressLint;
 import android.annotation.TargetApi;
 import android.app.Activity;
-import android.app.AlarmManager;
 import android.app.NotificationManager;
-import android.app.PendingIntent;
 import android.app.job.JobInfo;
 import android.app.job.JobScheduler;
 import android.content.BroadcastReceiver;
@@ -42,7 +40,6 @@ import android.os.Build;
 import android.os.Environment;
 import android.os.PowerManager;
 import android.os.StrictMode;
-import android.os.SystemClock;
 import android.provider.ContactsContract;
 import android.text.format.DateUtils;
 import android.widget.Toast;
@@ -93,6 +90,7 @@ import androidx.work.WorkManager;
 import ch.threema.app.backuprestore.csv.BackupService;
 import ch.threema.app.exceptions.DatabaseMigrationFailedException;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
+import ch.threema.app.grouplinks.IncomingGroupJoinRequestListener;
 import ch.threema.app.jobs.WorkSyncJobService;
 import ch.threema.app.jobs.WorkSyncService;
 import ch.threema.app.listeners.BallotVoteListener;
@@ -127,6 +125,7 @@ import ch.threema.app.services.MessageService;
 import ch.threema.app.services.MessageServiceImpl;
 import ch.threema.app.services.NotificationService;
 import ch.threema.app.services.PreferenceService;
+import ch.threema.app.services.ShortcutService;
 import ch.threema.app.services.SynchronizeContactsService;
 import ch.threema.app.services.UpdateSystemService;
 import ch.threema.app.services.UpdateSystemServiceImpl;
@@ -158,12 +157,12 @@ import ch.threema.app.webclient.services.instance.DisconnectContext;
 import ch.threema.app.webclient.state.WebClientSessionState;
 import ch.threema.app.workers.IdentityStatesWorker;
 import ch.threema.base.ThreemaException;
-import ch.threema.client.AppVersion;
-import ch.threema.client.ConnectionState;
-import ch.threema.client.ConnectionStateListener;
-import ch.threema.client.NonceFactory;
-import ch.threema.client.ThreemaConnection;
-import ch.threema.client.Utils;
+import ch.threema.base.crypto.NonceFactory;
+import ch.threema.base.utils.Utils;
+import ch.threema.domain.models.AppVersion;
+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.localcrypto.MasterKeyLockedException;
 import ch.threema.logging.backend.DebugLogFileBackend;
@@ -182,11 +181,14 @@ import ch.threema.storage.models.ballot.GroupBallotModel;
 import ch.threema.storage.models.ballot.IdentityBallotModel;
 import ch.threema.storage.models.ballot.LinkBallotModel;
 import ch.threema.storage.models.data.status.VoipStatusDataModel;
+import ch.threema.storage.models.group.IncomingGroupJoinRequestModel;
 
 import static android.app.NotificationManager.ACTION_NOTIFICATION_CHANNEL_GROUP_BLOCK_STATE_CHANGED;
 import static android.app.NotificationManager.ACTION_NOTIFICATION_POLICY_CHANGED;
 import static android.app.NotificationManager.EXTRA_BLOCKED_STATE;
 import static android.app.NotificationManager.EXTRA_NOTIFICATION_CHANNEL_GROUP_ID;
+import static ch.threema.app.services.PreferenceService.Theme_DARK;
+import static ch.threema.app.services.PreferenceService.Theme_LIGHT;
 
 public class ThreemaApplication extends MultiDexApplication implements DefaultLifecycleObserver {
 
@@ -202,11 +204,14 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	public static final String INTENT_DATA_TIMESTAMP = "timestamp";
 	public static final String INTENT_DATA_EDITFOCUS = "editfocus";
 	public static final String INTENT_DATA_GROUP = "group";
+	public static final String INTENT_DATA_GROUP_LINK = "group_link";
 	public static final String INTENT_DATA_DISTRIBUTION_LIST = "distribution_list";
 	public static final String INTENT_DATA_ARCHIVE_FILTER = "archiveFilter";
 	public static final String INTENT_DATA_QRCODE = "qrcodestring";
 	public static final String INTENT_DATA_QRCODE_TYPE_OK = "qrcodetypeok";
 	public static final String INTENT_DATA_MESSAGE_ID = "messageid";
+	public static final String INTENT_DATA_INCOMING_GROUP_REQUEST = "groupRequest";
+	public static final String INTENT_DATA_GROUP_REQUEST_NOTIFICATION_ID = "groupRequestNotificationId";
 	public static final String EXTRA_VOICE_REPLY = "voicereply";
 	public static final String EXTRA_OUTPUT_FILE = "output";
 	public static final String EXTRA_ORIENTATION = "rotate";
@@ -238,6 +243,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	public static final int WEB_RESUME_FAILED_NOTIFICATION_ID = 737;
 	public static final int PASSPHRASE_SERVICE_NOTIFICATION_ID = 587;
 	public static final int INCOMING_CALL_NOTIFICATION_ID = 800;
+	public static final int GROUP_RESPONSE_NOTIFICATION_ID = 801;
+	public static final int GROUP_REQUEST_NOTIFICATION_ID = 802;
 
 	private static final String THREEMA_APPLICATION_LISTENER_TAG = "al";
 	public static final String AES_KEY_FILE = "key.dat";
@@ -246,7 +253,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	public static final String EMAIL_LINKED_PLACEHOLDER = "***@***";
 
 	public static final long ACTIVITY_CONNECTION_LIFETIME = 60000;
-	public static final int MAX_BLOB_SIZE_MB = 50;
+	public static final int MAX_BLOB_SIZE_MB = 100;
 	public static final int MAX_BLOB_SIZE = MAX_BLOB_SIZE_MB * 1024 * 1024;
 	public static final int MIN_PIN_LENGTH = 4;
 	public static final int MAX_PIN_LENGTH = 8;
@@ -537,7 +544,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 				LocalBroadcastManager.getInstance(context).registerReceiver(receiver, new IntentFilter(BackgroundReporter.REPORT_VALIDATION_EVENT));
 
 				// register a broadcast receiver for changes in app restrictions
-				if (ConfigUtils.isWorkRestricted() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+				if (ConfigUtils.isWorkRestricted()) {
 					getAppContext().registerReceiver(new BroadcastReceiver() {
 						@Override
 						public void onReceive(Context context, Intent intent) {
@@ -548,18 +555,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 						}
 					}, new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED));
 				}
-
-				// setup locale override
-				try {
-					if (getServiceManager() != null) {
-						ConfigUtils.setLocaleOverride(this, getServiceManager().getPreferenceService());
-					}
-				} catch (Exception e) {
-					logger.error("Exception", e);
-				}
 			}
 		}
-
 	}
 
 	@Override
@@ -747,6 +744,17 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 		} catch (Exception e) {
 			logger.error("Exception", e);
 		}
+
+		// set default theme depending on app type
+		if (prefs != null) {
+			if (TestUtil.empty(prefs.getString(getAppContext().getString(R.string.preferences__theme), null))) {
+				prefs.edit().putString(getAppContext().getString(R.string.preferences__theme), String.valueOf(
+					ConfigUtils.isWorkBuild() ?
+						Theme_DARK:
+						Theme_LIGHT)
+				).apply();
+			}
+		}
 	}
 
 	private static void setupLogging(PreferenceStore preferenceStore) {
@@ -771,7 +779,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 		}
 	}
 
-	@TargetApi(Build.VERSION_CODES.LOLLIPOP)
 	public static synchronized void reset() {
 
 		//set default preferences
@@ -843,15 +850,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			final ThreemaConnection connection = new ThreemaConnection(
 					identityStore,
 					new NonceFactory(nonceDatabaseBlobService),
-					BuildConfig.CHAT_SERVER_PREFIX,
-					BuildConfig.CHAT_SERVER_IPV6_PREFIX,
-					BuildConfig.CHAT_SERVER_SUFFIX,
-					BuildFlavor.getServerPort(),
-					BuildFlavor.getServerPortAlt(),
-					getIPv6(),
-					BuildConfig.SERVER_PUBKEY,
-					BuildConfig.SERVER_PUBKEY_ALT,
-					BuildConfig.CHAT_SERVER_GROUPS);
+					null,
+					getIPv6());
 
 			connection.setVersion(appVersion);
 
@@ -881,6 +881,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 					updateSystemService
 			);
 
+			connection.setServerAddressProvider(serviceManager.getServerAddressProviderService().getServerAddressProvider());
+
 			// get application restrictions
 			if (ConfigUtils.isWorkBuild()) {
 				AppRestrictionService.getInstance()
@@ -951,6 +953,25 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			}).start();
 
 			initMapbox();
+
+			// publish most recent chats or pinned shortcuts as sharing targets
+			ShortcutService shortcutService;
+			try {
+				shortcutService = serviceManager.getShortcutService();
+
+				if (shortcutService != null) {
+					if (serviceManager.getPreferenceService().isDirectShare()) {
+						shortcutService.publishRecentChatsAsSharingTargets();
+					} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
+						shortcutService.publishPinnedShortcutsAsSharingTargets();
+					}
+				}
+			} catch (ThreemaException e) {
+				logger.error("Exception, failed to publish sharing shortcut targets", e);
+			}
+
+			// setup locale override
+			ConfigUtils.setLocaleOverride(getAppContext(), serviceManager.getPreferenceService());
 		} catch (MasterKeyLockedException e) {
 			logger.error("Exception", e);
 		} catch (SQLiteException e) {
@@ -1051,25 +1072,14 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 		logger.info("Scheduling Work Sync. Schedule period: {}", schedulePeriod);
 
 		// schedule the start of the service according to schedule period
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
-			JobScheduler jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
-			if (jobScheduler != null) {
-				ComponentName serviceComponent = new ComponentName(context, WorkSyncJobService.class);
-				JobInfo.Builder builder = new JobInfo.Builder(WORK_SYNC_JOB_ID, serviceComponent)
-					.setPeriodic(schedulePeriod)
-					.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_ANY);
-				jobScheduler.schedule(builder.build());
-				return true;
-			}
-		} else {
-			AlarmManager alarmMgr = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
-			if (alarmMgr != null) {
-				Intent intent = new Intent(context, WorkSyncService.class);
-				PendingIntent pendingIntent = PendingIntent.getService(context, WORK_SYNC_JOB_ID, intent, PendingIntent.FLAG_CANCEL_CURRENT);
-				alarmMgr.setInexactRepeating(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime(),
-					schedulePeriod, pendingIntent);
-				return true;
-			}
+		JobScheduler jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
+		if (jobScheduler != null) {
+			ComponentName serviceComponent = new ComponentName(context, WorkSyncJobService.class);
+			JobInfo.Builder builder = new JobInfo.Builder(WORK_SYNC_JOB_ID, serviceComponent)
+				.setPeriodic(schedulePeriod)
+				.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY);
+			jobScheduler.schedule(builder.build());
+			return true;
 		}
 		logger.debug("unable to schedule work sync");
 		return false;
@@ -1118,8 +1128,11 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			@Override
 			public void onRemove(GroupModel groupModel) {
 				try {
+					final MessageReceiver receiver = serviceManager.getGroupService().createReceiver(groupModel);
+					serviceManager.getBallotService().remove(receiver);
 					serviceManager.getConversationService().removed(groupModel);
-					serviceManager.getNotificationService().cancel(new GroupMessageReceiver(groupModel, null, null, null, null));
+					serviceManager.getNotificationService().cancel(new GroupMessageReceiver(groupModel, null, null, null, null, serviceManager.getApiService()));
+					serviceManager.getShortcutService().deleteShortcut(groupModel);
 				} catch (ThreemaException e) {
 					logger.error("Exception", e);
 				}
@@ -1264,6 +1277,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			public void onLeave(GroupModel groupModel) {
 				try {
 					serviceManager.getConversationService().refresh(groupModel);
+					serviceManager.getShortcutService().deleteShortcut(groupModel);
 				} catch (ThreemaException e) {
 					logger.error("Exception", e);
 				}
@@ -1302,7 +1316,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			public void onRemove(DistributionListModel distributionListModel) {
 				try {
 					serviceManager.getConversationService().removed(distributionListModel);
-
+					serviceManager.getShortcutService().deleteShortcut(distributionListModel);
 				} catch (ThreemaException e) {
 					logger.error("Exception", e);
 				}
@@ -1350,6 +1364,20 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 				}
 			}
 
+			@Override
+			public void onRemoved(List<AbstractMessageModel> removedMessageModels) {
+				logger.debug("MessageListener.onRemoved multi");
+				for (final AbstractMessageModel removedMessageModel : removedMessageModels) {
+					if (!removedMessageModel.isStatusMessage()) {
+						try {
+							serviceManager.getConversationService().refreshWithDeletedMessage(removedMessageModel);
+						} catch (ThreemaException e) {
+							logger.error("Exception", e);
+						}
+					}
+				}
+			}
+
 			@Override
 			public void onProgressChanged(AbstractMessageModel messageModel, int newProgress) {
 				//ingore
@@ -1390,6 +1418,29 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			}
 		}, THREEMA_APPLICATION_LISTENER_TAG);
 
+		ListenerManager.groupJoinResponseListener.add((outgoingGroupJoinRequestModel, status) -> {
+			NotificationService n = serviceManager.getNotificationService();
+			if (n != null) {
+				n.showGroupJoinResponseNotification(outgoingGroupJoinRequestModel, status, serviceManager.getDatabaseServiceNew());
+			}
+		});
+
+
+		ListenerManager.incomingGroupJoinRequestListener.add(new IncomingGroupJoinRequestListener() {
+			@Override
+			public void onReceived(IncomingGroupJoinRequestModel incomingGroupJoinRequestModel, GroupModel groupModel) {
+				NotificationService n = serviceManager.getNotificationService();
+				if (n != null) {
+					n.showGroupJoinRequestNotification(incomingGroupJoinRequestModel, groupModel);
+				}
+			}
+
+			@Override
+			public void onRespond() {
+				// don't bother here
+			}
+		});
+
 		ListenerManager.serverMessageListeners.add(new ServerMessageListener() {
 			@Override
 			public void onAlert(ServerMessageModel serverMessage) {
@@ -1426,6 +1477,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			public void onRemoved(ContactModel removedContactModel) {
 				try {
 					serviceManager.getConversationService().removed(removedContactModel);
+					serviceManager.getShortcutService().deleteShortcut(removedContactModel);
 
 					//remove notification from this contact
 
@@ -1434,7 +1486,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 							(
 									removedContactModel,
 									serviceManager.getContactService(),
-									null, null, null, null));
+									null, null, null, null, serviceManager.getApiService()));
 
 					//remove custom avatar (ANDR-353)
 					FileService f = serviceManager.getFileService();

+ 19 - 6
app/src/main/java/ch/threema/app/actions/LocationMessageSendAction.java

@@ -23,6 +23,10 @@ package ch.threema.app.actions;
 
 import android.location.Location;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import androidx.annotation.NonNull;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.messagereceiver.MessageReceiver;
@@ -32,6 +36,8 @@ import ch.threema.base.ThreemaException;
 import ch.threema.storage.models.AbstractMessageModel;
 
 public class LocationMessageSendAction extends SendAction {
+	private static final Logger logger = LoggerFactory.getLogger(LocationMessageSendAction.class);
+
 	protected static volatile LocationMessageSendAction instance;
 	private static final Object instanceLock = new Object();
 
@@ -52,11 +58,12 @@ public class LocationMessageSendAction extends SendAction {
 		return instance;
 	}
 
-	public boolean sendLocationMessage(final MessageReceiver[] allReceivers,
-									   final Location location,
-									   final String poiName,
-									   final ActionHandler actionHandler) {
-
+	public boolean sendLocationMessage(
+		final MessageReceiver[] allReceivers,
+		final Location location,
+		final String poiName,
+		final ActionHandler actionHandler
+	) {
 		if (actionHandler == null) {
 			return false;
 		}
@@ -114,7 +121,12 @@ public class LocationMessageSendAction extends SendAction {
 		return true;
 	}
 
-	private void sendSingleMessage(final MessageReceiver messageReceiver, final Location location, final String poiName, final ActionHandler actionHandler) {
+	private void sendSingleMessage(
+		final MessageReceiver messageReceiver,
+		final @NonNull Location location,
+		final String poiName,
+		final @NonNull ActionHandler actionHandler
+	) {
 		if (messageReceiver == null) {
 			actionHandler.onError("No receiver");
 			return;
@@ -140,6 +152,7 @@ public class LocationMessageSendAction extends SendAction {
 						}
 					});
 		} catch (final Exception e) {
+			logger.error("Could not send location message", e);
 			actionHandler.onError(e.getMessage());
 		}
 	}

+ 12 - 5
app/src/main/java/ch/threema/app/actions/TextMessageSendAction.java

@@ -21,7 +21,9 @@
 
 package ch.threema.app.actions;
 
-import java.io.UnsupportedEncodingException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import java.util.ArrayList;
 
 import ch.threema.app.messagereceiver.MessageReceiver;
@@ -30,9 +32,11 @@ import ch.threema.app.utils.MessageUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TextUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.client.ProtocolDefines;
+import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class TextMessageSendAction extends SendAction {
+	private static final Logger logger = LoggerFactory.getLogger(TextMessageSendAction.class);
+
 	protected static volatile TextMessageSendAction instance;
 	private static final Object instanceLock = new Object();
 
@@ -51,9 +55,11 @@ public class TextMessageSendAction extends SendAction {
 		return instance;
 	}
 
-	public boolean sendTextMessage(final MessageReceiver[] allReceivers,
-	                               String text,
-	                               final ActionHandler actionHandler) {
+	public boolean sendTextMessage(
+		final MessageReceiver[] allReceivers,
+		String text,
+		final ActionHandler actionHandler
+	) {
 
 		if (actionHandler == null) {
 			return false;
@@ -93,6 +99,7 @@ public class TextMessageSendAction extends SendAction {
 						messageService.sendText(messageText, receiver);
 					}
 				} catch (final Exception e) {
+					logger.error("Could not send text message", e);
 					actionHandler.onError(e.getMessage());
 					return false;
 				}

+ 37 - 94
app/src/main/java/ch/threema/app/activities/AddContactActivity.java

@@ -36,7 +36,6 @@ import android.widget.Toast;
 import com.google.android.material.snackbar.BaseTransientBottomBar;
 import com.google.android.material.snackbar.Snackbar;
 
-import java.io.IOException;
 import java.util.Date;
 
 import androidx.annotation.NonNull;
@@ -58,17 +57,13 @@ import ch.threema.app.services.QRCodeService;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
-import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.LogUtil;
 import ch.threema.app.utils.QRScannerUtil;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.app.webclient.services.QRCodeParser;
-import ch.threema.app.webclient.services.QRCodeParserImpl;
-import ch.threema.client.Base64;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
 
-import static ch.threema.client.ProtocolDefines.IDENTITY_LEN;
+import static ch.threema.domain.protocol.csp.ProtocolDefines.IDENTITY_LEN;
 
 public class AddContactActivity extends ThreemaActivity implements GenericAlertDialog.DialogClickListener, NewContactDialog.NewContactDialogClickListener {
 	private static final String DIALOG_TAG_ADD_PROGRESS = "ap";
@@ -76,7 +71,7 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 	private static final String DIALOG_TAG_ADD_USER = "au";
 	private static final String DIALOG_TAG_ADD_BY_ID = "abi";
 	public static final String EXTRA_ADD_BY_ID = "add_by_id";
-	public static final String EXTRA_ADD_BY_QR = "add_by_qr";
+	public static final String EXTRA_QR_RESULT = "qr_result";
 
 	private static final int PERMISSION_REQUEST_CAMERA = 1;
 
@@ -146,9 +141,8 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 					}
 				}
 			}
-
-			if (intent.getBooleanExtra(EXTRA_ADD_BY_QR, false)) {
-				scanQR();
+			if (intent.getStringExtra(EXTRA_QR_RESULT) != null) {
+				parseQrResult(intent.getStringExtra(EXTRA_QR_RESULT));
 			}
 
 			if (intent.getBooleanExtra(EXTRA_ADD_BY_ID, false)) {
@@ -157,6 +151,32 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 		}
 	}
 
+	private void parseQrResult(String payload) {
+		// first: try to parse as contact result (contact scan)
+		QRCodeService.QRCodeContentResult contactQRCode = this.qrCodeService.getResult(payload);
+
+		if (contactQRCode != null) {
+			addContactByQRResult(contactQRCode);
+			return;
+		}
+
+		// second: try uri scheme
+		String scannedIdentity = null;
+		Uri uri = Uri.parse(payload);
+		if (uri != null) {
+			String scheme = uri.getScheme();
+			if (BuildConfig.uriScheme.equals(scheme) && "add".equals(uri.getAuthority())) {
+				scannedIdentity = uri.getQueryParameter("id");
+			} else if ("https".equals(scheme) && BuildConfig.contactActionUrl.equals(uri.getHost())) {
+				scannedIdentity = uri.getLastPathSegment();
+			}
+
+			if (scannedIdentity != null && scannedIdentity.length() == IDENTITY_LEN) {
+				addContactByIdentity(scannedIdentity);
+			}
+		}
+	}
+
 	@SuppressLint("StaticFieldLeak")
 	private void addContactByIdentity(final String identity) {
 		if (lockAppService.isLocked()) {
@@ -216,25 +236,14 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 		super.onDestroy();
 	}
 
-	/**
-	 * start a web client session (payload must be validated before
-	 * the method is called)
-	 *
-	 * fix #ANDR-570
-	 * @param payload a valid payload
-	 */
-	private void startWebClientByQRResult(final byte[] payload) {
-		if (payload != null) {
-			// start web client session screen with payload data and finish my screen
-			Intent webClientIntent = new Intent(this, ch.threema.app.webclient.activities.SessionsActivity.class);
-			IntentDataUtil.append(payload, webClientIntent);
-			this.finish();
-			startActivity(webClientIntent);
-		}
-	}
-
 	@SuppressLint("StaticFieldLeak")
 	private void addContactByQRResult(final QRCodeService.QRCodeContentResult qrResult) {
+		if (qrResult.getExpirationDate() != null
+			&& qrResult.getExpirationDate().before(new Date())) {
+			GenericAlertDialog.newInstance(R.string.title_adduser, getString(R.string.expired_barcode), R.string.ok, 0).show(getSupportFragmentManager(), "ex");
+			return;
+		}
+
 		ContactModel contactModel = contactService.getByPublicKey(qrResult.getPublicKey());
 
 		if (contactModel != null) {
@@ -327,7 +336,7 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 
 	private void scanQR() {
 		if (ConfigUtils.requestCameraPermissions(this, null, PERMISSION_REQUEST_CAMERA)) {
-			QRScannerUtil.getInstance().initiateScan(this, false, null);
+			QRScannerUtil.getInstance().initiateGeneralThreemaQrScanner(this, getString(R.string.qr_scanner_id_hint));
 		}
 	}
 
@@ -340,72 +349,6 @@ public class AddContactActivity extends ThreemaActivity implements GenericAlertD
 		dialogFragment.show(getSupportFragmentManager(), DIALOG_TAG_ADD_BY_ID);
 	}
 
-	@Override
-	public void onActivityResult(int requestCode, int resultCode, Intent intent) {
-		super.onActivityResult(requestCode, resultCode, intent);
-
-		ConfigUtils.setLocaleOverride(this, serviceManager.getPreferenceService());
-
-		if (resultCode == RESULT_OK) {
-			String payload = QRScannerUtil.getInstance().parseActivityResult(this, requestCode, resultCode, intent);
-
-			if (!TestUtil.empty(payload)) {
-
-				// first: try to parse as content result (contact scan)
-				QRCodeService.QRCodeContentResult contactQRCode = this.qrCodeService.getResult(payload);
-
-				if (contactQRCode != null) {
-					// ok, try to add contact
-					if (contactQRCode.getExpirationDate() != null
-							&& contactQRCode.getExpirationDate().before(new Date())) {
-						GenericAlertDialog.newInstance(R.string.title_adduser, getString(R.string.expired_barcode), R.string.ok, 0).show(getSupportFragmentManager(), "ex");
-					} else {
-						addContactByQRResult(contactQRCode);
-					}
-
-					// return, qr code valid and exit method
-					DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_ADD_BY_ID, true);
-					return;
-				}
-
-				// second: try uri scheme
-				String scannedIdentity = null;
-				Uri uri = Uri.parse(payload);
-				if (uri != null) {
-					String scheme = uri.getScheme();
-					if (BuildConfig.uriScheme.equals(scheme) && "add".equals(uri.getAuthority())) {
-						scannedIdentity = uri.getQueryParameter("id");
-					} else if ("https".equals(scheme) && BuildConfig.contactActionUrl.equals(uri.getHost())) {
-						scannedIdentity = uri.getLastPathSegment();
-					}
-
-					if (scannedIdentity != null && scannedIdentity.length() == IDENTITY_LEN) {
-						addContactByIdentity(scannedIdentity);
-						return;
-					}
-				}
-
-				// third: try to parse as web client qr
-				try {
-					byte[] base64Payload = Base64.decode(payload);
-					if (base64Payload != null) {
-						final QRCodeParser webClientQRCodeParser = new QRCodeParserImpl();
-						webClientQRCodeParser.parse(base64Payload); // throws if QR is not valid
-						// it was a valid web client qr code, exit method
-						startWebClientByQRResult(base64Payload);
-						return;
-					}
-				} catch (IOException | QRCodeParser.InvalidQrCodeException x) {
-					// not a valid base64 or web client payload
-					// ignore and continue
-				}
-			}
-			Toast.makeText(this, R.string.invalid_barcode, Toast.LENGTH_SHORT).show();
-		}
-
-		finish();
-	}
-
 	@Override
 	public void onYes(String tag, Object data) {
 		finish();

+ 45 - 35
app/src/main/java/ch/threema/app/activities/AppLinksActivity.java

@@ -26,19 +26,17 @@ import android.net.Uri;
 import android.os.Bundle;
 import android.widget.Toast;
 
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
+import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.asynctasks.AddContactAsyncTask;
+import ch.threema.app.grouplinks.OutgoingGroupRequestActivity;
 import ch.threema.app.services.LockAppService;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.HiddenChatUtil;
-import ch.threema.client.ProtocolDefines;
+import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class AppLinksActivity extends ThreemaToolbarActivity {
-	private static final Logger logger = LoggerFactory.getLogger(AppLinksActivity.class);
 
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
@@ -81,40 +79,53 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
 	private void handleIntent() {
 		String appLinkAction = getIntent().getAction();
 		final Uri appLinkData = getIntent().getData();
-		if (Intent.ACTION_VIEW.equals(appLinkAction) && appLinkData != null){
-			final String threemaId = appLinkData.getLastPathSegment();
-			if (threemaId != null) {
-				if (threemaId.equalsIgnoreCase("compose")) {
-					Intent intent = new Intent(this, RecipientListActivity.class);
-					intent.setAction(appLinkAction);
-					intent.setData(appLinkData);
+		if (Intent.ACTION_VIEW.equals(appLinkAction) && appLinkData.getHost().equals(BuildConfig.contactActionUrl)) {
+			handleContactUrl(appLinkAction, appLinkData);
+		}
+		else if (Intent.ACTION_VIEW.equals(appLinkAction) && appLinkData.getHost().equals(BuildConfig.groupLinkActionUrl)) {
+			handleGroupLinkUrl(appLinkData);
+		}
+		finish();
+	}
+
+	private void handleContactUrl(String appLinkAction, Uri appLinkData) {
+		final String threemaId = appLinkData.getLastPathSegment();
+		if (threemaId != null) {
+			if (threemaId.equalsIgnoreCase("compose")) {
+				Intent intent = new Intent(this, RecipientListActivity.class);
+				intent.setAction(appLinkAction);
+				intent.setData(appLinkData);
+				startActivity(intent);
+			} else if (threemaId.length() == ProtocolDefines.IDENTITY_LEN) {
+				new AddContactAsyncTask(null, null, threemaId, false, () -> {
+					String text = appLinkData.getQueryParameter("text");
+
+					Intent intent = new Intent(AppLinksActivity.this, text != null ?
+						ComposeMessageActivity.class :
+						ContactDetailActivity.class);
+					intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+					intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, threemaId);
+					intent.putExtra(ThreemaApplication.INTENT_DATA_EDITFOCUS, Boolean.TRUE);
+
+					if (text != null) {
+						text = text.trim();
+						intent.putExtra(ThreemaApplication.INTENT_DATA_TEXT, text);
+					}
+
 					startActivity(intent);
-				} else if (threemaId.length() == ProtocolDefines.IDENTITY_LEN) {
-					new AddContactAsyncTask(null, null, threemaId, false, () -> {
-						String text = appLinkData.getQueryParameter("text");
-
-						Intent intent = new Intent(AppLinksActivity.this, text != null ?
-							ComposeMessageActivity.class :
-							ContactDetailActivity.class);
-						intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
-						intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, threemaId);
-						intent.putExtra(ThreemaApplication.INTENT_DATA_EDITFOCUS, Boolean.TRUE);
-
-						if (text != null) {
-							text = text.trim();
-							intent.putExtra(ThreemaApplication.INTENT_DATA_TEXT, text);
-						}
-
-						startActivity(intent);
-					}).execute();
-				} else {
-					Toast.makeText(this, R.string.invalid_input, Toast.LENGTH_LONG).show();
-				}
+				}).execute();
 			} else {
 				Toast.makeText(this, R.string.invalid_input, Toast.LENGTH_LONG).show();
 			}
+		} else {
+			Toast.makeText(this, R.string.invalid_input, Toast.LENGTH_LONG).show();
 		}
-		finish();
+	}
+
+	private void handleGroupLinkUrl(Uri appLinkData) {
+		Intent intent = new Intent(AppLinksActivity.this, OutgoingGroupRequestActivity.class);
+		intent.putExtra(ThreemaApplication.INTENT_DATA_GROUP_LINK, appLinkData.getEncodedFragment());
+		startActivity(intent);
 	}
 
 	@Override
@@ -125,7 +136,6 @@ public class AppLinksActivity extends ThreemaToolbarActivity {
 
 	@Override
 	protected void onActivityResult(int requestCode, int resultCode, Intent data) {
-		logger.debug("onActivityResult");
 		switch (requestCode) {
 			case ThreemaActivity.ACTIVITY_ID_CHECK_LOCK:
 				if (resultCode == RESULT_OK) {

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

@@ -98,7 +98,7 @@ import ch.threema.app.utils.ViewUtil;
 import ch.threema.app.voip.services.VoipStateService;
 import ch.threema.app.voip.util.VoipUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.base.VerificationLevel;
+import ch.threema.domain.models.VerificationLevel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 
@@ -883,6 +883,11 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		switch (tag) {
 			case DIALOG_TAG_DELETE_CONTACT:
 				deleteContact((ContactModel) data);
+				try {
+					serviceManager.getShortcutService().deleteShortcut((ContactModel) data);
+				} catch (ThreemaException e) {
+					logger.error("Exception, failed to delete direct share shortcut", e);
+				}
 				break;
 			case DIALOG_TAG_EXCLUDE_CONTACT:
 				removeContactConfirmed(true, (ContactModel) data);

+ 8 - 14
app/src/main/java/ch/threema/app/activities/DirectoryActivity.java

@@ -69,9 +69,9 @@ import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.LogUtil;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.client.work.WorkDirectoryCategory;
-import ch.threema.client.work.WorkDirectoryContact;
-import ch.threema.client.work.WorkOrganization;
+import ch.threema.domain.protocol.api.work.WorkDirectoryCategory;
+import ch.threema.domain.protocol.api.work.WorkDirectoryContact;
+import ch.threema.domain.protocol.api.work.WorkOrganization;
 
 public class DirectoryActivity extends ThreemaToolbarActivity implements ThreemaSearchView.OnQueryTextListener, MultiChoiceSelectorDialog.SelectorDialogClickListener {
 	private static final Logger logger = LoggerFactory.getLogger(DirectoryActivity.class);
@@ -90,15 +90,15 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 	private ChipGroup chipGroup;
 
 	private List<WorkDirectoryCategory> categoryList = new ArrayList<>();
-	private List<WorkDirectoryCategory> checkedCategories = new ArrayList<>();
+	private final List<WorkDirectoryCategory> checkedCategories = new ArrayList<>();
 
 	private String queryText;
 
 	@ColorInt int categorySpanColor;
 	@ColorInt int categorySpanTextColor;
 
-	private Handler queryHandler = new Handler();
-	private Runnable queryTask = new Runnable() {
+	private final Handler queryHandler = new Handler();
+	private final Runnable queryTask = new Runnable() {
 		@Override
 		public void run() {
 			directoryDataSourceFactory.postLiveData.getValue().setQueryText(queryText);
@@ -161,13 +161,7 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 
 		categoryList = preferenceService.getWorkDirectoryCategories();
 
-		if (categoryList.size() > 0) {
-			if (ConfigUtils.getAppTheme(this) == ConfigUtils.THEME_DARK) {
-				ConfigUtils.themeImageView(this, findViewById(R.id.category_selector_button));
-			}
-		} else {
-			findViewById(R.id.category_selector_button).setVisibility(View.GONE);
-		}
+		findViewById(R.id.category_selector_button).setVisibility(categoryList.size() > 0 ? View.VISIBLE : View.GONE);
 
 		WorkOrganization workOrganization = preferenceService.getWorkOrganization();
 		if (workOrganization != null && !TestUtil.empty(workOrganization.getName())) {
@@ -335,7 +329,7 @@ public class DirectoryActivity extends ThreemaToolbarActivity implements Threema
 
 				Chip chip = new Chip(this);
 				if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
-					chip.setTextAppearance(R.style.TextAppearance_Chip_Ballot);
+					chip.setTextAppearance(R.style.TextAppearance_Chip_ChatNotice);
 				} else {
 					chip.setTextSize(14);
 				}

+ 135 - 63
app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java

@@ -49,8 +49,10 @@ import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
+import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.push.PushService;
 import ch.threema.app.services.AppRestrictionService;
+import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.services.license.LicenseServiceUser;
 import ch.threema.app.services.license.SerialCredentials;
@@ -59,6 +61,7 @@ import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.EditTextUtil;
+import ch.threema.app.utils.LocaleUtil;
 import ch.threema.app.utils.TestUtil;
 
 // this should NOT extend ThreemaToolbarActivity
@@ -67,14 +70,15 @@ public class EnterSerialActivity extends ThreemaActivity {
 
 	private static final String BUNDLE_PASSWORD = "bupw";
 	private static final String BUNDLE_LICENSE_KEY = "bulk";
+	private static final String BUNDLE_SERVER = "busv";
 	private static final String DIALOG_TAG_CHECKING = "check";
 	private TextView stateTextView, privateExplainText = null;
-	private EditText licenseKeyText, passwordText;
+	private EditText licenseKeyOrUsernameText, passwordText, serverText;
 	private ImageView unlockButton;
 	private Button loginButton;
 	private LicenseService licenseService;
+	private PreferenceService preferenceService;
 
-	@SuppressLint("StringFormatInvalid")
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
 
@@ -85,8 +89,18 @@ public class EnterSerialActivity extends ThreemaActivity {
 
 		setContentView(R.layout.activity_enter_serial);
 
+		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+
+		if (serviceManager == null) {
+			// hide keyboard to make error message visible on low resolution displays
+			EditTextUtil.hideSoftKeyboard(this.licenseKeyOrUsernameText);
+			Toast.makeText(this, "Service Manager not available", Toast.LENGTH_LONG).show();
+			return;
+		}
+
 		try {
-			licenseService = ThreemaApplication.getServiceManager().getLicenseService();
+			licenseService = serviceManager.getLicenseService();
+			preferenceService = serviceManager.getPreferenceService();
 		} catch (NullPointerException|FileSystemNotPresentException e) {
 			logger.error("Exception", e);
 			Toast.makeText(this, "Service Manager not available", Toast.LENGTH_LONG).show();
@@ -100,60 +114,88 @@ public class EnterSerialActivity extends ThreemaActivity {
 		}
 
 		stateTextView = findViewById(R.id.unlock_state);
-		licenseKeyText = findViewById(R.id.passphrase);
-		passwordText = findViewById(R.id.password);
-
-		if (!ConfigUtils.isWorkBuild()) {
-			licenseKeyText.addTextChangedListener(new PasswordWatcher());
-			licenseKeyText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
-			licenseKeyText.setFilters(new InputFilter[]{new InputFilter.AllCaps(), new InputFilter.LengthFilter(11)});
-			licenseKeyText.setOnKeyListener(new View.OnKeyListener() {
-				@Override
-				public boolean onKey(View v, int keyCode, KeyEvent event) {
-					if (event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
-						if (licenseKeyText.getText().length() == 11) {
-							doUnlock();
-						}
-						return true;
-					}
-					return false;
-				}
-			});
-			unlockButton = findViewById(R.id.unlock_button);
-			unlockButton.setOnClickListener(new View.OnClickListener() {
-				@Override
-				public void onClick(View v) {
-					doUnlock();
-				}
-			});
+		licenseKeyOrUsernameText = findViewById(R.id.license_key);
+		passwordText = findViewById(getResources().getIdentifier("password", "id", getPackageName()));
+		serverText = findViewById(getResources().getIdentifier("server", "id", getPackageName()));
 
-			this.enableLogin(false);
+		if (!ConfigUtils.isWorkBuild() && !ConfigUtils.isOnPremBuild()) {
+			setupForShopBuild();
 		} else {
-			privateExplainText = findViewById(R.id.private_explain);
-			if (privateExplainText != null) {
-				if (PushService.hmsServicesInstalled(this)) {
-					privateExplainText.setText(Html.fromHtml(String.format(getString(R.string.private_threema_download), getString(R.string.private_download_url_hms))));
-				}
-				else {
-					privateExplainText.setText(Html.fromHtml(String.format(getString(R.string.private_threema_download), getString(R.string.private_download_url))));
+			setupForWorkBuild();
+		}
+
+		handleUrlIntent();
+	}
+
+	private void setupForShopBuild() {
+		licenseKeyOrUsernameText.addTextChangedListener(new PasswordWatcher());
+		licenseKeyOrUsernameText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+		licenseKeyOrUsernameText.setFilters(new InputFilter[]{new InputFilter.AllCaps(), new InputFilter.LengthFilter(11)});
+		licenseKeyOrUsernameText.setOnKeyListener(new View.OnKeyListener() {
+			@Override
+			public boolean onKey(View v, int keyCode, KeyEvent event) {
+				if (event.getAction() == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
+					if (licenseKeyOrUsernameText.getText().length() == 11) {
+						doUnlock();
+					}
+					return true;
 				}
-				privateExplainText.setClickable(true);
-				privateExplainText.setMovementMethod (LinkMovementMethod.getInstance());
+				return false;
 			}
-			licenseKeyText.addTextChangedListener(new TextChangeWatcher());
-			passwordText.addTextChangedListener(new TextChangeWatcher());
-			loginButton = findViewById(R.id.unlock_button_work);
-			loginButton.setOnClickListener(new View.OnClickListener() {
-				@Override
-				public void onClick(View v) {
-					doUnlock();
-				}
-			});
+		});
+		unlockButton = findViewById(R.id.unlock_button);
+		unlockButton.setOnClickListener(new View.OnClickListener() {
+			@Override
+			public void onClick(View v) {
+				doUnlock();
+			}
+		});
+
+		this.enableLogin(false);
+	}
 
-			//always enable login button
-			this.enableLogin(true);
+	@SuppressLint("StringFormatInvalid")
+	private void setupForWorkBuild() {
+		privateExplainText = findViewById(R.id.private_explain);
+
+		if (privateExplainText != null) {
+			String workInfoUrl = String.format(getString(R.string.threema_work_url), LocaleUtil.getAppLanguage());
+
+			if (PushService.hmsServicesInstalled(this)) {
+				privateExplainText.setText(Html.fromHtml(
+					String.format(getString(R.string.private_threema_download),
+						workInfoUrl,
+						getString(R.string.private_download_url)
+						)
+					)
+				);
+			}
+			else {
+				privateExplainText.setText(Html.fromHtml
+					(String.format(getString(R.string.private_threema_download),
+						workInfoUrl,
+						getString(R.string.private_download_url))
+					)
+				);
+			}
+			privateExplainText.setClickable(true);
+			privateExplainText.setMovementMethod(LinkMovementMethod.getInstance());
 		}
+		licenseKeyOrUsernameText.addTextChangedListener(new TextChangeWatcher());
+		passwordText.addTextChangedListener(new TextChangeWatcher());
+		loginButton = findViewById(getResources().getIdentifier("unlock_button_work", "id", getPackageName()));
+		loginButton.setOnClickListener(new View.OnClickListener() {
+			@Override
+			public void onClick(View v) {
+				doUnlock();
+			}
+		});
+
+		//always enable login button
+		this.enableLogin(true);
+	}
 
+	private void handleUrlIntent() {
 		String scheme = null;
 		Uri data = null;
 		Intent intent = getIntent();
@@ -183,8 +225,10 @@ public class EnterSerialActivity extends ThreemaActivity {
 			if (ConfigUtils.isWorkRestricted()) {
 				String username = AppRestrictionUtil.getStringRestriction(getString(R.string.restriction__license_username));
 				String password = AppRestrictionUtil.getStringRestriction(getString(R.string.restriction__license_password));
+				String server = AppRestrictionUtil.getStringRestriction(getString(R.string.restriction__onprem_server));
+
 				if (!TestUtil.empty(username) && !TestUtil.empty(password)) {
-					check(new UserCredentials(username, password));
+					check(new UserCredentials(username, password), server);
 				}
 			}
 		} else {
@@ -197,7 +241,7 @@ public class EnterSerialActivity extends ThreemaActivity {
 	}
 
 	private void enableLogin(boolean enable) {
-		if (!ConfigUtils.isWorkBuild()) {
+		if (!ConfigUtils.isWorkBuild() && !ConfigUtils.isOnPremBuild()) {
 			if (this.unlockButton != null) {
 				unlockButton.setClickable(enable);
 				unlockButton.setEnabled(enable);
@@ -217,14 +261,15 @@ public class EnterSerialActivity extends ThreemaActivity {
 			if (licenseService instanceof LicenseServiceUser) {
 				final String username = data.getQueryParameter("username");
 				final String password = data.getQueryParameter("password");
+				final String server = data.getQueryParameter("server");
 				if (!TestUtil.empty(username) && !TestUtil.empty(password)) {
-					check(new UserCredentials(username, password));
+					check(new UserCredentials(username, password), server);
 					return;
 				}
 			} else {
 				final String key = data.getQueryParameter("key");
 				if (!TestUtil.empty(key)) {
-					check(new SerialCredentials(key));
+					check(new SerialCredentials(key), null);
 					return;
 				}
 			}
@@ -234,19 +279,26 @@ public class EnterSerialActivity extends ThreemaActivity {
 
 	private void doUnlock() {
 		// hide keyboard to make error message visible on low resolution displays
-		EditTextUtil.hideSoftKeyboard(this.licenseKeyText);
+		EditTextUtil.hideSoftKeyboard(this.licenseKeyOrUsernameText);
 
 		this.enableLogin(false);
 
-		if (ConfigUtils.isWorkBuild()) {
-			if (!TestUtil.empty(this.licenseKeyText.getText().toString()) && !TestUtil.empty(this.passwordText.getText().toString())) {
-				this.check(new UserCredentials(this.licenseKeyText.getText().toString(), this.passwordText.getText().toString()));
+		if (ConfigUtils.isOnPremBuild()) {
+			if (!TestUtil.empty(this.licenseKeyOrUsernameText.getText().toString()) && !TestUtil.empty(this.passwordText.getText().toString()) && !TestUtil.empty(this.serverText.getText().toString())) {
+				this.check(new UserCredentials(this.licenseKeyOrUsernameText.getText().toString(), this.passwordText.getText().toString()), this.serverText.getText().toString());
+			} else {
+				this.enableLogin(true);
+				this.stateTextView.setText(getString(R.string.invalid_input));
+			}
+		} else if (ConfigUtils.isWorkBuild()) {
+			if (!TestUtil.empty(this.licenseKeyOrUsernameText.getText().toString()) && !TestUtil.empty(this.passwordText.getText().toString())) {
+				this.check(new UserCredentials(this.licenseKeyOrUsernameText.getText().toString(), this.passwordText.getText().toString()), null);
 			} else {
 				this.enableLogin(true);
 				this.stateTextView.setText(getString(R.string.invalid_input));
 			}
 		} else {
-			this.check(new SerialCredentials(this.licenseKeyText.getText().toString()));
+			this.check(new SerialCredentials(this.licenseKeyOrUsernameText.getText().toString()), null);
 		}
 	}
 
@@ -298,17 +350,37 @@ public class EnterSerialActivity extends ThreemaActivity {
 	protected void onSaveInstanceState(Bundle outState) {
 		super.onSaveInstanceState(outState);
 
-		if (!TestUtil.empty(licenseKeyText.getText())) {
-			outState.putString(BUNDLE_LICENSE_KEY, licenseKeyText.getText().toString());
+		if (!TestUtil.empty(licenseKeyOrUsernameText.getText())) {
+			outState.putString(BUNDLE_LICENSE_KEY, licenseKeyOrUsernameText.getText().toString());
 		}
 
-		if (!TestUtil.empty(passwordText.getText())) {
+		if (passwordText != null && !TestUtil.empty(passwordText.getText())) {
 			outState.putString(BUNDLE_PASSWORD, passwordText.getText().toString());
 		}
+
+		if (serverText != null && !TestUtil.empty(serverText.getText())) {
+			outState.putString(BUNDLE_SERVER, serverText.getText().toString());
+		}
 	}
 
 	@SuppressLint("StaticFieldLeak")
-	private void check(final LicenseService.Credentials credentials) {
+	private void check(final LicenseService.Credentials credentials, String onPremServer) {
+		if (ConfigUtils.isOnPremBuild()) {
+			if (onPremServer != null) {
+				if (!onPremServer.startsWith("https://")) {
+					onPremServer = "https://" + onPremServer;
+				}
+
+				if (!onPremServer.endsWith(".oppf")) {
+					// Automatically expand hostnames to default provisioning URL
+					onPremServer += "/prov/config.oppf";
+				}
+			}
+			preferenceService.setOnPremServer(onPremServer);
+			preferenceService.setLicenseUsername(((UserCredentials)credentials).username);
+			preferenceService.setLicensePassword(((UserCredentials)credentials).password);
+		}
+
 		new AsyncTask<Void, Void, String>() {
 			@Override
 			protected void onPreExecute() {

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

@@ -42,7 +42,7 @@ import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.client.IdentityBackupGenerator;
+import ch.threema.domain.identitybackup.IdentityBackupGenerator;
 
 public class ExportIDActivity extends AppCompatActivity implements PasswordEntryDialog.PasswordEntryDialogClickListener {
 	private static final Logger logger = LoggerFactory.getLogger(AppCompatActivity.class);
@@ -79,7 +79,7 @@ public class ExportIDActivity extends AppCompatActivity implements PasswordEntry
 				ThreemaApplication.MIN_PW_LENGTH_BACKUP,
 				ThreemaApplication.MAX_PW_LENGTH_BACKUP,
 				R.string.backup_password_again_summary,
-				0, 0);
+				0, 0, PasswordEntryDialog.ForgotHintType.NONE);
 		dialogFragment.show(getSupportFragmentManager(), DIALOG_TAG_SET_ID_BACKUP_PW);
 	}
 

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

@@ -26,7 +26,6 @@ import android.content.Intent;
 import android.content.res.Configuration;
 import android.graphics.Bitmap;
 import android.graphics.drawable.BitmapDrawable;
-import android.os.Build;
 import android.os.Bundle;
 import android.print.PrintAttributes;
 import android.print.PrintDocumentAdapter;
@@ -176,11 +175,7 @@ public class ExportIDResultActivity extends ThreemaToolbarActivity implements Ge
 				.getSystemService(Context.PRINT_SERVICE);
 
 		PrintDocumentAdapter printAdapter;
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
-			printAdapter = webView.createPrintDocumentAdapter("Threema_ID_" + identity);
-		} else {
-			printAdapter = webView.createPrintDocumentAdapter();
-		}
+		printAdapter = webView.createPrintDocumentAdapter("Threema_ID_" + identity);
 		String jobName = getString(R.string.app_name) + " " + getString(R.string.backup_id_title);
 
 		printManager.print(jobName, printAdapter,

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

@@ -30,6 +30,7 @@ import java.util.Arrays;
 import java.util.List;
 
 import androidx.annotation.NonNull;
+import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericAlertDialog;
@@ -120,8 +121,8 @@ public class GroupAddActivity extends MemberChooseActivity implements GenericAle
 		final int previousContacts = this.appendMembers ? excludedIdentities.size() : 1; // user counts as one contact
 
 		if (selectedContacts.size() >= ThreemaApplication.MIN_GROUP_MEMBERS_COUNT) {
-			if ((previousContacts + selectedContacts.size()) > getResources().getInteger(R.integer.max_group_size)) {
-				Toast.makeText(this, String.format(getString(R.string.group_select_max), getResources().getInteger(R.integer.max_group_size) - previousContacts), Toast.LENGTH_LONG).show();
+			if ((previousContacts + selectedContacts.size()) > BuildConfig.MAX_GROUP_SIZE) {
+				Toast.makeText(this, String.format(getString(R.string.group_select_max), BuildConfig.MAX_GROUP_SIZE - previousContacts), Toast.LENGTH_LONG).show();
 			} else {
 				createOrUpdateGroup(selectedContacts);
 			}

+ 155 - 109
app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java

@@ -62,6 +62,7 @@ import androidx.lifecycle.ViewModelProvider;
 import androidx.palette.graphics.Palette;
 import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
+import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.adapters.GroupDetailAdapter;
@@ -71,20 +72,24 @@ import ch.threema.app.asynctasks.LeaveGroupAsyncTask;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.SelectorDialog;
+import ch.threema.app.dialogs.ShowOnceDialog;
 import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.dialogs.TextEntryDialog;
 import ch.threema.app.emojis.EmojiEditText;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
+import ch.threema.app.grouplinks.GroupLinkOverviewActivity;
 import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactSettingsListener;
 import ch.threema.app.listeners.GroupListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.services.DeviceService;
 import ch.threema.app.services.IdListService;
+import ch.threema.app.services.group.GroupInviteService;
 import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.ui.AvatarEditView;
 import ch.threema.app.ui.GroupDetailViewModel;
 import ch.threema.app.ui.ResumePauseHandler;
+import ch.threema.app.ui.SelectorDialogItem;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.BitmapUtil;
 import ch.threema.app.utils.ConfigUtils;
@@ -96,14 +101,20 @@ import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.voip.util.VoipUtil;
+import ch.threema.base.ThreemaException;
+import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 
 import static ch.threema.app.dialogs.ContactEditDialog.CONTACT_AVATAR_HEIGHT_PX;
 
-public class GroupDetailActivity extends GroupEditActivity implements SelectorDialog.SelectorDialogClickListener, GenericAlertDialog.DialogClickListener, TextEntryDialog.TextEntryDialogClickListener {
+public class GroupDetailActivity extends GroupEditActivity implements SelectorDialog.SelectorDialogClickListener,
+	GenericAlertDialog.DialogClickListener,
+	TextEntryDialog.TextEntryDialogClickListener,
+	GroupDetailAdapter.OnGroupDetailsClickListener
+	{
 	private static final Logger logger = LoggerFactory.getLogger(GroupDetailActivity.class);
-
+	// static values
 	private final int MODE_EDIT = 1;
 	private final int MODE_READONLY = 2;
 
@@ -116,6 +127,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	private static final String DIALOG_TAG_CLONE_GROUP = "cg";
 	private static final String DIALOG_TAG_CLONE_GROUP_CONFIRM = "cgc";
 	private static final String DIALOG_TAG_CLONING_GROUP = "cgi";
+	public static final String DIALOG_SHOW_ONCE_RESET_LINK_INFO = "resetGroupLink";
 	private static final String RUN_ON_ACTIVE_RELOAD = "reload";
 
 	private static final int SELECTOR_OPTION_CONTACT_DETAIL = 0;
@@ -123,24 +135,27 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	private static final int SELECTOR_OPTION_CALL = 2;
 	private static final int SELECTOR_OPTION_REMOVE = 3;
 
-	private int operationMode;
-	private int groupId;
-	private EmojiEditText groupNameEditText;
-	private boolean hasChanges = false;
-
-	private String myIdentity;
+	// services
+	private LicenseService licenseService;
+	private GroupInviteService groupInviteService;
+	private DeviceService deviceService;
+	private IdListService blackListIdentityService;
 
 	private GroupModel groupModel;
+	private GroupDetailViewModel groupDetailViewModel;
 	private GroupDetailAdapter groupDetailAdapter;
+
+	private EmojiEditText groupNameEditText;
 	private CollapsingToolbarLayout collapsingToolbar;
 	private ResumePauseHandler resumePauseHandler;
-	private DeviceService deviceService;
-	private IdListService blackListIdentityService;
-	private LicenseService licenseService;
 	private AvatarEditView avatarEditView;
-	private GroupDetailViewModel groupDetailViewModel;
 	private ExtendedFloatingActionButton floatingActionButton;
 
+	private String myIdentity;
+	private int operationMode;
+	private int groupId;
+	private boolean hasChanges = false;
+
 	private final ResumePauseHandler.RunIfActive runIfActiveUpdate = new ResumePauseHandler.RunIfActive() {
 		@Override
 		public void runOnUiThread() {
@@ -298,8 +313,9 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			this.deviceService = serviceManager.getDeviceService();
 			this.blackListIdentityService = serviceManager.getBlackListService();
 			this.licenseService = serviceManager.getLicenseService();
-		} catch (FileSystemNotPresentException e) {
-			logger.error("Exception", e);
+			this.groupInviteService = serviceManager.getGroupInviteService();
+		} catch (FileSystemNotPresentException | MasterKeyLockedException e) {
+			logger.error("Exception, could not get required services", e);
 			finishUp();
 			return;
 		}
@@ -336,16 +352,13 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		((AppBarLayout) findViewById(R.id.appbar)).addOnOffsetChangedListener(new AppBarLayout.OnOffsetChangedListener() {
 			@Override
 			public void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) {
-				logger.debug("Vertical offset: " + verticalOffset);
 				if (verticalOffset == 0) {
 					if (!floatingActionButton.isExtended()) {
 						floatingActionButton.extend();
 					}
 				}
-				else {
-					if (floatingActionButton.isExtended()) {
-						floatingActionButton.shrink();
-					}
+				else if (floatingActionButton.isExtended()) {
+					floatingActionButton.shrink();
 				}
 			}
 		});
@@ -355,22 +368,12 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 
 		if (this.groupService.isGroupOwner(this.groupModel)) {
 			operationMode = MODE_EDIT;
-			doneButton.setOnClickListener(new View.OnClickListener() {
-				@Override
-				public void onClick(View v) {
-					saveGroupSettings();
-				}
-			});
-			floatingActionButton.setOnClickListener(new View.OnClickListener() {
-				@Override
-				public void onClick(View v) {
-					if (groupService != null && groupService.isGroupOwner(groupModel)) {
-						Intent intent = new Intent(GroupDetailActivity.this, GroupAddActivity.class);
-						IntentDataUtil.append(groupModel, intent);
-						IntentDataUtil.append(groupDetailViewModel.getGroupContacts(), intent);
-						startActivityForResult(intent, ThreemaActivity.ACTIVITY_ID_GROUP_ADD);
-					}
-				}
+			doneButton.setOnClickListener(v -> saveGroupSettings());
+			floatingActionButton.setOnClickListener(v -> {
+				Intent intent = new Intent(GroupDetailActivity.this, GroupAddActivity.class);
+				IntentDataUtil.append(groupModel, intent);
+				IntentDataUtil.append(groupDetailViewModel.getGroupContacts(), intent);
+				startActivityForResult(intent, ThreemaActivity.ACTIVITY_ID_GROUP_ADD);
 			});
 			groupNameEditText.setMaxByteSize(GroupModel.GROUP_NAME_MAX_LENGTH_BYTES);
 		} else {
@@ -458,51 +461,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				updateFloatingActionButton();
 			}
 		});
-		this.groupDetailAdapter.setOnClickListener(new GroupDetailAdapter.OnClickListener() {
-			@Override
-			public void onItemClick(View v, ContactModel contactModel) {
-				if (contactModel != null) {
-					String identity = contactModel.getIdentity();
-					String shortName = NameUtil.getShortName(contactModel);
-
-					ArrayList<String> items = new ArrayList<>();
-					ArrayList<Integer> optionsMap = new ArrayList<>();
-
-					items.add(getString(R.string.show_contact));
-					optionsMap.add(SELECTOR_OPTION_CONTACT_DETAIL);
-
-					if (!TestUtil.compare(myIdentity, identity)) {
-						items.add(String.format(getString(R.string.chat_with), shortName));
-						optionsMap.add(SELECTOR_OPTION_CHAT);
-
-						if (ContactUtil.canReceiveVoipMessages(contactModel, blackListIdentityService)
-								&& ConfigUtils.isCallsEnabled(GroupDetailActivity.this, preferenceService, licenseService)
-						) {
-							items.add(String.format(getString(R.string.call_with), shortName));
-							optionsMap.add(SELECTOR_OPTION_CALL);
-						}
-
-						if (operationMode == MODE_EDIT) {
-							if (groupModel != null && !TestUtil.compare(groupModel.getCreatorIdentity(), identity)) {
-								items.add(String.format(getString(R.string.kick_user_from_group), shortName));
-								optionsMap.add(SELECTOR_OPTION_REMOVE);
-							}
-						}
-						SelectorDialog selectorDialog = SelectorDialog.newInstance(null, items, null);
-						SelectorInfo selectorInfo = new SelectorInfo();
-						selectorInfo.contactModel = contactModel;
-						selectorInfo.view = v;
-						selectorInfo.optionsMap = optionsMap;
-						selectorDialog.setData(selectorInfo);
-						try {
-							selectorDialog.show(getSupportFragmentManager(), DIALOG_TAG_CHOOSE_ACTION);
-						} catch (IllegalStateException e) {
-							logger.error("Exception", e);
-						}
-					}
-				}
-			}
-		});
+		this.groupDetailAdapter.setOnClickListener(this);
 	}
 
 	@Override
@@ -565,8 +524,9 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		MenuItem groupSyncMenu = menu.findItem(R.id.menu_resync);
 		MenuItem leaveGroupMenu = menu.findItem(R.id.menu_leave_group);
 		MenuItem deleteGroupMenu = menu.findItem(R.id.menu_delete_group);
-		MenuItem mediaGalleryMenu = menu.findItem(R.id.menu_gallery);
 		MenuItem cloneMenu = menu.findItem(R.id.menu_clone_group);
+		MenuItem mediaGalleryMenu = menu.findItem(R.id.menu_gallery);
+		MenuItem groupLinkMenu = menu.findItem(R.id.menu_group_links_manage);
 
 		if (AppRestrictionUtil.isCreateGroupDisabled(this)) {
 			cloneMenu.setVisible(false);
@@ -575,23 +535,22 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		if (groupModel != null) {
 			leaveGroupMenu.setVisible(true);
 			deleteGroupMenu.setVisible(true);
-
 			if (groupService.isGroupOwner(this.groupModel)) {
 				// MODE_EDIT
 				groupSyncMenu.setVisible(true);
+				if (ConfigUtils.supportsGroupLinks()) {
+					groupLinkMenu.setVisible(true);
+				}
 			}
-		}
 
-		if (groupModel != null && !hiddenChatsListService.has(groupService.getUniqueIdString(this.groupModel))) {
-			mediaGalleryMenu.setVisible(true);
-		} else {
-			mediaGalleryMenu.setVisible(false);
+			mediaGalleryMenu.setVisible(!hiddenChatsListService.has(groupService.getUniqueIdString(this.groupModel)));
 		}
 
 		if (operationMode != MODE_READONLY) {
 			menu.findItem(R.id.action_send_message).setVisible(false);
 		}
 
+
 		return super.onPrepareOptionsMenu(menu);
 	}
 
@@ -615,7 +574,14 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		if (itemId == android.R.id.home) {
 			finishUp();
 			return true;
-		} else if (itemId == R.id.action_send_message) {
+		}
+		else if (itemId == R.id.menu_group_links_manage) {
+			Intent groupLinkOverviewIntent = new Intent(this, GroupLinkOverviewActivity.class);
+			groupLinkOverviewIntent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, groupId);
+			startActivityForResult(groupLinkOverviewIntent, ThreemaActivity.ACTIVITY_ID_MANAGE_GROUP_LINKS);
+
+		}
+		else if (itemId == R.id.action_send_message) {
 			if (groupModel != null) {
 				Intent intent = new Intent(this, ComposeMessageActivity.class);
 				intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
@@ -624,9 +590,11 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				startActivity(intent);
 				finish();
 			}
-		} else if (itemId == R.id.menu_resync) {
+		}
+		else if (itemId == R.id.menu_resync) {
 			this.syncGroup();
-		} else if (itemId == R.id.menu_leave_group) {
+		}
+		else if (itemId == R.id.menu_leave_group) {
 			int leaveMessageRes = operationMode == MODE_READONLY ? R.string.really_leave_group_message : R.string.really_leave_group_admin_message;
 
 			GenericAlertDialog.newInstance(
@@ -635,20 +603,16 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				R.string.ok,
 				R.string.cancel)
 				.show(getSupportFragmentManager(), DIALOG_TAG_LEAVE_GROUP);
-		} else if (itemId == R.id.menu_delete_group) {
+		}
+		else if (itemId == R.id.menu_delete_group) {
 			GenericAlertDialog.newInstance(
 				R.string.action_delete_group,
 				groupService.isGroupOwner(groupModel) ? R.string.delete_my_group_message : R.string.delete_group_message,
 				R.string.ok,
 				R.string.cancel)
 				.show(getSupportFragmentManager(), DIALOG_TAG_DELETE_GROUP);
-		} else if (itemId == R.id.menu_gallery) {
-			if (groupId > 0 && !hiddenChatsListService.has(groupService.getUniqueIdString(this.groupModel))) {
-				Intent mediaGalleryIntent = new Intent(this, MediaGalleryActivity.class);
-				mediaGalleryIntent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, groupId);
-				startActivity(mediaGalleryIntent);
-			}
-		} else if (itemId == R.id.menu_clone_group) {
+		}
+		else if (itemId == R.id.menu_clone_group) {
 			GenericAlertDialog.newInstance(
 				R.string.action_clone_group,
 				R.string.clone_group_message,
@@ -656,6 +620,13 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				R.string.no)
 				.show(getSupportFragmentManager(), DIALOG_TAG_CLONE_GROUP_CONFIRM);
 		}
+		else if (itemId == R.id.menu_gallery) {
+			if (groupId > 0 && !hiddenChatsListService.has(groupService.getUniqueIdString(this.groupModel))) {
+				Intent mediaGalleryIntent = new Intent(this, MediaGalleryActivity.class);
+				mediaGalleryIntent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, groupId);
+				startActivity(mediaGalleryIntent);
+			}
+		}
 		return super.onOptionsItemSelected(item);
 	}
 
@@ -691,7 +662,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 							groupService.getGroupIdentities(groupModel),
 							avatar);
 				} catch (Exception e) {
-					logger.error("Exception", e);
+					logger.error("Exception, cloning group failed", e);
 					return null;
 				}
 
@@ -807,7 +778,12 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				groupDetailViewModel.addGroupContacts(IntentDataUtil.getContactIdentities(data));
 				sortGroupMembers();
 				this.hasChanges = true;
-			} else {
+			}
+			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
+				groupDetailAdapter.notifyDataSetChanged();
+			}
+			else {
 				if (this.avatarEditView != null) {
 					this.avatarEditView.onActivityResult(requestCode, resultCode, data);
 				}
@@ -889,6 +865,11 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				break;
 			case DIALOG_TAG_DELETE_GROUP:
 				deleteGroupAndQuit();
+				try {
+					serviceManager.getShortcutService().deleteShortcut((groupModel));
+				} catch (ThreemaException e) {
+					logger.debug("Exception, failed to delete direct group shortcut", e);
+				}
 				break;
 			case DIALOG_TAG_QUIT:
 				saveGroupSettings();
@@ -936,15 +917,18 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	}
 
 	private void updateFloatingActionButton() {
-		if (this.floatingActionButton != null &&
-			this.groupService != null &&
-			this.groupDetailAdapter != null) {
-			if (this.groupService.isGroupOwner(this.groupModel)) {
-				if (this.groupDetailAdapter.getItemCount() > getResources().getInteger(R.integer.max_group_size)) {
-					this.floatingActionButton.hide();
-				} else {
-					this.floatingActionButton.show();
-				}
+		if (this.floatingActionButton == null ||
+			this.groupService == null ||
+			this.groupDetailAdapter == null) {
+			logger.error("Exception, could not update floating actions button, required instances not available");
+			return;
+		}
+
+		if (this.groupService.isGroupOwner(this.groupModel)) {
+			if (this.groupDetailAdapter.getItemCount() > BuildConfig.MAX_GROUP_SIZE) {
+				this.floatingActionButton.hide();
+			} else {
+				this.floatingActionButton.show();
 			}
 		}
 	}
@@ -969,4 +953,66 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			this.avatarEditView.onRequestPermissionsResult(requestCode, permissions, grantResults);
 		}
 	}
+
+	@Override
+	public void onGroupOwnerClick(View v, String identity) {
+		Intent intent = new Intent(this, ContactDetailActivity.class);
+		intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, groupModel.getCreatorIdentity());
+		intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+		ActivityOptionsCompat options = ActivityOptionsCompat.makeScaleUpAnimation(v, 0, 0, v.getWidth(), v.getHeight());
+		ActivityCompat.startActivityForResult((Activity) this, intent, ThreemaActivity.ACTIVITY_ID_CONTACT_DETAIL, options.toBundle());
+	}
+
+	@Override
+	public void onGroupMemberClick(View v, @NonNull ContactModel contactModel) {
+		String identity = contactModel.getIdentity();
+		String shortName = NameUtil.getShortName(contactModel);
+
+		ArrayList<SelectorDialogItem> items = new ArrayList<>();
+		ArrayList<Integer> optionsMap = new ArrayList<>();
+
+		items.add(new SelectorDialogItem(getString(R.string.show_contact), R.drawable.ic_outline_visibility));
+		optionsMap.add(SELECTOR_OPTION_CONTACT_DETAIL);
+
+		if (!TestUtil.compare(myIdentity, identity)) {
+			items.add(new SelectorDialogItem(String.format(getString(R.string.chat_with), shortName), R.drawable.ic_chat_bubble));
+			optionsMap.add(SELECTOR_OPTION_CHAT);
+
+			if (ContactUtil.canReceiveVoipMessages(contactModel, blackListIdentityService)
+				&& ConfigUtils.isCallsEnabled(GroupDetailActivity.this, preferenceService, licenseService)
+			) {
+				items.add(new SelectorDialogItem(String.format(getString(R.string.call_with), shortName), R.drawable.ic_phone_locked_outline));
+				optionsMap.add(SELECTOR_OPTION_CALL);
+			}
+
+			if (operationMode == MODE_EDIT) {
+				if (groupModel != null && !TestUtil.compare(groupModel.getCreatorIdentity(), identity)) {
+					items.add(new SelectorDialogItem(String.format(getString(R.string.kick_user_from_group), shortName), R.drawable.ic_person_remove_outline));
+					optionsMap.add(SELECTOR_OPTION_REMOVE);
+				}
+			}
+			SelectorDialog selectorDialog = SelectorDialog.newInstance(null, items, null);
+			SelectorInfo selectorInfo = new SelectorInfo();
+			selectorInfo.contactModel = contactModel;
+			selectorInfo.view = v;
+			selectorInfo.optionsMap = optionsMap;
+			selectorDialog.setData(selectorInfo);
+			try {
+				selectorDialog.show(getSupportFragmentManager(), DIALOG_TAG_CHOOSE_ACTION);
+			} catch (IllegalStateException e) {
+				logger.error("Exception", e);
+			}
+		}
+	}
+
+	@Override
+	public void onResetLinkClick() {
+		RuntimeUtil.runOnUiThread(() -> ShowOnceDialog.newInstance(R.string.reset_default_group_link_title, R.string.reset_default_group_link_desc).show(getSupportFragmentManager(), DIALOG_SHOW_ONCE_RESET_LINK_INFO));
+	}
+
+		@Override
+	public void onShareLinkClick() {
+		// option only enabled if there is a default link
+		groupInviteService.shareGroupLink(this, groupInviteService.getDefaultGroupInvite(groupModel).get());
+	}
 }

+ 38 - 9
app/src/main/java/ch/threema/app/activities/HomeActivity.java

@@ -56,7 +56,6 @@ import java.io.File;
 import java.lang.ref.WeakReference;
 import java.net.InetSocketAddress;
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.Date;
 import java.util.Iterator;
 import java.util.List;
@@ -90,6 +89,7 @@ import ch.threema.app.fragments.ContactsSectionFragment;
 import ch.threema.app.fragments.MessageSectionFragment;
 import ch.threema.app.fragments.MyIDFragment;
 import ch.threema.app.globalsearch.GlobalSearchActivity;
+import ch.threema.app.grouplinks.OutgoingGroupRequestActivity;
 import ch.threema.app.listeners.AppIconListener;
 import ch.threema.app.listeners.ContactCountListener;
 import ch.threema.app.listeners.ConversationListener;
@@ -102,6 +102,7 @@ import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.preference.SettingsActivity;
 import ch.threema.app.push.PushService;
+import ch.threema.app.qrscanner.activity.BaseQrScannerActivity;
 import ch.threema.app.routines.CheckLicenseRoutine;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationService;
@@ -134,10 +135,10 @@ import ch.threema.app.utils.TestUtil;
 import ch.threema.app.voip.activities.CallActivity;
 import ch.threema.app.voip.services.VoipCallService;
 import ch.threema.app.webclient.activities.SessionsActivity;
-import ch.threema.client.ConnectionState;
-import ch.threema.client.ConnectionStateListener;
-import ch.threema.client.LinkMobileNoException;
-import ch.threema.client.ThreemaConnection;
+import ch.threema.domain.protocol.api.LinkMobileNoException;
+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.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
@@ -208,9 +209,10 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 				@Override
 				public void run() {
 					if (intent.getAction().equals(IntentDataUtil.ACTION_LICENSE_NOT_ALLOWED)) {
-						if (Arrays.asList(BuildFlavor.LicenseType.SERIAL,
-								BuildFlavor.LicenseType.GOOGLE_WORK,
-								BuildFlavor.LicenseType.HMS_WORK).contains(BuildFlavor.getLicenseType())) {
+						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 {
@@ -440,6 +442,13 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			updateUnsentMessagesList(removedMessageModel, false);
 		}
 
+		@Override
+		public void onRemoved(List<AbstractMessageModel> removedMessageModels) {
+			for (AbstractMessageModel removedMessageModel: removedMessageModels) {
+				updateUnsentMessagesList(removedMessageModel, false);
+			}
+		}
+
 		@Override
 		public void onProgressChanged(AbstractMessageModel messageModel, int newProgress) {
 			//do nothing
@@ -806,7 +815,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	}
 
 	private void showErrorTextAndExit(String text) {
-		GenericAlertDialog.newInstance(R.string.error, text, R.string.finish, 0).show(getSupportFragmentManager(), DIALOG_TAG_FINISH_UP);
+		GenericAlertDialog.newInstance(R.string.error, text, R.string.finish, 0)
+			.show(getSupportFragmentManager(), DIALOG_TAG_FINISH_UP);
 	}
 
 	private void runUpdates(final UpdateSystemService updateSystemService) {
@@ -1234,12 +1244,18 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			case R.id.menu_new_distribution_list:
 				intent = new Intent(this, DistributionListAddActivity.class);
 				break;
+			case R.id.group_requests:
+				intent = new Intent(this, OutgoingGroupRequestActivity.class);
+				break;
 			case R.id.my_backups:
 				intent = new Intent(HomeActivity.this, BackupAdminActivity.class);
 				break;
 			case R.id.webclient:
 				intent = new Intent(HomeActivity.this, SessionsActivity.class);
 				break;
+			case R.id.scanner:
+				intent = new Intent(HomeActivity.this, BaseQrScannerActivity.class);
+				break;
 			case R.id.help:
 				intent = new Intent(HomeActivity.this, SupportActivity.class);
 				break;
@@ -1361,6 +1377,19 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 				menuItem.setVisible(false);
 			}
 
+			if (ConfigUtils.supportsGroupLinks()) {
+				MenuItem menuItem = menu.findItem(R.id.scanner);
+				if (menuItem != null) {
+					menuItem.setVisible(true);
+				}
+
+				menuItem = menu.findItem(R.id.group_requests);
+				if (menuItem != null) {
+					menuItem.setVisible(true);
+					menu.setGroupVisible(menuItem.getGroupId(), true);
+				}
+			}
+
 			MenuItem webclientMenuItem = menu.findItem(R.id.webclient);
 			if (webclientMenuItem != null) {
 				webclientMenuItem.setVisible(!(webDisabled || ConfigUtils.isBlackBerry()));

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

@@ -54,7 +54,7 @@ import ch.threema.app.services.IdListService;
 import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.EmptyView;
 import ch.threema.app.utils.ConfigUtils;
-import ch.threema.client.ProtocolDefines;
+import ch.threema.domain.protocol.csp.ProtocolDefines;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
 

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

@@ -34,7 +34,6 @@ import android.graphics.Typeface;
 import android.media.FaceDetector;
 import android.net.Uri;
 import android.os.AsyncTask;
-import android.os.Build;
 import android.os.Bundle;
 import android.view.Menu;
 import android.view.MenuItem;
@@ -133,7 +132,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 	public void onBackPressed() {
 		if (hasChanges()) {
 			GenericAlertDialog dialogFragment = GenericAlertDialog.newInstance(
-					R.string.draw,
+					R.string.discard_changes_title,
 					R.string.discard_changes,
 					R.string.discard,
 					R.string.cancel);
@@ -219,9 +218,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 	protected void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
 
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
-			getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
-		}
+		getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
 
 		Intent intent = getIntent();
 		MediaItem mediaItem = intent.getParcelableExtra(Intent.EXTRA_STREAM);
@@ -672,7 +669,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 					try {
 						TapTargetView.showFor(this,
 							TapTarget.forView(v, getString(R.string.face_blur_tooltip_title), getString(R.string.face_blur_tooltip_text))
-								.outerCircleColor(R.color.accent_dark)      // Specify a color for the outer circle
+								.outerCircleColor(R.color.dark_accent)      // Specify a color for the outer circle
 								.outerCircleAlpha(0.96f)            // Specify the alpha amount for the outer circle
 								.targetCircleColor(android.R.color.white)   // Specify a color for the target circle
 								.titleTextSize(24)                  // Specify the size (in sp) of the title text

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

@@ -28,7 +28,6 @@ import android.content.Context;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.graphics.Bitmap;
-import android.graphics.Canvas;
 import android.graphics.Color;
 import android.location.Location;
 import android.location.LocationManager;
@@ -133,18 +132,14 @@ public class MapActivity extends ThreemaActivity implements GenericAlertDialog.D
 
 		setContentView(R.layout.activity_map);
 
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
-			getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
-			getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
-			getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
-			getWindow().setStatusBarColor(Color.TRANSPARENT);
-			if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
-				// we want dark icons, i.e. a light status bar
-				getWindow().getDecorView().setSystemUiVisibility(
-						getWindow().getDecorView().getSystemUiVisibility() | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
-			}
-		} else {
-			getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
+		getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
+		getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
+		getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
+		getWindow().setStatusBarColor(Color.TRANSPARENT);
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+			// we want dark icons, i.e. a light status bar
+			getWindow().getDecorView().setSystemUiVisibility(
+					getWindow().getDecorView().getSystemUiVisibility() | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
 		}
 
 		try {
@@ -240,12 +235,10 @@ public class MapActivity extends ThreemaActivity implements GenericAlertDialog.D
 						}
 						mapboxMap.addMarker(getMarker(markerPosition, markerName, markerProvider));
 
-						if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
-							int marginTop = getResources().getDimensionPixelSize(R.dimen.map_compass_margin_top) + insetTop;
-							int marginRight = getResources().getDimensionPixelSize(R.dimen.map_compass_margin_right);
+						int marginTop = getResources().getDimensionPixelSize(R.dimen.map_compass_margin_top) + insetTop;
+						int marginRight = getResources().getDimensionPixelSize(R.dimen.map_compass_margin_right);
 
-							mapboxMap.getUiSettings().setCompassMargins(0, marginTop, marginRight, 0);
-						}
+						mapboxMap.getUiSettings().setCompassMargins(0, marginTop, marginRight, 0);
 
 						moveCamera(markerPosition, false, -1);
 						mapView.postDelayed(new Runnable() {

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

@@ -547,7 +547,7 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 	}
 
 	private void saveMessages() {
-		if (ConfigUtils.requestStoragePermissions(this, null, PERMISSION_REQUEST_SAVE_MESSAGE)) {
+		if (ConfigUtils.requestWriteStoragePermissions(this, null, PERMISSION_REQUEST_SAVE_MESSAGE)) {
 			fileService.saveMedia(this, gridView, new CopyOnWriteArrayList<>(getSelectedMessages()), true);
 			actionMode.finish();
 		}

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

@@ -181,8 +181,6 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 		getToolbar().setTitleTextAppearance(this, R.style.TextAppearance_MediaViewer_Title);
 		getToolbar().setSubtitleTextAppearance(this, R.style.TextAppearance_MediaViewer_SubTitle);
 
-		adjustStatusBar();
-
 		this.caption = findViewById(R.id.caption);
 
 		this.captionContainer = findViewById(R.id.caption_container);
@@ -194,8 +192,6 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 			return insets;
 		});
 
-		setCaptionPosition();
-
 		this.currentMessageModel = IntentDataUtil.getAbstractMessageModel(intent, messageService);
 		try {
 			this.currentReceiver = messageService.getMessageReceiver(this.currentMessageModel);
@@ -408,7 +404,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 			finish();
 			return true;
 		} else if (itemId == R.id.menu_save) {
-			if (ConfigUtils.requestStoragePermissions(this, null, PERMISSION_REQUEST_SAVE_MESSAGE)) {
+			if (ConfigUtils.requestWriteStoragePermissions(this, null, PERMISSION_REQUEST_SAVE_MESSAGE)) {
 				saveMedia();
 			}
 			return true;
@@ -816,36 +812,10 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 		}
 	}
 
-	private void setCaptionPosition() {
-		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
-			try {
-				ConfigUtils.NavigationBarDimensions dimensions = new ConfigUtils.NavigationBarDimensions();
-				dimensions = ConfigUtils.getNavigationBarDimensions(getWindowManager(), dimensions);
-				if (this.captionContainer != null) {
-					FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) this.captionContainer.getLayoutParams();
-					params.setMargins(dimensions.width, 0, dimensions.width, dimensions.height + getResources().getDimensionPixelSize(R.dimen.mediaviewer_caption_border_bottom));
-					this.captionContainer.setLayoutParams(params);
-				}
-			} catch (Exception e) {
-				logger.error("Exception", e);
-			}
-		}
-	}
-
-	private void adjustStatusBar() {
-		if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
-			FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) getToolbar().getLayoutParams();
-			lp.topMargin = ConfigUtils.getStatusBarHeight(this);
-			getToolbar().setLayoutParams(lp);
-		}
-	}
-
 	@Override
 	public void onConfigurationChanged(@NonNull Configuration newConfig) {
 		super.onConfigurationChanged(newConfig);
 
 		ConfigUtils.adjustToolbar(this, getToolbar());
-		adjustStatusBar();
-		setCaptionPosition();
 	}
 }

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

@@ -127,7 +127,7 @@ public class PinLockActivity extends ThreemaActivity {
 		errorTextView = findViewById(R.id.errorText);
 
 		headerTextView.setText(R.string.confirm_your_pin);
-		detailsTextView.setText(R.string.pinentry_enter_pin);
+		detailsTextView.setText(getString(R.string.pinentry_enter_pin, getString(R.string.app_name)));
 		passwordEntry.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
 
 		Button cancelButton = findViewById(R.id.cancelButton);

+ 4 - 9
app/src/main/java/ch/threema/app/activities/QRCodeZoomActivity.java

@@ -21,7 +21,6 @@
 
 package ch.threema.app.activities;
 
-import android.os.Build;
 import android.os.Bundle;
 import android.view.View;
 
@@ -41,14 +40,10 @@ public class QRCodeZoomActivity extends AppCompatActivity {
 
 		final View rootView = getWindow().getDecorView().getRootView();
 
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
-			ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> {
-				showPopup(v);
-				return insets;
-			});
-		} else {
-			showPopup(rootView);
-		}
+		ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> {
+			showPopup(v);
+			return insets;
+		});
 	}
 
 	private void showPopup(final View v) {

+ 30 - 11
app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java

@@ -113,7 +113,7 @@ import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.NavigationUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.client.file.FileData;
+import ch.threema.domain.protocol.csp.messages.file.FileData;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.DistributionListModel;
@@ -400,17 +400,31 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 			}
 
 			String identity = IntentDataUtil.getIdentity(intent);
-			if (!TestUtil.empty(identity)) {
-				hideUi = true;
+			int groupId = -1;
+			int distributionListID = -1;
+			//check for receiver information either through intent extras or sharing shortcut id which looses its intent extras
+			// (workaround to pass identity extras in shortcut id https://medium.com/@styrc.adam/android-dynamic-shortcuts-passing-extras-64db534621c1)
+			if (TestUtil.empty(identity)) {
+				identity = IntentDataUtil.getIdentityFromSharingShortcut(intent);
+				if (TestUtil.empty(identity)) {
+					groupId = IntentDataUtil.getGroupId(intent);
+
+					if (groupId == -1) {
+						groupId = IntentDataUtil.getGroupIdFromSharingShortcut(intent);
+
+						if (groupId == -1) {
+							// distribution list is only passed as id to recipient activity, no check for intent extras
+							distributionListID = IntentDataUtil.getDistributionListIdFromSharingShortcut(intent);
+						}
+					}
+				}
 			}
 
-			int groupId = IntentDataUtil.getGroupId(intent);
-			if (groupId > 0) {
+			if (groupId > 0 || distributionListID > 0 || !TestUtil.empty(identity)) {
 				hideUi = true;
 			}
 
 			String action = intent.getAction();
-
 			if (action != null) {
 				// called from other app via regular send intent
 				if (action.equals(Intent.ACTION_SEND)) {
@@ -425,7 +439,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 						uri = (Uri) parcelable;
 					}
 
-					if (type != null && (uri != null || MimeUtil.isTextFile(type))) {
+					if (type != null && (uri != null || MimeUtil.isText(type))) {
 						if (type.equals("message/rfc822")) {
 							// email attachments
 							//  extract file type from uri path
@@ -506,10 +520,12 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 					if (!TestUtil.empty(identity)) {
 						prepareForwardingOrSharing(new ArrayList<>(Collections.singletonList(contactService.getByIdentity(identity))));
 					}
-
-					if (groupId > 0) {
+					else if (groupId > 0) {
 						prepareForwardingOrSharing(new ArrayList<>(Collections.singletonList(groupService.getById(groupId))));
 					}
+					else if (distributionListID > 0) {
+						prepareForwardingOrSharing(new ArrayList<>(Collections.singletonList(distributionListService.getById(distributionListID))));
+					}
 				} else if (action.equals(Intent.ACTION_SENDTO)) {
 					// called from contact app or quickcontactbadge
 					if (lockAppService != null && lockAppService.isLocked()) {
@@ -900,8 +916,11 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 		if (mediaItems.size() > 0 || originalMessageModels.size() > 0) {
 			String recipientName = "";
 
-			if (!((mediaItems.size() == 1 && MimeUtil.isTextFile(mediaItems.get(0).getMimeType()))
-				|| (originalMessageModels.size() == 1 && originalMessageModels.get(0).getType() == MessageType.TEXT))) {
+			if (!(
+				(mediaItems.size() == 1 && MimeUtil.isText(mediaItems.get(0).getMimeType()) && !MimeUtil.isFileType(mediaItems.get(0).getType())) // not a single plain text item (.txt file should == true bc. mimetype text/plain but type file)
+				||
+				(originalMessageModels.size() == 1 && originalMessageModels.get(0).getType() == MessageType.TEXT)) // not a single threema text message
+			) {
 				for (Object model : recipients) {
 					if (recipientName.length() > 0) {
 						recipientName += ", ";

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

@@ -38,13 +38,11 @@ import android.graphics.PorterDuff;
 import android.media.MediaMetadataRetriever;
 import android.net.Uri;
 import android.os.AsyncTask;
-import android.os.Build;
 import android.os.Bundle;
 import android.os.Handler;
 import android.provider.MediaStore;
 import android.text.Editable;
 import android.text.TextWatcher;
-import android.util.DisplayMetrics;
 import android.view.KeyEvent;
 import android.view.Menu;
 import android.view.MenuItem;
@@ -188,41 +186,21 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 		}
 
 		if (preferenceService.getEmojiStyle() != PreferenceService.EmojiStyle_ANDROID) {
-			if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
-				findViewById(R.id.activity_parent).getRootView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
-					@Override
-					public void onGlobalLayout() {
-						DisplayMetrics metrics = new DisplayMetrics();
-						// get dimensions of usable display space with decorations (status bar / navigation bar) subtracted
-						getWindowManager().getDefaultDisplay().getMetrics(metrics);
-						int usableHeight = metrics.heightPixels;
-						int statusBarHeight = ConfigUtils.getStatusBarHeight(SendMediaActivity.this);
-						int rootViewHeight = findViewById(R.id.activity_parent).getHeight();
-
-						if (rootViewHeight + statusBarHeight == usableHeight) {
-							onSoftKeyboardClosed();
-						} else {
-							onSoftKeyboardOpened(usableHeight - statusBarHeight - rootViewHeight);
-						}
-					}
-				});
-			} else {
-				ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.activity_parent).getRootView(), new OnApplyWindowInsetsListener() {
-					@Override
-					public WindowInsetsCompat onApplyWindowInsets(View v, WindowInsetsCompat insets) {
+			ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.activity_parent).getRootView(), new OnApplyWindowInsetsListener() {
+				@Override
+				public WindowInsetsCompat onApplyWindowInsets(View v, WindowInsetsCompat insets) {
 
-						logger.debug("system window top " + insets.getSystemWindowInsetTop() + " bottom " + insets.getSystemWindowInsetBottom());
-						logger.debug("stable insets top " + insets.getStableInsetTop() + " bottom " + insets.getStableInsetBottom());
+					logger.debug("system window top " + insets.getSystemWindowInsetTop() + " bottom " + insets.getSystemWindowInsetBottom());
+					logger.debug("stable insets top " + insets.getStableInsetTop() + " bottom " + insets.getStableInsetBottom());
 
-						if (insets.getSystemWindowInsetBottom() <= insets.getStableInsetBottom()) {
-							onSoftKeyboardClosed();
-						} else {
-							onSoftKeyboardOpened(insets.getSystemWindowInsetBottom() - insets.getStableInsetBottom());
-						}
-						return insets;
+					if (insets.getSystemWindowInsetBottom() <= insets.getStableInsetBottom()) {
+						onSoftKeyboardClosed();
+					} else {
+						onSoftKeyboardOpened(insets.getSystemWindowInsetBottom() - insets.getStableInsetBottom());
 					}
-				});
-			}
+					return insets;
+				}
+			});
 			addOnSoftKeyboardChangedListener(this);
 		}
 
@@ -1201,13 +1179,11 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 	}
 
 	private void showBigVideo(MediaItem item) {
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
-			this.bigImageView.setVisibility(View.GONE);
-			this.bigGifImageView.setVisibility(View.GONE);
-			this.videoEditView.setVisibility(View.VISIBLE);
-			this.videoEditView.setVideo(item);
-			logger.debug("show video " + item.getDurationMs());
-		}
+		this.bigImageView.setVisibility(View.GONE);
+		this.bigGifImageView.setVisibility(View.GONE);
+		this.videoEditView.setVisibility(View.VISIBLE);
+		this.videoEditView.setVideo(item);
+		logger.debug("show video " + item.getDurationMs());
 	}
 
 	private void showBigImage(final int position) {
@@ -1303,7 +1279,7 @@ public class SendMediaActivity extends ThreemaToolbarActivity implements
 	private void confirmQuit() {
 		if (hasChanges) {
 			GenericAlertDialog dialogFragment = GenericAlertDialog.newInstance(
-					R.string.send_media,
+					R.string.discard_changes_title,
 					R.string.discard_changes,
 					R.string.yes,
 					R.string.no);

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

@@ -35,7 +35,7 @@ import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.NotificationService;
 import ch.threema.app.services.PassphraseService;
 import ch.threema.app.utils.ConfigUtils;
-import ch.threema.client.ThreemaConnection;
+import ch.threema.domain.protocol.csp.connection.ThreemaConnection;
 import ch.threema.localcrypto.MasterKey;
 
 /**

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

@@ -163,20 +163,12 @@ public class TextChatBubbleActivity extends ThreemaActivity implements GenericAl
 
 		if (messageModel.isOutbox()) {
 			// send
-			if (ConfigUtils.getAppTheme(this) == ConfigUtils.THEME_DARK) {
-				color = getResources().getColor(R.color.dark_bubble_send);
-			} else {
-				color = getResources().getColor(R.color.light_bubble_send);
-			}
+			color = ConfigUtils.getColorFromAttribute(this, R.attr.bubble_send);
 			title = getString(R.string.threema_message_to, messageReceiver.getDisplayName());
 			footerLayout = R.layout.conversation_bubble_footer_send;
 		} else {
 			// recv
-			if (ConfigUtils.getAppTheme(this) == ConfigUtils.THEME_DARK) {
-				color = getResources().getColor(R.color.dark_bubble_recv);
-			} else {
-				color = getResources().getColor(R.color.light_bubble_recv);
-			}
+			color = ConfigUtils.getColorFromAttribute(this, R.attr.bubble_recv);
 			title = getString(R.string.threema_message_from, messageReceiver.getDisplayName());
 			footerLayout = R.layout.conversation_bubble_footer_recv;
 		}

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

@@ -68,6 +68,7 @@ public abstract class ThreemaActivity extends ThreemaAppCompatActivity {
 	public static final int ACTIVITY_ID_PICK_FILE = 20047;
 	public static final int ACTIVITY_ID_PAINT = 20049;
 	public static final int ACTIVITY_ID_PICK_MEDIA = 20050;
+	public static final int ACTIVITY_ID_MANAGE_GROUP_LINKS = 20051;
 
 	public static final int RESULT_RESTART = 40005;
 

+ 4 - 4
app/src/main/java/ch/threema/app/activities/ThreemaToolbarActivity.java

@@ -54,9 +54,9 @@ import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ConnectionIndicatorUtil;
 import ch.threema.app.utils.EditTextUtil;
 import ch.threema.app.utils.RuntimeUtil;
-import ch.threema.client.ConnectionState;
-import ch.threema.client.ConnectionStateListener;
-import ch.threema.client.ThreemaConnection;
+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;
 
 /**
@@ -134,7 +134,7 @@ public abstract class ThreemaToolbarActivity extends ThreemaActivity implements
 		}
 	}
 
-	private void initServices() {
+	protected void initServices() {
 		if (serviceManager == null) {
 			serviceManager = ThreemaApplication.getServiceManager();
 			if (serviceManager == null) {

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

@@ -22,12 +22,9 @@
 package ch.threema.app.activities;
 
 import android.content.Intent;
-import android.content.res.Configuration;
 import android.os.Bundle;
-import android.text.Html;
 import android.view.View;
 import android.widget.LinearLayout;
-import android.widget.TextView;
 
 import ch.threema.app.R;
 import ch.threema.app.utils.AnimationUtil;
@@ -38,15 +35,14 @@ import static ch.threema.app.activities.WhatsNewActivity.EXTRA_NO_ANIMATION;
 public class WhatsNew2Activity extends ThreemaAppCompatActivity {
 	@Override
 	protected void onCreate(Bundle savedInstanceState) {
-
 		ConfigUtils.configureActivityTheme(this);
 
 		super.onCreate(savedInstanceState);
 
 		setContentView(R.layout.activity_whatsnew2);
-
+/*
 		((TextView) findViewById(R.id.whatsnew_body)).setText(Html.fromHtml(getString(R.string.whatsnew2_body, getString(R.string.app_name))));
-
+*/
 		findViewById(R.id.ok_button).setOnClickListener(v -> {
 			finish();
 			overridePendingTransition(R.anim.slide_in_right_short, R.anim.slide_out_left_short);
@@ -61,11 +57,6 @@ public class WhatsNew2Activity extends ThreemaAppCompatActivity {
 		}
 	}
 
-	@Override
-	public void onConfigurationChanged(Configuration newConfig) {
-		super.onConfigurationChanged(newConfig);
-	}
-
 	@Override
 	public void onBackPressed() {
 		Intent intent = new Intent(WhatsNew2Activity.this, WhatsNewActivity.class);

+ 5 - 11
app/src/main/java/ch/threema/app/activities/WhatsNewActivity.java

@@ -21,11 +21,10 @@
 
 package ch.threema.app.activities;
 
-import android.content.res.Configuration;
+import android.content.Intent;
 import android.os.Bundle;
 import android.view.View;
 import android.widget.LinearLayout;
-import android.widget.TextView;
 
 import ch.threema.app.R;
 import ch.threema.app.utils.AnimationUtil;
@@ -42,13 +41,13 @@ public class WhatsNewActivity extends ThreemaAppCompatActivity {
 		super.onCreate(savedInstanceState);
 
 		setContentView(R.layout.activity_whatsnew);
-
+/*
 		((TextView) findViewById(R.id.whatsnew_title)).setText(getString(R.string.whatsnew_title, getString(R.string.app_name)));
 		((TextView) findViewById(R.id.whatsnew_body)).setText(getString(R.string.whatsnew_headline, getString(R.string.app_name)));
-
+*/
 		findViewById(R.id.next_text).setOnClickListener(v -> {
-//			startActivity(new Intent(WhatsNewActivity.this, WhatsNew2Activity.class));
-//			overridePendingTransition(R.anim.slide_in_right_short, R.anim.slide_out_left_short);
+			startActivity(new Intent(WhatsNewActivity.this, WhatsNew2Activity.class));
+			overridePendingTransition(R.anim.slide_in_right_short, R.anim.slide_out_left_short);
 			finish();
 		});
 
@@ -60,9 +59,4 @@ public class WhatsNewActivity extends ThreemaAppCompatActivity {
 			}
 		}
 	}
-
-	@Override
-	public void onConfigurationChanged(Configuration newConfig) {
-		super.onConfigurationChanged(newConfig);
-	}
 }

+ 53 - 9
app/src/main/java/ch/threema/app/activities/ballot/BallotMatrixActivity.java

@@ -30,9 +30,14 @@ import android.widget.TableRow;
 import android.widget.TextView;
 import android.widget.Toast;
 
+import com.google.android.material.card.MaterialCardView;
+
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.ArrayList;
+import java.util.List;
+
 import androidx.appcompat.app.ActionBar;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -68,8 +73,9 @@ public class BallotMatrixActivity extends BallotDetailActivity {
 	private ContactService contactService;
 	private GroupService groupService;
 	private String identity;
+	private View scrollParent, noVotesView;
 
-	private BallotVoteListener ballotVoteListener = new BallotVoteListener() {
+	private final BallotVoteListener ballotVoteListener = new BallotVoteListener() {
 		@Override
 		public void onSelfVote(BallotModel ballotModel) {
 			ballotListener.onModified(ballotModel);
@@ -91,7 +97,7 @@ public class BallotMatrixActivity extends BallotDetailActivity {
 		}
 	};
 
-	private BallotListener ballotListener = new BallotListener() {
+	private final BallotListener ballotListener = new BallotListener() {
 		@Override
 		public void onClosed(BallotModel ballotModel) {
 			this.onModified(ballotModel);
@@ -165,6 +171,9 @@ public class BallotMatrixActivity extends BallotDetailActivity {
 			textView.setText(this.getBallotModel().getName());
 		}
 
+		noVotesView = findViewById(R.id.no_votes_yet);
+		scrollParent = findViewById(R.id.scroll_parent);
+
 		ListenerManager.ballotListeners.add(this.ballotListener);
 		ListenerManager.ballotVoteListeners.add(this.ballotVoteListener);
 		this.updateView();
@@ -175,8 +184,7 @@ public class BallotMatrixActivity extends BallotDetailActivity {
 		return R.layout.activity_ballot_matrix;
 	}
 
-
-	private void refreshList() {
+	private void updateView() {
 		TableLayout dataTableLayout = findViewById(R.id.matrix_data);
 
 		if(dataTableLayout != null) {
@@ -192,6 +200,28 @@ public class BallotMatrixActivity extends BallotDetailActivity {
 			return;
 		}
 
+		List<BallotMatrixService.Participant> allParticipants = matrixData.getParticipants();
+		List<BallotMatrixService.Participant> votedParticipants = new ArrayList<>();
+		List<BallotMatrixService.Participant> notVotedParticipants = new ArrayList<>();
+
+		for (BallotMatrixService.Participant participant: allParticipants) {
+			if (participant.hasVoted()) {
+				votedParticipants.add(participant);
+			} else {
+				notVotedParticipants.add(participant);
+			}
+		}
+
+		if (votedParticipants.size() == 0) {
+			// no votes
+			noVotesView.setVisibility(View.VISIBLE);
+			scrollParent.setVisibility(View.GONE);
+			return;
+		}
+
+		noVotesView.setVisibility(View.GONE);
+		scrollParent.setVisibility(View.VISIBLE);
+
 		// add header row containing names/avatars of participants
 		TableRow nameHeaderRow = new TableRow(this);
 
@@ -201,7 +231,7 @@ public class BallotMatrixActivity extends BallotDetailActivity {
 		View emptyCell2 = getLayoutInflater().inflate(R.layout.row_cell_ballot_matrix_empty, null);
 		nameHeaderRow.addView(emptyCell2);
 
-		for(BallotMatrixService.Participant p: matrixData.getParticipants()) {
+		for(BallotMatrixService.Participant p: votedParticipants) {
 			final ContactModel contactModel = this.contactService.getByIdentity(p.getIdentity());
 
 			View nameCell = getLayoutInflater().inflate(R.layout.row_cell_ballot_matrix_name, null);
@@ -243,7 +273,7 @@ public class BallotMatrixActivity extends BallotDetailActivity {
 
 			row.addView(sumCell);
 
-			for (BallotMatrixService.Participant p: matrixData.getParticipants()) {
+			for (BallotMatrixService.Participant p: votedParticipants) {
 				View choiceVoteView;
 
 				if (c.isWinner()) {
@@ -271,10 +301,24 @@ public class BallotMatrixActivity extends BallotDetailActivity {
 
 			dataTableLayout.addView(row);
 		}
-	}
 
-	private void updateView() {
-		this.refreshList();
+		TextView notVotedTextView = findViewById(R.id.not_voted);
+		MaterialCardView notVotedContainer = findViewById(R.id.not_voted_container);
+
+		if (contactService != null && notVotedParticipants.size() > 0) {
+			notVotedContainer.setVisibility(View.VISIBLE);
+			String userList = "";
+
+			for (BallotMatrixService.Participant p: notVotedParticipants) {
+				if (!"".equals(userList)) {
+					userList += ", ";
+				}
+				userList += NameUtil.getDisplayNameOrNickname(p.getIdentity(), contactService);
+			}
+			notVotedTextView.setText(getString(R.string.not_voted_user_list, userList));
+ 		} else {
+			notVotedContainer.setVisibility(View.GONE);
+		}
 	}
 
 	@Override

+ 5 - 4
app/src/main/java/ch/threema/app/activities/ballot/BallotOverviewActivity.java

@@ -55,6 +55,7 @@ import ch.threema.app.services.ContactService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.ballot.BallotService;
 import ch.threema.app.ui.EmptyView;
+import ch.threema.app.ui.SelectorDialogItem;
 import ch.threema.app.utils.BallotUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
@@ -273,19 +274,19 @@ public class BallotOverviewActivity extends ThreemaToolbarActivity implements Li
 			BallotModel ballotModel = listAdapter.getItem(position);
 
 			if (ballotModel != null) {
-				ArrayList<String> items = new ArrayList<>(3);
+				ArrayList<SelectorDialogItem> items = new ArrayList<>(3);
 				ArrayList<Integer> values = new ArrayList<>(3);
 
 				if (BallotUtil.canVote(ballotModel, myIdentity)) {
-					items.add(getString(R.string.ballot_vote));
+					items.add(new SelectorDialogItem(getString(R.string.ballot_vote), R.drawable.ic_vote_outline));
 					values.add(SELECTOR_ID_VOTE);
 				}
 				if (BallotUtil.canViewMatrix(ballotModel, myIdentity)) {
-					items.add(getString(ballotModel.getState() == BallotModel.State.CLOSED ? R.string.ballot_result_final : R.string.ballot_result_intermediate));
+					items.add(new SelectorDialogItem(getString(ballotModel.getState() == BallotModel.State.CLOSED ? R.string.ballot_result_final : R.string.ballot_result_intermediate), R.drawable.ic_ballot_outline));
 					values.add(SELECTOR_ID_RESULTS);
 				}
 				if (BallotUtil.canClose(ballotModel, myIdentity)) {
-					items.add(getString(R.string.ballot_close));
+					items.add(new SelectorDialogItem(getString(R.string.ballot_close), R.drawable.ic_check));
 					values.add(SELECTOR_ID_CLOSE);
 				}
 

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

@@ -57,7 +57,7 @@ import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.client.APIConnector;
+import ch.threema.domain.protocol.api.APIConnector;
 import ch.threema.storage.models.ballot.BallotChoiceModel;
 import ch.threema.storage.models.ballot.BallotModel;
 

+ 338 - 0
app/src/main/java/ch/threema/app/activities/wizard/WizardBackupRestoreActivity.java

@@ -0,0 +1,338 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2021 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.activities.wizard;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.Html;
+import android.text.method.LinkMovementMethod;
+import android.widget.TextView;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.core.content.ContextCompat;
+import ch.threema.app.R;
+import ch.threema.app.ThreemaApplication;
+import ch.threema.app.activities.DisableBatteryOptimizationsActivity;
+import ch.threema.app.activities.ThreemaActivity;
+import ch.threema.app.activities.ThreemaAppCompatActivity;
+import ch.threema.app.backuprestore.csv.RestoreService;
+import ch.threema.app.dialogs.GenericAlertDialog;
+import ch.threema.app.dialogs.GenericProgressDialog;
+import ch.threema.app.dialogs.PasswordEntryDialog;
+import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.services.FileService;
+import ch.threema.app.services.PreferenceService;
+import ch.threema.app.services.UserService;
+import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
+import ch.threema.app.utils.ConfigUtils;
+import ch.threema.app.utils.DialogUtil;
+import ch.threema.app.utils.FileUtil;
+import ch.threema.app.utils.LocaleUtil;
+import ch.threema.app.utils.MimeUtil;
+import ch.threema.app.utils.RuntimeUtil;
+import ch.threema.app.utils.TestUtil;
+
+public class WizardBackupRestoreActivity extends ThreemaAppCompatActivity implements GenericAlertDialog.DialogClickListener,
+	PasswordEntryDialog.PasswordEntryDialogClickListener {
+	private static final Logger logger = LoggerFactory.getLogger(WizardBackupRestoreActivity.class);
+
+	private static final String DIALOG_TAG_DISABLE_ENERGYSAVE_CONFIRM = "de";
+	private static final String DIALOG_TAG_DOWNLOADING_BACKUP = "dwnldBkp";
+	private static final String DIALOG_TAG_NO_INTERNET = "nin";
+
+	public static final int REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS = 541;
+
+	private ThreemaSafeMDMConfig safeMDMConfig;
+	private FileService fileService;
+	private UserService userService;
+	private PreferenceService preferenceService;
+
+	@Override
+	protected void onCreate(@Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
+		super.onCreate(savedInstanceState);
+
+		// directly forward to ID restore activity
+		Intent intent = getIntent();
+		if (intent.hasExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP) &&
+			intent.hasExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP_PW)) {
+
+			restoreIDExport(intent.getStringExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP),
+				intent.getStringExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP_PW));
+		}
+
+		initServices();
+		initLayout();
+		initListeners();
+	}
+
+	@Override
+	protected void onPause() {
+		ThreemaApplication.activityPaused(this);
+		super.onPause();
+	}
+
+	@Override
+	protected void onResume() {
+		ThreemaApplication.activityResumed(this);
+		super.onResume();
+	}
+
+	@Override
+	public void onUserInteraction() {
+		ThreemaApplication.activityUserInteract(this);
+		super.onUserInteraction();
+	}
+
+	private void initServices() {
+		this.safeMDMConfig = ThreemaSafeMDMConfig.getInstance();
+
+		try {
+			ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+			if (serviceManager != null) {
+				fileService = serviceManager.getFileService();
+				userService = serviceManager.getUserService();
+				preferenceService = serviceManager.getPreferenceService();
+			}
+		} catch (Exception e) {
+			logger.error("Exception ", e);
+			finish();
+		}
+	}
+
+	private void initLayout() {
+		setContentView(R.layout.activity_backup_restore);
+
+		String faqURL = String.format(getString(R.string.backup_faq_url), LocaleUtil.getAppLanguage());
+		TextView backupSubtitle = findViewById(R.id.backup_restore_subtitle);
+		backupSubtitle.setText(Html.fromHtml(
+			String.format(getString(R.string.backup_restore_type), faqURL))
+		);
+		backupSubtitle.setMovementMethod(LinkMovementMethod.getInstance());
+
+		if (ConfigUtils.isWorkRestricted()) {
+			if (safeMDMConfig.isRestoreDisabled()) {
+				findViewById(R.id.safe_backup).setEnabled(false);
+			}
+		}
+	}
+
+	private void initListeners() {
+		findViewById(R.id.safe_backup).setOnClickListener(v -> restoreSafe());
+		findViewById(R.id.data_backup).setOnClickListener(v -> showDisableEnergySaveDialog());
+		findViewById(R.id.id_backup).setOnClickListener(v -> restoreIDExport(null, null));
+		findViewById(R.id.cancel).setOnClickListener(v -> finish());
+	}
+
+	public void restoreSafe() {
+		startActivity(new Intent(this, WizardSafeRestoreActivity.class));
+		overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
+	}
+
+	public void restoreIDExport(String backupString, String backupPassword) {
+		Intent intent = new Intent(this, WizardIDRestoreActivity.class);
+
+		if (!TestUtil.empty(backupString) && !TestUtil.empty(backupPassword)) {
+			intent.putExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP, backupString);
+			intent.putExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP_PW, backupPassword);
+		}
+		startActivityForResult(intent, ThreemaActivity.ACTIVITY_ID_RESTORE_KEY);
+		overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
+	}
+
+	private void restoreBackup(final Uri uri) {
+		if (!ContentResolver.SCHEME_FILE.equalsIgnoreCase(uri.getScheme()) && this.fileService != null) {
+			// copy "file" to cache directory first
+			GenericProgressDialog.newInstance(R.string.importing_files, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_DOWNLOADING_BACKUP);
+
+			new Thread(() -> {
+				final File file = fileService.copyUriToTempFile(uri, "file", "zip", true);
+
+				RuntimeUtil.runOnUiThread(() -> {
+					DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_DOWNLOADING_BACKUP, true);
+
+					if (file != null) {
+						restoreBackupFile(file);
+						file.deleteOnExit();
+					}
+				});
+			}).start();
+
+		} else {
+			String path = FileUtil.getRealPathFromURI(this, uri);
+			if (path != null && !path.isEmpty()) {
+				File file = new File(path);
+				if (file.exists()) {
+					restoreBackupFile(file);
+				}
+			}
+		}
+	}
+
+	private void restoreBackupFile(File file) {
+		if (file.exists()) {
+//			try {
+// Zipfile validity check is sometimes wrong
+//				ZipFile zipFile = new ZipFile(file);
+//				if (zipFile.isValidZipFile()) {
+			ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
+
+			NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
+			if (activeNetwork == null || !activeNetwork.isConnectedOrConnecting()) {
+				showNoInternetDialog(file);
+			} else {
+				confirmRestore(file);
+			}
+			return;
+		}
+//				}
+//			} catch (ZipException e) {
+//				logger.error("Exception", e);
+//			}
+		logger.error(getString(R.string.invalid_backup), this);
+	}
+
+	private void showDisableEnergySaveDialog() {
+		GenericAlertDialog.newInstance(R.string.menu_restore, R.string.restore_disable_energy_saving, R.string.ok, R.string.cancel).show(getSupportFragmentManager(), DIALOG_TAG_DISABLE_ENERGYSAVE_CONFIRM);
+	}
+
+	private void confirmRestore(File file) {
+		PasswordEntryDialog dialogFragment = PasswordEntryDialog.newInstance(
+			R.string.backup_data_title,
+			R.string.restore_data_password_msg,
+			R.string.password_hint,
+			R.string.ok,
+			R.string.cancel,
+			ThreemaApplication.MIN_PW_LENGTH_BACKUP,
+			ThreemaApplication.MAX_PW_LENGTH_BACKUP,
+			0, 0, 0, PasswordEntryDialog.ForgotHintType.PIN_PASSPHRASE);
+		dialogFragment.setData(file);
+		dialogFragment.show(getSupportFragmentManager(), "restorePW");
+	}
+
+	@UiThread
+	private void showNoInternetDialog(File file) {
+		GenericAlertDialog dialog = GenericAlertDialog.newInstance(R.string.menu_restore, R.string.new_wizard_need_internet, R.string.retry, R.string.cancel);
+		dialog.setData(file);
+		dialog.show(getSupportFragmentManager(), DIALOG_TAG_NO_INTERNET);
+	}
+
+	// start generic alert dialog callbacks
+	@Override
+	public void onYes(String tag, Object data) {
+		switch (tag) {
+			case DIALOG_TAG_DISABLE_ENERGYSAVE_CONFIRM:
+				Intent intent = new Intent(this, DisableBatteryOptimizationsActivity.class);
+				intent.putExtra(DisableBatteryOptimizationsActivity.EXTRA_NAME, getString(R.string.restore));
+				intent.putExtra(DisableBatteryOptimizationsActivity.EXTRA_WIZARD, true);
+				startActivityForResult(intent, REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS);
+				break;
+			case DIALOG_TAG_NO_INTERNET:
+				restoreBackupFile((File) data);
+				break;
+		}
+	}
+
+	@Override
+	public void onNo(String tag, Object data) {
+		if (safeMDMConfig.isRestoreDisabled()) {
+			finish();
+		}
+	}
+	// end generic alert dialog callbacks
+
+	// start password dialog callbacks
+	@Override
+	public void onYes(String tag, String text, boolean isChecked, Object data) {
+		Intent intent = new Intent(this, RestoreService.class);
+		intent.putExtra(RestoreService.EXTRA_RESTORE_BACKUP_FILE, (File) data);
+		intent.putExtra(RestoreService.EXTRA_RESTORE_BACKUP_PASSWORD, text);
+		ContextCompat.startForegroundService(this, intent);
+		finish();
+	}
+
+	@Override
+	public void onNo(String tag) {
+		if (safeMDMConfig.isRestoreDisabled()) {
+			finish();
+		}
+	}
+	// end password dialog callbacks
+
+	@Override
+	protected void onActivityResult(int requestCode, int resultCode, Intent resultData) {
+		if (resultCode != RESULT_OK) {
+			if (requestCode != REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS && requestCode != ThreemaActivity.ACTIVITY_ID_BACKUP_PICKER) {
+				if (safeMDMConfig.isRestoreDisabled()) {
+					finish();
+				}
+			}
+		}
+
+		switch (requestCode) {
+			case REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS:
+				FileUtil.selectFile(WizardBackupRestoreActivity.this, null, new String[]{MimeUtil.MIME_TYPE_ZIP}, ThreemaActivity.ACTIVITY_ID_BACKUP_PICKER, false, 0, fileService.getBackupPath().getPath());
+				break;
+
+			case ThreemaActivity.ACTIVITY_ID_RESTORE_KEY:
+				if (resultCode == RESULT_OK) {
+					setResult(RESULT_OK);
+					startNextWizard();
+				}
+				break;
+
+			case ThreemaActivity.ACTIVITY_ID_BACKUP_PICKER:
+				if (resultCode == RESULT_OK) {
+					setResult(RESULT_OK);
+					if (resultData != null) {
+						Uri uri;
+
+						uri = resultData.getData();
+						if (uri != null) {
+							restoreBackup(uri);
+						}
+					}
+				}
+				break;
+		}
+		super.onActivityResult(requestCode, resultCode, resultData);
+	}
+
+	private void startNextWizard() {
+		if (this.userService.hasIdentity()) {
+			this.preferenceService.setWizardRunning(true);
+			startActivity(new Intent(this, WizardBaseActivity.class));
+			overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
+			finish();
+		}
+	}
+}

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

@@ -86,8 +86,8 @@ import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TextUtil;
 import ch.threema.app.workers.IdentityStatesWorker;
-import ch.threema.client.LinkEmailException;
-import ch.threema.client.LinkMobileNoException;
+import ch.threema.domain.protocol.api.LinkEmailException;
+import ch.threema.domain.protocol.api.LinkMobileNoException;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
 
@@ -159,14 +159,14 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements View
 					if (safeConfig.isBackupForced()) {
 						setPage(WizardFragment1.PAGE_ID);
 					} else if (!isReadOnlyProfile()) {
-						WizardDialog wizardDialog = WizardDialog.newInstance(R.string.safe_disable_confirm, R.string.yes, R.string.no);
+						WizardDialog wizardDialog = WizardDialog.newInstance(R.string.safe_disable_confirm, R.string.yes, R.string.no, WizardDialog.Highlight.NEGATIVE);
 						wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_THREEMA_SAFE);
 					}
 				}
 
 				if (current == WizardFragment3.PAGE_ID && previous == WizardFragment2.PAGE_ID && TestUtil.empty(nickname)) {
 					if (!isReadOnlyProfile()) {
-						WizardDialog wizardDialog = WizardDialog.newInstance(R.string.new_wizard_use_id_as_nickname, R.string.yes, R.string.no);
+						WizardDialog wizardDialog = WizardDialog.newInstance(R.string.new_wizard_use_id_as_nickname, R.string.yes, R.string.no, WizardDialog.Highlight.POSITIVE);
 						wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_USE_ID_AS_NICKNAME);
 					}
 				}
@@ -197,7 +197,7 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements View
 									ConfigUtils.isWorkBuild() ?
 											R.string.new_wizard_anonymous_confirm :
 											R.string.new_wizard_anonymous_confirm_phone_only,
-									R.string.yes, R.string.no);
+									R.string.yes, R.string.no, WizardDialog.Highlight.NEGATIVE);
 							wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_USE_ANONYMOUSLY);
 						}
 					}
@@ -396,7 +396,7 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements View
 								WizardDialog wizardDialog = WizardDialog.newInstance(AppRestrictionUtil.getSafePasswordMessage(context), R.string.try_again);
 								wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_PASSWORD_BAD);
 							} else {
-								WizardDialog wizardDialog = WizardDialog.newInstance(R.string.password_bad_explain, R.string.try_again, R.string.continue_anyway);
+								WizardDialog wizardDialog = WizardDialog.newInstance(R.string.password_bad_explain, R.string.continue_anyway, R.string.try_again, WizardDialog.Highlight.NEGATIVE);
 								wizardDialog.show(getSupportFragmentManager(), DIALOG_TAG_PASSWORD_BAD);
 							}
 						}
@@ -592,7 +592,6 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements View
 				prevPage();
 				break;
 			case DIALOG_TAG_PASSWORD_BAD:
-				setPage(WizardFragment1.PAGE_ID);
 				break;
 			case DIALOG_TAG_THREEMA_SAFE:
 				break;
@@ -612,6 +611,7 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements View
 				prevPage();
 				break;
 			case DIALOG_TAG_PASSWORD_BAD:
+				setPage(WizardFragment1.PAGE_ID);
 				break;
 		}
 	}

+ 2 - 0
app/src/main/java/ch/threema/app/activities/wizard/WizardFingerPrintActivity.java

@@ -92,6 +92,8 @@ public class WizardFingerPrintActivity extends WizardBackgroundActivity implemen
 						}
 					}
 				}, PROGRESS_MAX);
+
+		findViewById(R.id.cancel).setOnClickListener(v -> finish());
 	}
 
 	@SuppressLint("StaticFieldLeak")

+ 7 - 5
app/src/main/java/ch/threema/app/activities/wizard/WizardRestoreIDActivity.java → app/src/main/java/ch/threema/app/activities/wizard/WizardIDRestoreActivity.java

@@ -43,7 +43,6 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import androidx.annotation.NonNull;
-import ch.threema.app.utils.QRScannerUtil;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericProgressDialog;
@@ -51,10 +50,11 @@ import ch.threema.app.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.EditTextUtil;
-import ch.threema.client.ThreemaConnection;
+import ch.threema.app.utils.QRScannerUtil;
+import ch.threema.domain.protocol.csp.connection.ThreemaConnection;
 
-public class WizardRestoreIDActivity extends WizardBackgroundActivity {
-	private static final Logger logger = LoggerFactory.getLogger(WizardRestoreIDActivity.class);
+public class WizardIDRestoreActivity extends WizardBackgroundActivity {
+	private static final Logger logger = LoggerFactory.getLogger(WizardIDRestoreActivity.class);
 	private static final String DIALOG_TAG_RESTORE_PROGRESS = "rp";
 	private static final int PERMISSION_REQUEST_CAMERA = 1;
 
@@ -114,7 +114,7 @@ public class WizardRestoreIDActivity extends WizardBackgroundActivity {
 		findViewById(R.id.wizard_finish).setOnClickListener(this::restoreID);
 		findViewById(R.id.wizard_cancel).setOnClickListener(this::onCancel);
 		findViewById(R.id.wizard_scan).setOnClickListener(v -> {
-			if (ConfigUtils.requestCameraPermissions(WizardRestoreIDActivity.this, null, PERMISSION_REQUEST_CAMERA)) {
+			if (ConfigUtils.requestCameraPermissions(WizardIDRestoreActivity.this, null, PERMISSION_REQUEST_CAMERA)) {
 				scanQR();
 			}
 		});
@@ -197,6 +197,7 @@ public class WizardRestoreIDActivity extends WizardBackgroundActivity {
 				logger.error(getString(R.string.invalid_barcode), this);
 			}
 		}
+		super.onActivityResult(requestCode, resultCode, intent);
 	}
 
 	@Override
@@ -207,6 +208,7 @@ public class WizardRestoreIDActivity extends WizardBackgroundActivity {
 	@TargetApi(Build.VERSION_CODES.M)
 	@Override
 	public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+		super.onRequestPermissionsResult(requestCode, permissions, grantResults);
 		switch (requestCode) {
 			case PERMISSION_REQUEST_CAMERA:
 				if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {

+ 18 - 83
app/src/main/java/ch/threema/app/activities/wizard/WizardIntroActivity.java

@@ -30,73 +30,42 @@ import android.text.TextUtils;
 import android.text.method.LinkMovementMethod;
 import android.text.style.ClickableSpan;
 import android.view.View;
-import android.widget.CompoundButton;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
 import android.widget.TextView;
 
-import java.util.Date;
-
-import androidx.appcompat.widget.AppCompatCheckBox;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.PrivacyPolicyActivity;
-import ch.threema.app.dialogs.WizardDialog;
-import ch.threema.app.services.PreferenceService;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.TestUtil;
 
-public class WizardIntroActivity extends WizardBackgroundActivity implements WizardDialog.WizardDialogCallback {
-
-	private static final String DIALOG_TAG_CHECK_PP = "pp";
+public class WizardIntroActivity extends WizardBackgroundActivity {
 	private static final int ACTIVITY_RESULT_PRIVACY_POLICY = 9442;
 
 	private AnimationDrawable frameAnimation;
-	private AppCompatCheckBox privacyPolicyCheckBox;
-	private LinearLayout buttonLayout;
 
 	@Override
 	protected void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
 		setContentView(R.layout.activity_wizard_intro);
 
-		privacyPolicyCheckBox = findViewById(R.id.wizard_switch_accept_privacy_policy);
-		privacyPolicyCheckBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
-			@Override
-			public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
-				if (!isChecked) {
-					if (preferenceService.getPrivacyPolicyAccepted() != null) {
-						preferenceService.clearPrivacyPolicyAccepted();
-					}
-				} else {
-					privacyPolicyCheckBox.setBackgroundDrawable(getResources().getDrawable(R.drawable.shape_switch));
-				}
-			}
-		});
-		TextView privacyPolicyExplainText = findViewById(R.id.wizard_privacy_policy_explain);
-		buttonLayout = findViewById(R.id.button_layout);
-
 		if (ConfigUtils.isWorkRestricted()) {
 			// Skip privacy policy check if admin pre-set a backup to restore - either Safe or ID
 			if (ThreemaSafeMDMConfig.getInstance().isRestoreForced()) {
-				checkPrivacyPolicy(true);
-				restoreBackup(null);
+				startActivity(new Intent(this, WizardSafeRestoreActivity.class));
 				finish();
 				return;
 			} else {
 				String backupString = AppRestrictionUtil.getStringRestriction(getString(R.string.restriction__id_backup));
 				String backupPassword = AppRestrictionUtil.getStringRestriction(getString(R.string.restriction__id_backup_password));
 				if (!TestUtil.empty(backupString) && !TestUtil.empty(backupPassword)) {
-					checkPrivacyPolicy(true);
-					Intent intent = new Intent(this, WizardRestoreMainActivity.class);
-
-					if (!TestUtil.empty(backupString) && !TestUtil.empty(backupPassword)) {
-						intent.putExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP, backupString);
-						intent.putExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP_PW, backupPassword);
-					}
+					Intent intent = new Intent(this, WizardBackupRestoreActivity.class);
+					intent.putExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP, backupString);
+					intent.putExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP_PW, backupPassword);
 					startActivity(intent);
 					overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
 					finish();
@@ -105,6 +74,7 @@ public class WizardIntroActivity extends WizardBackgroundActivity implements Wiz
 			}
 		}
 
+		LinearLayout buttonLayout = findViewById(R.id.button_layout);
 		if (savedInstanceState == null) {
 			buttonLayout.setVisibility(View.GONE);
 			buttonLayout.postDelayed(() -> AnimationUtil.slideInFromBottomOvershoot(buttonLayout), 200);
@@ -116,8 +86,8 @@ public class WizardIntroActivity extends WizardBackgroundActivity implements Wiz
 		frameAnimation.setOneShot(false);
 		frameAnimation.start();
 
-		if (preferenceService.getPrivacyPolicyAccepted() != null) {
-			privacyPolicyCheckBox.setVisibility(View.GONE);
+		TextView privacyPolicyExplainText = findViewById(R.id.wizard_privacy_policy_explain);
+		if (TestUtil.empty(ThreemaApplication.getAppContext().getString(R.string.privacy_policy_url))) {
 			privacyPolicyExplainText.setVisibility(View.GONE);
 		} else {
 			String privacyPolicy = getString(R.string.privacy_policy);
@@ -135,19 +105,20 @@ public class WizardIntroActivity extends WizardBackgroundActivity implements Wiz
 			privacyPolicyExplainText.setMovementMethod(LinkMovementMethod.getInstance());
 		}
 
+		((TextView) findViewById(R.id.new_to_threema_title)).setText(getString(R.string.new_to_threema, getString(R.string.app_name)));
+		((TextView) findViewById(R.id.back_to_threema_title)).setText(getString(R.string.back_to_threema, getString(R.string.app_name)));
+
 		findViewById(R.id.restore_backup).setOnClickListener(this::restoreBackup);
 		findViewById(R.id.setup_threema).setOnClickListener(this::setupThreema);
 	}
 
 	public void setupThreema(View view) {
-		if (checkPrivacyPolicy(false)) {
-			if (!userService.hasIdentity()) {
-				startActivity(new Intent(this, WizardFingerPrintActivity.class));
-			} else {
-				startActivity(new Intent(this, WizardBaseActivity.class));
-			}
-			overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
+		if (!userService.hasIdentity()) {
+			startActivity(new Intent(this, WizardFingerPrintActivity.class));
+		} else {
+			startActivity(new Intent(this, WizardBaseActivity.class));
 		}
+		overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
 	}
 
 	/**
@@ -155,24 +126,8 @@ public class WizardIntroActivity extends WizardBackgroundActivity implements Wiz
 	 * @param view
 	 */
 	public void restoreBackup(View view) {
-		if (checkPrivacyPolicy(false)) {
-			startActivity(new Intent(this, WizardRestoreMainActivity.class));
-			overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
-		}
-	}
-
-	private boolean checkPrivacyPolicy(boolean force) {
-		if (preferenceService.getPrivacyPolicyAccepted() != null) {
-			return true;
-		}
-
-		if (!privacyPolicyCheckBox.isChecked() && !force) {
-			WizardDialog.newInstance(String.format(getString(R.string.privacy_policy_check_confirm), getString(R.string.app_name)), R.string.ok).show(getSupportFragmentManager(), DIALOG_TAG_CHECK_PP);
-			return false;
-		}
-
-		preferenceService.setPrivacyPolicyAccepted(new Date(), force ? PreferenceService.PRIVACY_POLICY_ACCEPT_IMPLICIT : PreferenceService.PRIVACY_POLICY_ACCEPT_EXCPLICIT);
-		return true;
+		startActivity(new Intent(this, WizardBackupRestoreActivity.class));
+		overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
 	}
 
 	@Override
@@ -186,26 +141,6 @@ public class WizardIntroActivity extends WizardBackgroundActivity implements Wiz
 		}
 	}
 
-	@Override
-	public void onYes(String tag, Object data) {
-		privacyPolicyCheckBox.setBackgroundDrawable(getResources().getDrawable(R.drawable.shape_switch_alert));
-		privacyPolicyCheckBox.postDelayed(() -> {
-			if (!isFinishing()) {
-				privacyPolicyCheckBox.setBackgroundDrawable(getResources().getDrawable(R.drawable.shape_switch));
-			}
-		}, 400);
-		privacyPolicyCheckBox.postDelayed(() -> {
-			if (!isFinishing()) {
-				privacyPolicyCheckBox.setBackgroundDrawable(getResources().getDrawable(R.drawable.shape_switch_alert));
-			}
-		}, 600);
-	}
-
-	@Override
-	public void onNo(String tag) {
-
-	}
-
 	@Override
 	protected void onActivityResult(int requestCode, int resultCode, Intent data) {
 		super.onActivityResult(requestCode, resultCode, data);

+ 32 - 262
app/src/main/java/ch/threema/app/activities/wizard/WizardRestoreMainActivity.java → app/src/main/java/ch/threema/app/activities/wizard/WizardSafeRestoreActivity.java

@@ -22,12 +22,6 @@
 package ch.threema.app.activities.wizard;
 
 import android.annotation.SuppressLint;
-import android.content.ContentResolver;
-import android.content.Context;
-import android.content.Intent;
-import android.net.ConnectivityManager;
-import android.net.NetworkInfo;
-import android.net.Uri;
 import android.os.AsyncTask;
 import android.os.Build;
 import android.os.Bundle;
@@ -36,28 +30,19 @@ import android.text.InputType;
 import android.view.View;
 import android.widget.Button;
 import android.widget.EditText;
-import android.widget.TextView;
 import android.widget.Toast;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 
-import androidx.annotation.UiThread;
-import androidx.core.content.ContextCompat;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
-import ch.threema.app.activities.DisableBatteryOptimizationsActivity;
-import ch.threema.app.activities.ThreemaActivity;
-import ch.threema.app.backuprestore.csv.RestoreService;
-import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.PasswordEntryDialog;
 import ch.threema.app.dialogs.SimpleStringAlertDialog;
-import ch.threema.app.dialogs.WizardRestoreSelectorDialog;
 import ch.threema.app.dialogs.WizardSafeSearchPhoneDialog;
 import ch.threema.app.threemasafe.ThreemaSafeAdvancedDialog;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
@@ -66,27 +51,20 @@ 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.FileUtil;
-import ch.threema.app.utils.MimeUtil;
-import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.client.ProtocolDefines;
+import ch.threema.domain.protocol.csp.ProtocolDefines;
 
-public class WizardRestoreMainActivity extends WizardBackgroundActivity implements GenericAlertDialog.DialogClickListener, PasswordEntryDialog.PasswordEntryDialogClickListener, WizardSafeSearchPhoneDialog.WizardSafeSearchPhoneDialogCallback,  WizardRestoreSelectorDialog.WizardRestoreSelectorDialogCallback, ThreemaSafeAdvancedDialog.WizardDialogCallback {
-	private static final Logger logger = LoggerFactory.getLogger(WizardRestoreMainActivity.class);
+public class WizardSafeRestoreActivity extends WizardBackgroundActivity implements PasswordEntryDialog.PasswordEntryDialogClickListener,
+	WizardSafeSearchPhoneDialog.WizardSafeSearchPhoneDialogCallback,
+	ThreemaSafeAdvancedDialog.WizardDialogCallback {
+	private static final Logger logger = LoggerFactory.getLogger(WizardSafeRestoreActivity.class);
 
 	private static final String DIALOG_TAG_PASSWORD = "tpw";
 	private static final String DIALOG_TAG_PROGRESS = "tpr";
 	private static final String DIALOG_TAG_FORGOT_ID = "li";
-	private static final String DIALOG_TAG_RESTORE_SELECTOR = "rss";
-	private static final String DIALOG_TAG_DISABLE_ENERGYSAVE_CONFIRM = "de";
-	private static final String DIALOG_TAG_DOWNLOADING_BACKUP = "dwnldBkp";
-	private static final String DIALOG_TAG_NO_INTERNET = "nin";
 	private static final String DIALOG_TAG_ADVANCED = "adv";
 
-	public static final int REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS = 541;
-
 	private ThreemaSafeService threemaSafeService;
 
 	EditText identityEditText;
@@ -97,7 +75,7 @@ public class WizardRestoreMainActivity extends WizardBackgroundActivity implemen
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
 
-		setContentView(R.layout.activity_wizard_restore_main);
+		setContentView(R.layout.activity_wizard_restore_safe);
 
 		try {
 			threemaSafeService = ThreemaApplication.getServiceManager().getThreemaSafeService();
@@ -115,7 +93,6 @@ public class WizardRestoreMainActivity extends WizardBackgroundActivity implemen
 				this.identityEditText.setText(safeMDMConfig.getIdentity());
 				this.identityEditText.setEnabled(false);
 
-				findViewById(R.id.backup_restore_other_button).setVisibility(View.GONE);
 				findViewById(R.id.safe_restore_subtitle).setVisibility(View.INVISIBLE);
 				findViewById(R.id.forgot_id).setVisibility(View.GONE);
 
@@ -135,10 +112,10 @@ public class WizardRestoreMainActivity extends WizardBackgroundActivity implemen
 			}
 		});
 
-		findViewById(R.id.backup_restore_other_button).setOnClickListener(new View.OnClickListener() {
+		findViewById(R.id.cancel).setOnClickListener(new View.OnClickListener() {
 			@Override
 			public void onClick(View v) {
-				showOtherRestoreOptions();
+				finish();
 			}
 		});
 
@@ -167,27 +144,25 @@ public class WizardRestoreMainActivity extends WizardBackgroundActivity implemen
 				advancedOptions.setEnabled(false);
 				advancedOptions.setVisibility(View.GONE);
 			}
-			Intent intent = getIntent();
-			if (intent.hasExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP) &&
-				intent.hasExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP_PW)) {
-
-				launchIdRecovery(intent.getStringExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP),
-					intent.getStringExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP_PW));
-			}
-			if (safeMDMConfig.isRestoreDisabled()) {
-				findViewById(R.id.safe_restore_button).setVisibility(View.GONE);
-				((TextView) findViewById(R.id.safe_restore_title)).setText(R.string.restore);
-				findViewById(R.id.safe_restore_subtitle).setVisibility(View.INVISIBLE);
-				findViewById(R.id.forgot_id).setVisibility(View.GONE);
-				identityEditText.setVisibility(View.GONE);
-				advancedOptions.setVisibility(View.GONE);
-				showOtherRestoreOptions();
-			}
 		}
 	}
 
-	private void showOtherRestoreOptions() {
-		WizardRestoreSelectorDialog.newInstance().show(getSupportFragmentManager(), DIALOG_TAG_RESTORE_SELECTOR);
+	@Override
+	protected void onPause() {
+		ThreemaApplication.activityPaused(this);
+		super.onPause();
+	}
+
+	@Override
+	protected void onResume() {
+		ThreemaApplication.activityResumed(this);
+		super.onResume();
+	}
+
+	@Override
+	public void onUserInteraction() {
+		ThreemaApplication.activityUserInteract(this);
+		super.onUserInteraction();
 	}
 
 	private void doSafeRestore() {
@@ -198,7 +173,8 @@ public class WizardRestoreMainActivity extends WizardBackgroundActivity implemen
 			R.string.ok,
 			R.string.cancel,
 			ThreemaSafeServiceImpl.MIN_PW_LENGTH,
-			ThreemaSafeServiceImpl.MAX_PW_LENGTH, 0, 0, 0);
+			ThreemaSafeServiceImpl.MAX_PW_LENGTH,
+			0, 0, 0, PasswordEntryDialog.ForgotHintType.SAFE);
 		dialogFragment.show(getSupportFragmentManager(), DIALOG_TAG_PASSWORD);
 	}
 
@@ -284,7 +260,7 @@ public class WizardRestoreMainActivity extends WizardBackgroundActivity implemen
 					}
 					ConfigUtils.scheduleAppRestart(getApplicationContext(), 3000, getApplicationContext().getString(R.string.ipv6_restart_now));
 				} else {
-					Toast.makeText(WizardRestoreMainActivity.this, getString(R.string.safe_restore_failed) + ". " + failureMessage, Toast.LENGTH_LONG).show();
+					Toast.makeText(WizardSafeRestoreActivity.this, getString(R.string.safe_restore_failed) + ". " + failureMessage, Toast.LENGTH_LONG).show();
 					if (safeMDMConfig.isRestoreForced()) {
 						finish();
 					}
@@ -293,165 +269,6 @@ public class WizardRestoreMainActivity extends WizardBackgroundActivity implemen
 		}.execute();
 	}
 
-	private void launchIdRecovery(String backupString, String backupPassword) {
-		Intent intent = new Intent(this, WizardRestoreIDActivity.class);
-
-		if (!TestUtil.empty(backupString) && !TestUtil.empty(backupPassword)) {
-			intent.putExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP, backupString);
-			intent.putExtra(ThreemaApplication.INTENT_DATA_ID_BACKUP_PW, backupPassword);
-		}
-		startActivityForResult(intent, ThreemaActivity.ACTIVITY_ID_RESTORE_KEY);
-		overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
-	}
-
-	private void showDisableEnergySaveDialog() {
-		GenericAlertDialog.newInstance(R.string.menu_restore, R.string.restore_disable_energy_saving, R.string.ok, R.string.cancel).show(getSupportFragmentManager(), DIALOG_TAG_DISABLE_ENERGYSAVE_CONFIRM);
-	}
-
-	private void doDataBackupRestore(final Uri uri) {
-		if (!ContentResolver.SCHEME_FILE.equalsIgnoreCase(uri.getScheme()) && this.fileService != null) {
-			// copy "file" to cache directory first
-			GenericProgressDialog.newInstance(R.string.importing_files, R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_DOWNLOADING_BACKUP);
-
-			new Thread(() -> {
-				final File file = fileService.copyUriToTempFile(uri, "file", "zip", true);
-
-				RuntimeUtil.runOnUiThread(() -> {
-					DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_DOWNLOADING_BACKUP, true);
-
-					if (file != null) {
-						performDataBackupRestore(file);
-					} else {
-						SimpleStringAlertDialog.newInstance(R.string.importing_files, R.string.importing_files_failed).show(getSupportFragmentManager(), "ifail");
-					}
-				});
-			}).start();
-
-		} else {
-			String path = FileUtil.getRealPathFromURI(this, uri);
-			if (path != null && !path.isEmpty()) {
-				File file = new File(path);
-				if (file.exists()) {
-					performDataBackupRestore(file);
-				}
-			}
-		}
-	}
-
-	private void performDataBackupRestore(File file) {
-		if (file.exists()) {
-//			try {
-// Zipfile validity check is sometimes wrong
-//				ZipFile zipFile = new ZipFile(file);
-//				if (zipFile.isValidZipFile()) {
-				ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
-
-				NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
-				if (activeNetwork == null || !activeNetwork.isConnectedOrConnecting()) {
-					showNoInternetDialog(file);
-				} else {
-					confirmRestore(file);
-				}
-				return;
-			}
-//				}
-//			} catch (ZipException e) {
-//				logger.error("Exception", e);
-//			}
-		logger.error(getString(R.string.invalid_backup), this);
-	}
-
-	private void confirmRestore(File file) {
-		PasswordEntryDialog dialogFragment = PasswordEntryDialog.newInstance(
-			R.string.backup_data_title,
-			R.string.restore_data_password_msg,
-			R.string.password_hint,
-			R.string.ok,
-			R.string.cancel,
-			ThreemaApplication.MIN_PW_LENGTH_BACKUP,
-			ThreemaApplication.MAX_PW_LENGTH_BACKUP, 0, 0, 0);
-		dialogFragment.setData(file);
-		dialogFragment.show(getSupportFragmentManager(), "restorePW");
-	}
-
-	private void startNextWizard() {
-		logger.debug("start next wizard");
-
-		if (this.userService.hasIdentity()) {
-			serviceManager.getPreferenceService().setWizardRunning(true);
-
-			startActivity(new Intent(this, WizardBaseActivity.class));
-			overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
-			finish();
-		}
-	}
-
-	@UiThread
-	private void showNoInternetDialog(File file) {
-		GenericAlertDialog dialog = GenericAlertDialog.newInstance(R.string.menu_restore, R.string.new_wizard_need_internet, R.string.retry, R.string.cancel);
-		dialog.setData(file);
-		dialog.show(getSupportFragmentManager(), DIALOG_TAG_NO_INTERNET);
-	}
-
-	@Override
-	protected void onPause() {
-		ThreemaApplication.activityPaused(this);
-		super.onPause();
-	}
-
-	@Override
-	protected void onResume() {
-		ThreemaApplication.activityResumed(this);
-		super.onResume();
-	}
-
-	@Override
-	public void onUserInteraction() {
-		ThreemaApplication.activityUserInteract(this);
-		super.onUserInteraction();
-	}
-
-	@Override
-	protected void onActivityResult(int requestCode, int resultCode, Intent resultData) {
-		logger.debug("Restore: {} {}", requestCode, resultCode);
-
-		if (resultCode != RESULT_OK) {
-			if (requestCode != REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS && requestCode != ThreemaActivity.ACTIVITY_ID_BACKUP_PICKER) {
-				if (safeMDMConfig.isRestoreDisabled()) {
-					finish();
-				}
-			}
-		}
-
-		switch (requestCode) {
-			case REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS:
-				FileUtil.selectFile(WizardRestoreMainActivity.this, null, new String[]{MimeUtil.MIME_TYPE_ZIP}, ThreemaActivity.ACTIVITY_ID_BACKUP_PICKER, false, 0, fileService.getBackupPath().getPath());
-				break;
-
-			case ThreemaActivity.ACTIVITY_ID_RESTORE_KEY:
-				if (resultCode == RESULT_OK) {
-					setResult(RESULT_OK);
-					startNextWizard();
-				}
-				break;
-
-			case ThreemaActivity.ACTIVITY_ID_BACKUP_PICKER:
-				if (resultCode == RESULT_OK) {
-					setResult(RESULT_OK);
-					if (resultData != null) {
-						Uri uri;
-
-						uri = resultData.getData();
-						if (uri != null) {
-							doDataBackupRestore(uri);
-						}
-					}
-				}
-				break;
-		}
-		super.onActivityResult(requestCode, resultCode, resultData);
-	}
-
 	@Override
 	public void onBackPressed() {
 		finish();
@@ -459,19 +276,9 @@ public class WizardRestoreMainActivity extends WizardBackgroundActivity implemen
 
 	@Override
 	public void onYes(String tag, String text, boolean isChecked, Object data) {
-		if (DIALOG_TAG_PASSWORD.equals(tag)) {
-			// safe backup restore
-			if (!TestUtil.empty(text)) {
-				reallySafeRestore(text);
-			}
-		} else {
-			// data backup restore
-			Intent intent = new Intent(this, RestoreService.class);
-			intent.putExtra(RestoreService.EXTRA_RESTORE_BACKUP_FILE, (File) data);
-			intent.putExtra(RestoreService.EXTRA_RESTORE_BACKUP_PASSWORD, text);
-			ContextCompat.startForegroundService(this, intent);
-
-			finish();
+		// safe backup restore
+		if (!TestUtil.empty(text)) {
+			reallySafeRestore(text);
 		}
 	}
 
@@ -484,21 +291,6 @@ public class WizardRestoreMainActivity extends WizardBackgroundActivity implemen
 		}
 	}
 
-	@Override
-	public void onYes(String tag, Object data) {
-		switch (tag) {
-			case DIALOG_TAG_DISABLE_ENERGYSAVE_CONFIRM:
-				Intent intent = new Intent(this, DisableBatteryOptimizationsActivity.class);
-				intent.putExtra(DisableBatteryOptimizationsActivity.EXTRA_NAME, getString(R.string.restore));
-				intent.putExtra(DisableBatteryOptimizationsActivity.EXTRA_WIZARD, true);
-				startActivityForResult(intent, REQUEST_ID_DISABLE_BATTERY_OPTIMIZATIONS);
-				break;
-			case DIALOG_TAG_NO_INTERNET:
-				performDataBackupRestore((File) data);
-				break;
-		}
-	}
-
 	@Override
 	public void onYes(String tag, ThreemaSafeServerInfo serverInfo) {
 		this.serverInfo = serverInfo;
@@ -506,30 +298,8 @@ public class WizardRestoreMainActivity extends WizardBackgroundActivity implemen
 
 	@Override
 	public void onNo(String tag) {
-		if (DIALOG_TAG_RESTORE_SELECTOR.equals(tag)) {
-			if (safeMDMConfig.isRestoreDisabled()) {
-				finish();
-			}
-		}
-	}
-
-	@Override
-	public void onNo(String tag, Object data) {
-		if (DIALOG_TAG_DISABLE_ENERGYSAVE_CONFIRM.equals(tag)) {
-			if (safeMDMConfig.isRestoreDisabled()) {
-				finish();
-			}
+		if (safeMDMConfig.isRestoreDisabled()) {
+			finish();
 		}
 	}
-
-	@Override
-	public void onDataBackupRestore() {
-		showDisableEnergySaveDialog();
-	}
-
-	@Override
-	public void onIdBackupRestore() {
-		launchIdRecovery(null, null);
-	}
-
 }

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

@@ -80,7 +80,7 @@ import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.QuoteUtil;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.client.file.FileData;
+import ch.threema.domain.protocol.csp.messages.file.FileData;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.DateSeparatorMessageModel;

+ 12 - 10
app/src/main/java/ch/threema/app/adapters/ContactDetailAdapter.java

@@ -42,12 +42,13 @@ import org.slf4j.LoggerFactory;
 import java.util.List;
 
 import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
 import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
+import ch.threema.app.dialogs.PublicKeyDialog;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.ContactService;
-import ch.threema.app.services.FingerPrintService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.IdListService;
 import ch.threema.app.services.PreferenceService;
@@ -67,7 +68,6 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 	private ContactService contactService;
 	private GroupService groupService;
 	private PreferenceService preferenceService;
-	private FingerPrintService fingerprintService;
 	private IdListService excludeFromSyncListService;
 	private IdListService blackListIdentityService;
 	private final ContactModel contactModel;
@@ -90,7 +90,7 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 
 	public class HeaderHolder extends RecyclerView.ViewHolder {
 		private final VerificationLevelImageView verificationLevelImageView;
-		private final TextView threemaIdView, fingerprintView;
+		private final TextView threemaIdView;
 		private final CheckBox synchronize;
 		private final View nicknameContainer, synchronizeContainer;
 		private final ImageView syncSourceIcon;
@@ -101,9 +101,7 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 		public HeaderHolder(View view) {
 			super(view);
 
-			// items in object
 			this.threemaIdView = itemView.findViewById(R.id.threema_id);
-			this.fingerprintView = itemView.findViewById(R.id.key_fingerprint);
 			this.verificationLevelImageView = itemView.findViewById(R.id.verification_level_image);
 			ImageView verificationLevelIconView = itemView.findViewById(R.id.verification_information_icon);
 			this.synchronize = itemView.findViewById(R.id.synchronize_contact);
@@ -123,6 +121,13 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 					}
 				}
 			});
+
+			itemView.findViewById(R.id.public_key_button).setOnClickListener(v -> {
+				if (context instanceof AppCompatActivity) {
+					PublicKeyDialog.newInstance(context.getString(R.string.public_key_for, contactModel.getIdentity()), contactModel.getPublicKey())
+						.show(((AppCompatActivity) context).getSupportFragmentManager(), "pk");
+				}
+			});
 		}
 	}
 
@@ -135,7 +140,6 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 		try {
 			this.contactService = serviceManager.getContactService();
 			this.groupService = serviceManager.getGroupService();
-			this.fingerprintService = serviceManager.getFingerPrintService();
 			this.excludeFromSyncListService = serviceManager.getExcludedSyncIdentitiesService();
 			this.blackListIdentityService = serviceManager.getBlackListService();
 			this.preferenceService = serviceManager.getPreferenceService();
@@ -182,7 +186,6 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 			String identityAdditional = null;
 			if(this.contactModel.getState() != null) {
 				switch (this.contactModel.getState()) {
-					case TEMPORARY:
 					case ACTIVE:
 						if (blackListIdentityService.has(contactModel.getIdentity())) {
 							identityAdditional = context.getString(R.string.blocked);
@@ -197,7 +200,6 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 				}
 			}
 			headerHolder.threemaIdView.setText(contactModel.getIdentity() + (identityAdditional != null ? " (" + identityAdditional + ")" : ""));
-			headerHolder.fingerprintView.setText(this.fingerprintService.getFingerPrint(contactModel.getIdentity()));
 			headerHolder.verificationLevelImageView.setContactModel(contactModel);
 			headerHolder.verificationLevelImageView.setVisibility(View.VISIBLE);
 
@@ -271,9 +273,9 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 
 	@Override
 	public int getItemViewType(int position) {
-		if (isPositionHeader(position))
+		if (isPositionHeader(position)) {
 			return TYPE_HEADER;
-
+		}
 		return TYPE_ITEM;
 	}
 

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

@@ -30,9 +30,13 @@ import android.view.View;
 import android.view.ViewGroup;
 import android.widget.Filter;
 import android.widget.ImageView;
+import android.widget.ListView;
 import android.widget.SectionIndexer;
 import android.widget.TextView;
 
+import com.google.android.material.card.MaterialCardView;
+import com.google.android.material.shape.ShapeAppearanceModel;
+
 import org.jetbrains.annotations.NotNull;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -48,6 +52,7 @@ import java.util.HashSet;
 import java.util.List;
 
 import androidx.annotation.NonNull;
+import androidx.constraintlayout.widget.ConstraintLayout;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.emojis.EmojiTextView;
@@ -75,13 +80,14 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 	private final IdListService blackListIdentityService;
 
 	public static final int VIEW_TYPE_NORMAL = 0;
+	public static final int VIEW_TYPE_RECENTLY_ADDED = 1;
 	public static final int VIEW_TYPE_COUNT = 2;
 
 	private static final String PLACEHOLDER_BLANK_HEADER = " ";
 	private static final String PLACEHOLDER_CHANNELS = "\uffff";
 	private static final String PLACEHOLDER_RECENTLY_ADDED = "\u0001";
 	private static final String CHANNEL_SIGN = "\u002a";
-	private static final String RECENTLY_ADDED_SIGN = "+";
+	public static final String RECENTLY_ADDED_SIGN = "+";
 
 	private List<ContactModel> values, ovalues, recentlyAdded = new ArrayList<>();
 	private ContactListFilter contactListFilter;
@@ -240,7 +246,7 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 		} else {
 			if (ContactUtil.isChannelContact(c)) {
 				firstLetter = afterSorting ? CHANNEL_SIGN : PLACEHOLDER_CHANNELS;
-			} else if (recentlyAdded != null && recentlyAdded.size() > 0 && position < recentlyAdded.size() && recentlyAdded.contains(c)) {
+			} else if (getItemViewType(position) == VIEW_TYPE_RECENTLY_ADDED) {
 				if (contactListFilter != null && contactListFilter.getFilterString() != null) {
 					if (position > 0) {
 						for (int i = Math.min(position - 1, MAX_RECENTLY_ADDED_CONTACTS - 1) ; i >= 0; i--) {
@@ -272,14 +278,19 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 	@NonNull
 	@Override
 	public View getView(final int position, View convertView, @NonNull ViewGroup parent) {
-		CheckableConstraintLayout itemView = (CheckableConstraintLayout) convertView;
+		final int viewType = getItemViewType(position);
+		ConstraintLayout itemView = (ConstraintLayout) convertView;
 
 		ContactListHolder holder;
 
 		if (convertView == null) {
 			// This a new view we inflate the new layout
 			holder = new ContactListHolder();
-			itemView = (CheckableConstraintLayout) inflater.inflate(R.layout.item_contact_list, parent, false);
+
+			itemView = (ConstraintLayout) inflater.inflate(
+						viewType == VIEW_TYPE_RECENTLY_ADDED ?
+						R.layout.item_contact_list_recently_added :
+						R.layout.item_contact_list, parent, false);
 
 			holder.nameTextView = itemView.findViewById(R.id.name);
 			holder.idTextView = itemView.findViewById(R.id.subject);
@@ -291,16 +302,29 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 			holder.initialImageView = itemView.findViewById(R.id.initial_image);
 
 			itemView.setTag(holder);
-			itemView.setOnCheckedChangeListener(new CheckableConstraintLayout.OnCheckedChangeListener() {
-				@Override
-				public void onCheckedChanged(CheckableConstraintLayout checkableView, boolean isChecked) {
+
+			if (viewType == VIEW_TYPE_NORMAL) {
+				((CheckableConstraintLayout) itemView).setOnCheckedChangeListener((checkableView, isChecked) -> {
 					if (isChecked) {
 						checkedItems.add(((ContactListHolder) checkableView.getTag()).originalPosition);
 					} else {
 						checkedItems.remove(((ContactListHolder) checkableView.getTag()).originalPosition);
 					}
-				}
-			});
+				});
+			} else if (viewType == VIEW_TYPE_RECENTLY_ADDED) {
+				int cornerSize = getContext().getResources().getDimensionPixelSize(R.dimen.recently_added_background_corner_size);
+				int recentlyAddedLastPosition = recentlyAdded.size() - 1;
+
+				ShapeAppearanceModel shapeAppearanceModel = new ShapeAppearanceModel.Builder()
+					.setTopLeftCornerSize(position == 0 ? cornerSize : 0)
+					.setTopRightCornerSize(position == 0 ? cornerSize : 0)
+					.setBottomLeftCornerSize(position == recentlyAddedLastPosition ? cornerSize : 0)
+					.setBottomRightCornerSize(position == recentlyAddedLastPosition ? cornerSize : 0)
+					.build();
+
+				MaterialCardView cardView = itemView.findViewById(R.id.recently_added_background);
+				cardView.setShapeAppearanceModel(shapeAppearanceModel);
+			}
 		} else {
 			holder = (ContactListHolder) itemView.getTag();
 		}
@@ -386,11 +410,30 @@ public class ContactListAdapter extends FilterableListAdapter implements Section
 
 		holder.avatarView.setBadgeVisible(contactService.showBadge(contactModel));
 
+		//itemView.setEnabled(viewType == VIEW_TYPE_NORMAL);
+		if (viewType == VIEW_TYPE_RECENTLY_ADDED) {
+			itemView.setOnLongClickListener(v -> true);
+			itemView.setOnClickListener(new View.OnClickListener() {
+				@Override
+				public void onClick(View v) {
+					ListView listView = (ListView) parent;
+					if (listView.getCheckedItemCount() == 0) {
+						listView.getOnItemClickListener().onItemClick(null, v, position, 0L);
+					}
+				}
+			});
+		}
+
 		return itemView;
 	}
 
 	@Override
 	public int getItemViewType(int position) {
+		ContactModel c = values.get(position);
+
+		if (recentlyAdded != null && recentlyAdded.size() > 0 && position < recentlyAdded.size() && recentlyAdded.contains(c)) {
+			return VIEW_TYPE_RECENTLY_ADDED;
+		}
 		return VIEW_TYPE_NORMAL;
 	}
 

+ 6 - 6
app/src/main/java/ch/threema/app/adapters/DirectoryAdapter.java

@@ -44,9 +44,9 @@ import ch.threema.app.services.ContactService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.ui.InitialAvatarView;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.client.work.WorkDirectoryCategory;
-import ch.threema.client.work.WorkDirectoryContact;
-import ch.threema.client.work.WorkOrganization;
+import ch.threema.domain.protocol.api.work.WorkDirectoryCategory;
+import ch.threema.domain.protocol.api.work.WorkDirectoryContact;
+import ch.threema.domain.protocol.api.work.WorkOrganization;
 
 public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, RecyclerView.ViewHolder> {
 	private final Context context;
@@ -56,7 +56,7 @@ public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, Rec
 	private final WorkOrganization workOrganization;
 	private final HashMap<String, String> categoryMap = new HashMap<>();
 	private DirectoryAdapter.OnClickItemListener onClickItemListener;
-	@DrawableRes private int backgroundRes;
+	@DrawableRes private final int backgroundRes;
 
 	private static class DirectoryHolder extends RecyclerView.ViewHolder {
 		private final TextView nameView;
@@ -113,14 +113,14 @@ public class DirectoryAdapter extends PagedListAdapter<WorkDirectoryContact, Rec
 
 	@NonNull
 	@Override
-	public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup viewGroup, int i) {
+	public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
 		View itemView = inflater.inflate(R.layout.item_directory, viewGroup, false);
 		itemView.setBackgroundResource(R.drawable.listitem_background_selector);
 		return new DirectoryHolder(itemView);
 	}
 
 	@Override
-	public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
+	public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
 		boolean isMe = false;
 		final DirectoryHolder holder = (DirectoryHolder) viewHolder;
 

+ 121 - 20
app/src/main/java/ch/threema/app/adapters/GroupDetailAdapter.java

@@ -21,9 +21,7 @@
 
 package ch.threema.app.adapters;
 
-import android.app.Activity;
 import android.content.Context;
-import android.content.Intent;
 import android.graphics.Bitmap;
 import android.view.LayoutInflater;
 import android.view.View;
@@ -34,24 +32,33 @@ import android.widget.TextView;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.IOException;
 import java.util.List;
 
-import androidx.core.app.ActivityCompat;
-import androidx.core.app.ActivityOptionsCompat;
+import androidx.annotation.NonNull;
+import androidx.appcompat.widget.AppCompatImageButton;
+import androidx.appcompat.widget.SwitchCompat;
+import androidx.constraintlayout.widget.ConstraintLayout;
 import androidx.recyclerview.widget.RecyclerView;
+import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
-import ch.threema.app.activities.ContactDetailActivity;
-import ch.threema.app.activities.ThreemaActivity;
+import ch.threema.app.activities.GroupDetailActivity;
+import ch.threema.app.dialogs.ShowOnceDialog;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.ContactService;
+import ch.threema.app.services.group.GroupInviteService;
 import ch.threema.app.ui.AvatarView;
 import ch.threema.app.ui.SectionHeaderView;
 import ch.threema.app.utils.AdapterUtil;
+import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.LocaleUtil;
 import ch.threema.app.utils.NameUtil;
+import ch.threema.domain.protocol.csp.messages.group.GroupInviteToken;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
+import ch.threema.storage.models.group.GroupInviteModel;
+import java8.util.Optional;
 
 public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
 	private static final Logger logger = LoggerFactory.getLogger(GroupDetailAdapter.class);
@@ -61,9 +68,13 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 
 	private Context context;
 	private ContactService contactService;
+	private GroupInviteService groupInviteService;
 	private GroupModel groupModel;
+	private GroupInviteModel defaultGroupInviteModel;
 	private List<ContactModel> contactModels; // Cached copy of group members
-	private OnClickListener onClickListener;
+	private OnGroupDetailsClickListener onClickListener;
+	HeaderHolder headerHolder;
+	private boolean warningShown = false;
 
 	public static class ItemHolder extends RecyclerView.ViewHolder {
 		public final View view;
@@ -86,6 +97,12 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		private final TextView ownerName;
 		private final TextView ownerThreemaId;
 		private final SectionHeaderView ownerNameTitle;
+		private final ConstraintLayout linkContainerView;
+		private final SectionHeaderView groupLinkTitle;
+		private final SwitchCompat linkEnableSwitch;
+		private final TextView linkString;
+		private final AppCompatImageButton linkResetButton;
+		private final AppCompatImageButton linkShareButton;
 
 		public HeaderHolder(View view) {
 			super(view);
@@ -97,6 +114,12 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 			this.ownerName = itemView.findViewById(R.id.group_name);
 			this.ownerThreemaId = itemView.findViewById(R.id.threemaid);
 			this.ownerNameTitle = itemView.findViewById(R.id.group_owner_title);
+			this.linkContainerView = itemView.findViewById(R.id.group_link_container);
+			this.groupLinkTitle = itemView.findViewById(R.id.group_link_header);
+			this.linkEnableSwitch = itemView.findViewById(R.id.group_link_switch);
+			this.linkString = itemView.findViewById(R.id.group_link_string);
+			this.linkResetButton = itemView.findViewById(R.id.reset_button);
+			this.linkShareButton = itemView.findViewById(R.id.share_button);
 		}
 	}
 
@@ -107,8 +130,9 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 
 		try {
 			this.contactService = serviceManager.getContactService();
+			this.groupInviteService = serviceManager.getGroupInviteService();
 		} catch (Exception e) {
-			logger.error("Exception", e);
+			logger.error("Exception, failed to get required services", e);
 		}
 	}
 
@@ -148,19 +172,15 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 			itemHolder.view.setOnClickListener(new View.OnClickListener() {
 				@Override
 				public void onClick(View v) {
-					onClickListener.onItemClick(v, contactModel);
+					onClickListener.onGroupMemberClick(v, contactModel);
 				}
 			});
 		} else {
-			HeaderHolder headerHolder = (HeaderHolder) holder;
+			this.headerHolder = (HeaderHolder) holder;
 			headerHolder.groupOwnerContainerView.setOnClickListener(new View.OnClickListener() {
 				@Override
 				public void onClick(View v) {
-					Intent intent = new Intent(context, ContactDetailActivity.class);
-					intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, groupModel.getCreatorIdentity());
-					intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
-					ActivityOptionsCompat options = ActivityOptionsCompat.makeScaleUpAnimation(v, 0, 0, v.getWidth(), v.getHeight());
-					ActivityCompat.startActivityForResult((Activity) context, intent, ThreemaActivity.ACTIVITY_ID_CONTACT_DETAIL, options.toBundle());
+					onClickListener.onGroupOwnerClick(v, groupModel.getCreatorIdentity());
 				}
 			});
 
@@ -171,6 +191,13 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 				headerHolder.ownerAvatarView.setImageBitmap(bitmap);
 				headerHolder.ownerThreemaId.setText(ownerContactModel.getIdentity());
 				headerHolder.ownerName.setText(NameUtil.getDisplayNameOrNickname(ownerContactModel, true));
+
+				if (!ConfigUtils.supportsGroupLinks() || ownerContactModel != contactService.getMe()) {
+					headerHolder.linkContainerView.setVisibility(View.GONE);
+				}
+				else {
+					initGroupLinkSection();
+				}
 			} else {
 				// creator is no longer around / has been revoked
 				headerHolder.ownerAvatarView.setImageBitmap(contactService.getDefaultAvatar(null, false));
@@ -183,9 +210,80 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 
 			if (contactModels != null) {
 				headerHolder.groupMembersTitleView.setText(context.getString(R.string.add_group_members_list) +
-					" (" + contactModels.size() + "/" + context.getResources().getInteger(R.integer.max_group_size) +
-					")");
+					" (" + contactModels.size() + "/" + BuildConfig.MAX_GROUP_SIZE + ")");
+			}
+		}
+	}
+
+	private void initGroupLinkSection() {
+		Optional<GroupInviteModel> groupInviteModelOptional = groupInviteService.getDefaultGroupInvite(groupModel);
+		if (groupInviteModelOptional.isPresent()) {
+			this.defaultGroupInviteModel = groupInviteModelOptional.get();
+		}
+		boolean enableGroupLinkSwitch = defaultGroupInviteModel != null && !defaultGroupInviteModel.isInvalidated();
+		headerHolder.linkEnableSwitch.setChecked(enableGroupLinkSwitch);
+		setGroupLinkViewsEnabled(enableGroupLinkSwitch);
+		if (defaultGroupInviteModel != null) {
+			encodeAndDisplayDefaultLink();
+		}
+		else {
+			headerHolder.linkString.setText(R.string.group_link_none);
+		}
+
+		headerHolder.linkEnableSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+			GroupDetailAdapter.this.setGroupLinkViewsEnabled(isChecked);
+			if (isChecked) {
+				try {
+					GroupDetailAdapter.this.defaultGroupInviteModel = groupInviteService.createOrEnableDefaultLink(groupModel);
+					encodeAndDisplayDefaultLink();
+				} catch (GroupInviteToken.InvalidGroupInviteTokenException | IOException | GroupInviteModel.MissingRequiredArgumentsException e) {
+					logger.error("Exception, failed to create or get default group link", e);
+				}
+			}
+			else {
+				groupInviteService.deleteDefaultLink(groupModel);
+				GroupDetailAdapter.this.defaultGroupInviteModel = null;
 			}
+		});
+		headerHolder.linkShareButton.setOnClickListener(v -> onClickListener.onShareLinkClick());
+		headerHolder.linkResetButton.setOnClickListener(v -> {
+			if (!warningShown && !ShowOnceDialog.shouldNotShowAnymore(GroupDetailActivity.DIALOG_SHOW_ONCE_RESET_LINK_INFO)) {
+				// show only once dialog
+				onClickListener.onResetLinkClick();
+				warningShown = true;
+				return;
+			}
+			try {
+				this.defaultGroupInviteModel = groupInviteService.resetDefaultGroupInvite(groupModel);
+				encodeAndDisplayDefaultLink();
+			} catch (IOException | GroupInviteToken.InvalidGroupInviteTokenException | GroupInviteModel.MissingRequiredArgumentsException e) {
+				logger.error("Exception, failed to reset default group link", e);
+			}
+		});
+
+		headerHolder.groupLinkTitle.setText(context.getString(R.string.default_group_link) +
+			" (" + groupInviteService.getCustomLinksCount() + " " + context.getString(R.string.other_group_links) + ")" );
+	}
+
+	private void encodeAndDisplayDefaultLink() {
+		headerHolder.linkString.setText(
+			groupInviteService.encodeGroupInviteLink(GroupDetailAdapter.this.defaultGroupInviteModel).toString()
+		);
+	}
+
+	private void setGroupLinkViewsEnabled(boolean enabled) {
+		headerHolder.linkContainerView.setEnabled(enabled);
+		headerHolder.linkResetButton.setEnabled(enabled);
+		headerHolder.linkShareButton.setEnabled(enabled);
+		if (enabled) {
+			headerHolder.linkString.setAlpha(1F);
+			headerHolder.linkResetButton.setAlpha(1F);
+			headerHolder.linkShareButton.setAlpha(1F);
+		}
+		else {
+			headerHolder.linkString.setAlpha(0.5F);
+			headerHolder.linkResetButton.setAlpha(0.5F);
+			headerHolder.linkShareButton.setAlpha(0.5F);
 		}
 	}
 
@@ -216,11 +314,14 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		return contactModels.get(position - 1);
 	}
 
-	public void setOnClickListener(OnClickListener listener) {
+	public void setOnClickListener(OnGroupDetailsClickListener listener) {
 		onClickListener = listener;
 	}
 
-	public interface OnClickListener {
-		void onItemClick(View v, ContactModel contactModel);
+	public interface OnGroupDetailsClickListener {
+		void onGroupOwnerClick(View v, String identity);
+		void onGroupMemberClick(View v, @NonNull ContactModel contactModel);
+		void onResetLinkClick();
+		void onShareLinkClick();
 	}
 }

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

@@ -502,9 +502,9 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 				// handle selection in multi-pane mode
 				if (highlightUid != null && highlightUid.equals(conversationModel.getUid()) && context instanceof ComposeMessageActivity) {
 					if (ConfigUtils.getAppTheme(context) == ConfigUtils.THEME_DARK) {
-						holder.listItemFG.setBackgroundResource(R.color.settings_multipane_selection_bg_dark);
+						holder.listItemFG.setBackgroundResource(R.color.dark_settings_multipane_selection_bg);
 					} else {
-						holder.listItemFG.setBackgroundResource(R.color.settings_multipane_selection_bg_light);
+						holder.listItemFG.setBackgroundResource(R.color.settings_multipane_selection_bg);
 					}
 				} else {
 					holder.listItemFG.setBackgroundColor(this.backgroundColor);

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

@@ -41,7 +41,7 @@ import ch.threema.app.utils.FileUtil;
 import ch.threema.app.utils.ImageViewUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.client.file.FileData;
+import ch.threema.domain.protocol.csp.messages.file.FileData;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.data.media.FileDataModel;

+ 6 - 5
app/src/main/java/ch/threema/app/adapters/decorators/BallotChatAdapterDecorator.java

@@ -35,6 +35,7 @@ import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.SelectorDialog;
 import ch.threema.app.exceptions.NotAllowedException;
 import ch.threema.app.services.GroupService;
+import ch.threema.app.ui.SelectorDialogItem;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
 import ch.threema.app.utils.BallotUtil;
 import ch.threema.storage.models.AbstractMessageModel;
@@ -135,26 +136,26 @@ public class BallotChatAdapterDecorator extends ChatAdapterDecorator {
 
 	private void showChooser(final BallotModel ballotModel) {
 
-		final ArrayList<String> items = new ArrayList<>();
+		ArrayList<SelectorDialogItem> items = new ArrayList<>();
 		final ArrayList<Integer> action = new ArrayList<>();
 		String title = null;
 
 		if (BallotUtil.canVote(ballotModel, helper.getMyIdentity())) {
-			items.add(getContext().getString(R.string.ballot_vote));
+			items.add(new SelectorDialogItem(getContext().getString(R.string.ballot_vote), R.drawable.ic_vote_outline));
 			action.add(ACTION_VOTE);
 		}
 
 		if (BallotUtil.canViewMatrix(ballotModel, helper.getMyIdentity())) {
 			if (ballotModel.getState() == BallotModel.State.CLOSED) {
-				items.add(getContext().getString(R.string.ballot_result_final));
+				items.add(new SelectorDialogItem(getContext().getString(R.string.ballot_result_final), R.drawable.ic_ballot_outline));
 			} else {
-				items.add(getContext().getString(R.string.ballot_result_intermediate));
+				items.add(new SelectorDialogItem(getContext().getString(R.string.ballot_result_intermediate), R.drawable.ic_ballot_outline));
 			}
 			action.add(ACTION_RESULTS);
 		}
 
 		if (BallotUtil.canClose(ballotModel, helper.getMyIdentity())) {
-			items.add(getContext().getString(R.string.ballot_close));
+			items.add(new SelectorDialogItem(getContext().getString(R.string.ballot_close), R.drawable.ic_check));
 			action.add(ACTION_CLOSE);
 		}
 

+ 6 - 13
app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java

@@ -22,7 +22,6 @@
 package ch.threema.app.adapters.decorators;
 
 import android.content.Context;
-import android.content.res.TypedArray;
 import android.graphics.Bitmap;
 import android.graphics.drawable.Drawable;
 import android.text.Spannable;
@@ -39,8 +38,9 @@ import org.slf4j.LoggerFactory;
 import java.util.HashMap;
 import java.util.Map;
 
-import androidx.annotation.AttrRes;
+import androidx.annotation.DrawableRes;
 import androidx.annotation.Nullable;
+import androidx.appcompat.content.res.AppCompatResources;
 import androidx.fragment.app.Fragment;
 import ch.threema.app.R;
 import ch.threema.app.cache.ThumbnailCache;
@@ -504,23 +504,16 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 
 	void setDefaultBackground(ComposeMessageHolder holder) {
 		if (holder.messageBlockView.getBackground() == null) {
-			@AttrRes int attr;
+			@DrawableRes int drawableRes;
 
 			if (this.getMessageModel().isOutbox() && !(this.getMessageModel() instanceof DistributionListMessageModel)) {
 				// outgoing
-				attr = R.attr.chat_bubble_send;
+				drawableRes = R.drawable.bubble_send_selector;
 			} else {
 				// incoming
-				attr = R.attr.chat_bubble_recv;
+				drawableRes = R.drawable.bubble_recv_selector;
 			}
-
-			TypedArray typedArray;
-			typedArray = getContext().getTheme().obtainStyledAttributes(new int[] { attr });
-
-			Drawable drawable = typedArray.getDrawable(0);
-
-			typedArray.recycle();
-			holder.messageBlockView.setBackground(drawable);
+			holder.messageBlockView.setBackground(AppCompatResources.getDrawable(getContext(), drawableRes));
 
 			logger.debug("*** setDefaultBackground");
 		}

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

@@ -52,7 +52,7 @@ import ch.threema.app.utils.LinkifyUtil;
 import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.client.file.FileData;
+import ch.threema.domain.protocol.csp.messages.file.FileData;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.DistributionListMessageModel;
 import ch.threema.storage.models.MessageState;

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

@@ -80,10 +80,10 @@ public class TextChatAdapterDecorator extends ChatAdapterDecorator {
 				if (messageText != null && messageText.length() > helper.getMaxBubbleTextLength()) {
 					holder.readOnContainer.setVisibility(View.VISIBLE);
 					if (quoteType != QuoteUtil.QUOTE_TYPE_NONE) {
-						holder.readOnContainer.setBackgroundResource(ConfigUtils.getResourceFromAttribute(getContext(),
+						holder.readOnContainer.setBackgroundResource(
 							this.getMessageModel().isOutbox() ?
-								R.attr.chat_bubble_fade_send :
-								R.attr.chat_bubble_fade_recv));
+								R.drawable.bubble_fade_send_selector :
+								R.drawable.bubble_fade_recv_selector);
 					}
 					holder.readOnButton.setOnClickListener(view -> {
 						Intent intent = new Intent(helper.getFragment().getContext(), TextChatBubbleActivity.class);

+ 1 - 3
app/src/main/java/ch/threema/app/adapters/decorators/VideoChatAdapterDecorator.java

@@ -28,8 +28,6 @@ import android.text.format.Formatter;
 import android.view.View;
 import android.widget.Toast;
 
-import com.google.android.exoplayer2.ui.DefaultTimeBar;
-
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -46,7 +44,7 @@ import ch.threema.app.utils.LinkifyUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.StringConversionUtil;
 import ch.threema.app.utils.TestUtil;
-import ch.threema.client.file.FileData;
+import ch.threema.domain.protocol.csp.messages.file.FileData;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.DistributionListMessageModel;
 import ch.threema.storage.models.MessageState;

+ 3 - 0
app/src/main/java/ch/threema/app/archive/ArchiveActivity.java

@@ -400,6 +400,9 @@ public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAl
 		@Override
 		public void onRemoved(AbstractMessageModel removedMessageModel) {}
 
+		@Override
+		public void onRemoved(List<AbstractMessageModel> removedMessageModels) {}
+
 		@Override
 		public void onProgressChanged(AbstractMessageModel messageModel, int newProgress) {}
 	};

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

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

+ 8 - 0
app/src/main/java/ch/threema/app/asynctasks/DeleteDistributionListAsyncTask.java

@@ -27,6 +27,7 @@ import androidx.fragment.app.Fragment;
 import ch.threema.app.R;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.listeners.ConversationListener;
+import ch.threema.app.listeners.DistributionListListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.services.DistributionListService;
 import ch.threema.app.utils.DialogUtil;
@@ -72,6 +73,13 @@ public class DeleteDistributionListAsyncTask extends AsyncTask<Void, Void, Void>
 			}
 		});
 
+		ListenerManager.distributionListListeners.handle(new ListenerManager.HandleListener<DistributionListListener>() {
+			@Override
+			public void handle(DistributionListListener listener) {
+				listener.onRemove(distributionListModel);
+			}
+		});
+
 		if (runOnCompletion != null) {
 			runOnCompletion.run();
 		}

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

@@ -41,7 +41,7 @@ import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.SecureDeleteUtil;
 import ch.threema.app.webclient.services.SessionWakeUpServiceImpl;
 import ch.threema.app.webclient.services.instance.DisconnectContext;
-import ch.threema.client.ThreemaConnection;
+import ch.threema.domain.protocol.csp.connection.ThreemaConnection;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.NonceDatabaseBlobService;
 

+ 8 - 0
app/src/main/java/ch/threema/app/asynctasks/DeleteMyGroupAsyncTask.java

@@ -28,6 +28,7 @@ import androidx.appcompat.app.AppCompatActivity;
 import ch.threema.app.R;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.listeners.ConversationListener;
+import ch.threema.app.listeners.GroupListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.utils.DialogUtil;
@@ -77,6 +78,13 @@ public class DeleteMyGroupAsyncTask extends AsyncTask<Void, Void, Void> {
 			}
 		});
 
+		ListenerManager.groupListeners.handle(new ListenerManager.HandleListener<GroupListener>() {
+			@Override
+			public void handle(GroupListener listener) {
+				listener.onRemove(groupModel);
+			}
+		});
+
 		if (runOnCompletion != null) {
 			runOnCompletion.run();
 		}

+ 56 - 12
app/src/main/java/ch/threema/app/asynctasks/EmptyChatAsyncTask.java

@@ -23,31 +23,48 @@ package ch.threema.app.asynctasks;
 
 import android.os.AsyncTask;
 
+import net.sqlcipher.database.SQLiteException;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+
 import androidx.fragment.app.FragmentManager;
 import ch.threema.app.R;
-import ch.threema.app.dialogs.GenericProgressDialog;
+import ch.threema.app.dialogs.CancelableHorizontalProgressDialog;
+import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.messagereceiver.MessageReceiver;
+import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.storage.models.AbstractMessageModel;
 
-public class EmptyChatAsyncTask extends AsyncTask<Void, Void, Integer> {
+public class EmptyChatAsyncTask extends AsyncTask<Void, Integer, Integer> {
+	private static final Logger logger = LoggerFactory.getLogger(EmptyChatAsyncTask.class);
+
 	private static final String DIALOG_TAG_EMPTYING_CHAT = "ec";
 
-	private final MessageReceiver[] messageReceivers;
+	private final MessageReceiver messageReceiver;
 	private final MessageService messageService;
+	private final ConversationService conversationService;
 	private final FragmentManager fragmentManager;
 	private final boolean quiet;
 	private final Runnable runOnCompletion;
+	private int progress;
+	private boolean isCancelled;
 
-	public EmptyChatAsyncTask(MessageReceiver[] messageReceivers,
+	public EmptyChatAsyncTask(MessageReceiver messageReceiver,
 	                          MessageService messageService,
+	                          ConversationService conversationService,
 	                          FragmentManager fragmentManager,
 	                          boolean quiet,
 	                          Runnable runOnCompletion) {
 
-		this.messageReceivers = messageReceivers;
+		this.messageReceiver = messageReceiver;
 		this.messageService = messageService;
+		this.conversationService = conversationService;
 		this.fragmentManager = fragmentManager;
 		this.quiet = quiet;
 		this.runOnCompletion = runOnCompletion;
@@ -56,20 +73,47 @@ public class EmptyChatAsyncTask extends AsyncTask<Void, Void, Integer> {
 	@Override
 	protected void onPreExecute() {
 		if (!quiet) {
-			GenericProgressDialog.newInstance(R.string.emptying_chat, R.string.please_wait).show(fragmentManager, DIALOG_TAG_EMPTYING_CHAT);
+			isCancelled = false;
+			CancelableHorizontalProgressDialog dialog = CancelableHorizontalProgressDialog.newInstance(R.string.emptying_chat, 0, R.string.cancel, 100);
+			dialog.setOnCancelListener((dialog1, which) -> isCancelled = true);
+			dialog.show(fragmentManager, DIALOG_TAG_EMPTYING_CHAT);
+		}
+	}
+
+	@Override
+	protected void onProgressUpdate(Integer... values) {
+		if (quiet) {
+			return;
+		}
+		if (values[0] > progress) {
+			DialogUtil.updateProgress(fragmentManager, DIALOG_TAG_EMPTYING_CHAT, values[0]);
+			progress = values[0];
 		}
 	}
 
 	@Override
 	protected Integer doInBackground(Void... voids) {
-		int i = 0;
-		for (MessageReceiver messageReceiver: messageReceivers) {
-			for (AbstractMessageModel m : messageService.getMessagesForReceiver(messageReceiver)) {
-				messageService.remove(m, true);
-				i++;
+		List<AbstractMessageModel> messageModelsToDelete = new ArrayList<>(messageService.getMessagesForReceiver(messageReceiver));
+		int i = 0, size = messageModelsToDelete.size();
+		if (size > 0) {
+			for (AbstractMessageModel abstractMessageModel : messageModelsToDelete) {
+				if (isCancelled) {
+					break;
+				}
+
+				publishProgress(i++ * 100 / size);
+				try {
+					messageService.remove(abstractMessageModel, true);
+				} catch (SQLiteException e) {
+					logger.error("Unable to remove message", e);
+				}
 			}
+
+			ListenerManager.messageListeners.handle(listener -> listener.onRemoved(messageModelsToDelete));
+
+			conversationService.refresh(messageReceiver);
 		}
-		return i;
+		return size;
 	}
 
 	@Override

+ 8 - 0
app/src/main/java/ch/threema/app/asynctasks/LeaveGroupAsyncTask.java

@@ -28,6 +28,7 @@ import androidx.appcompat.app.AppCompatActivity;
 import ch.threema.app.R;
 import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.listeners.ConversationListener;
+import ch.threema.app.listeners.GroupListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.utils.DialogUtil;
@@ -76,6 +77,13 @@ public class LeaveGroupAsyncTask extends AsyncTask<Void, Void, Void> {
 			}
 		});
 
+		ListenerManager.groupListeners.handle(new ListenerManager.HandleListener<GroupListener>() {
+			@Override
+			public void handle(GroupListener listener) {
+				listener.onLeave(groupModel);
+			}
+		});
+
 		if (runOnCompletion != null) {
 			runOnCompletion.run();
 		}

+ 17 - 5
app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java

@@ -85,8 +85,8 @@ import ch.threema.app.utils.StringConversionUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.ZipUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.client.IdentityBackupGenerator;
-import ch.threema.client.Utils;
+import ch.threema.base.utils.Utils;
+import ch.threema.domain.identitybackup.IdentityBackupGenerator;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
@@ -538,7 +538,9 @@ public class BackupService extends Service {
 			Tags.TAG_MESSAGE_IS_STATUS_MESSAGE,
 			Tags.TAG_MESSAGE_IS_QUEUED,
 			Tags.TAG_MESSAGE_CAPTION,
-			Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID
+			Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID,
+			Tags.TAG_MESSAGE_DELIVERED_AT,
+			Tags.TAG_MESSAGE_READ_AT
 		};
 
 		// Iterate over all contacts. Then backup every contact with the corresponding messages.
@@ -624,6 +626,8 @@ public class BackupService extends Service {
 										.write(Tags.TAG_MESSAGE_IS_QUEUED, messageModel.isQueued())
 										.write(Tags.TAG_MESSAGE_CAPTION, messageModel.getCaption())
 										.write(Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID, messageModel.getQuotedMessageId())
+										.write(Tags.TAG_MESSAGE_DELIVERED_AT, messageModel.getDeliveredAt())
+										.write(Tags.TAG_MESSAGE_READ_AT, messageModel.getReadAt())
 										.write();
 								}
 
@@ -689,7 +693,9 @@ public class BackupService extends Service {
 			Tags.TAG_MESSAGE_IS_STATUS_MESSAGE,
 			Tags.TAG_MESSAGE_IS_QUEUED,
 			Tags.TAG_MESSAGE_CAPTION,
-			Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID
+			Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID,
+			Tags.TAG_MESSAGE_DELIVERED_AT,
+			Tags.TAG_MESSAGE_READ_AT
 		};
 
 		final GroupService.GroupFilter groupFilter = new GroupService.GroupFilter() {
@@ -778,6 +784,8 @@ public class BackupService extends Service {
 									.write(Tags.TAG_MESSAGE_IS_QUEUED, groupMessageModel.isQueued())
 									.write(Tags.TAG_MESSAGE_CAPTION, groupMessageModel.getCaption())
 									.write(Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID, groupMessageModel.getQuotedMessageId())
+									.write(Tags.TAG_MESSAGE_DELIVERED_AT, groupMessageModel.getDeliveredAt())
+									.write(Tags.TAG_MESSAGE_READ_AT, groupMessageModel.getReadAt())
 									.write();
 
 								if (MessageUtil.hasDataFile(groupMessageModel)) {
@@ -1032,7 +1040,9 @@ public class BackupService extends Service {
 			Tags.TAG_MESSAGE_IS_STATUS_MESSAGE,
 			Tags.TAG_MESSAGE_IS_QUEUED,
 			Tags.TAG_MESSAGE_CAPTION,
-			Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID
+			Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID,
+			Tags.TAG_MESSAGE_DELIVERED_AT,
+			Tags.TAG_MESSAGE_READ_AT
 		};
 
 		try (final ByteArrayOutputStream distributionListBuffer = new ByteArrayOutputStream()) {
@@ -1080,6 +1090,8 @@ public class BackupService extends Service {
 										.write(Tags.TAG_MESSAGE_IS_QUEUED, distributionListMessageModel.isQueued())
 										.write(Tags.TAG_MESSAGE_CAPTION, distributionListMessageModel.getCaption())
 										.write(Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID, distributionListMessageModel.getQuotedMessageId())
+										.write(Tags.TAG_MESSAGE_DELIVERED_AT, distributionListMessageModel.getDeliveredAt())
+										.write(Tags.TAG_MESSAGE_READ_AT, distributionListMessageModel.getReadAt())
 										.write();
 								}
 

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

@@ -28,7 +28,7 @@ import ch.threema.app.services.GroupService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.services.ballot.BallotService;
-import ch.threema.client.ThreemaConnection;
+import ch.threema.domain.protocol.csp.connection.ThreemaConnection;
 import ch.threema.storage.DatabaseServiceNew;
 
 public class Helper {

+ 19 - 9
app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java

@@ -85,11 +85,11 @@ import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.StringConversionUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
-import ch.threema.base.VerificationLevel;
-import ch.threema.client.GroupId;
-import ch.threema.client.ProtocolDefines;
-import ch.threema.client.ThreemaConnection;
-import ch.threema.client.Utils;
+import ch.threema.base.utils.Utils;
+import ch.threema.domain.models.GroupId;
+import ch.threema.domain.models.VerificationLevel;
+import ch.threema.domain.protocol.csp.ProtocolDefines;
+import ch.threema.domain.protocol.csp.connection.ThreemaConnection;
 import ch.threema.storage.DatabaseServiceNew;
 import ch.threema.storage.factories.ContactModelFactory;
 import ch.threema.storage.models.AbstractMessageModel;
@@ -887,7 +887,7 @@ public class RestoreService extends Service {
 							if (groupService.isGroupOwner(groupModel)) {
 								groupService.sendSync(groupModel);
 							} else {
-								groupService.requestSync(groupModel.getCreatorIdentity(), new GroupId(Utils.hexStringToByteArray(groupModel.getApiGroupId())));
+								groupService.requestSync(groupModel.getCreatorIdentity(), new GroupId(Utils.hexStringToByteArray(groupModel.getApiGroupId().toString())));
 							}
 						}
 					}
@@ -1020,7 +1020,7 @@ public class RestoreService extends Service {
 
 	private GroupModel createGroupModel(CSVRow row, RestoreSettings restoreSettings) throws ThreemaException {
 		GroupModel groupModel = new GroupModel();
-		groupModel.setApiGroupId(row.getString(Tags.TAG_GROUP_ID));
+		groupModel.setApiGroupId(new GroupId(row.getString(Tags.TAG_GROUP_ID)));
 		groupModel.setCreatorIdentity(row.getString(Tags.TAG_GROUP_CREATOR));
 		groupModel.setName(row.getString(Tags.TAG_GROUP_NAME));
 		groupModel.setCreatedAt(row.getDate(Tags.TAG_GROUP_CREATED_AT));
@@ -1501,7 +1501,10 @@ public class RestoreService extends Service {
 		else {
 			messageModel.setIsQueued(true);
 		}
-
+		if (restoreSettings.getVersion() >= 16) {
+			messageModel.setDeliveredAt(row.getDate(Tags.TAG_MESSAGE_DELIVERED_AT));
+			messageModel.setReadAt(row.getDate(Tags.TAG_MESSAGE_READ_AT));
+		}
 		return messageModel;
 	}
 
@@ -1522,7 +1525,10 @@ public class RestoreService extends Service {
 			messageModel.setIsQueued(true);
 		}
 		messageModel.setUid(row.getString(Tags.TAG_MESSAGE_UID));
-
+		if (restoreSettings.getVersion() >= 16) {
+			messageModel.setDeliveredAt(row.getDate(Tags.TAG_MESSAGE_DELIVERED_AT));
+			messageModel.setReadAt(row.getDate(Tags.TAG_MESSAGE_READ_AT));
+		}
 		return messageModel;
 	}
 
@@ -1543,6 +1549,10 @@ public class RestoreService extends Service {
 			messageModel.setIsQueued(true);
 		}
 		messageModel.setUid(row.getString(Tags.TAG_MESSAGE_UID));
+		if (restoreSettings.getVersion() >= 16) {
+			messageModel.setDeliveredAt(row.getDate(Tags.TAG_MESSAGE_DELIVERED_AT));
+			messageModel.setReadAt(row.getDate(Tags.TAG_MESSAGE_READ_AT));
+		}
 		return messageModel;
 	}
 

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

@@ -35,8 +35,9 @@ public class RestoreSettings {
 	 * 12: voip status messages (not implemented)
 	 * 13: add hidden flag to contacts
 	 * 15: add quoted message id to messages
+	 * 16: added read and delivered date
 	 */
-	public static final int CURRENT_VERSION = 15;
+	public static final int CURRENT_VERSION = 16;
 	private int version = 1;
 
 	public RestoreSettings(int version) {

+ 3 - 0
app/src/main/java/ch/threema/app/backuprestore/csv/Tags.java

@@ -80,6 +80,9 @@ public abstract class Tags {
 	public static final String TAG_MESSAGE_IS_SAVED = "issaved";
 	public static final String TAG_MESSAGE_CREATED_AT = "created_at";
 	public static final String TAG_MESSAGE_MODIFIED_AT = "modified_at";
+	public static final String TAG_MESSAGE_DELIVERED_AT = "delivered_at";
+	public static final String TAG_MESSAGE_READ_AT = "read_at";
+
 	public static final String TAG_MESSAGE_MESSAGE_STATE = "messagestae";
 	public static final String TAG_MESSAGE_IS_STATUS_MESSAGE = "isstatusmessage";
 	public static final String TAG_MESSAGE_IS_QUEUED = "isqueued";

+ 8 - 15
app/src/main/java/ch/threema/app/camera/CameraActivity.java

@@ -44,7 +44,6 @@ import java.io.IOException;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
-import androidx.annotation.RequiresApi;
 import androidx.camera.lifecycle.ProcessCameraProvider;
 import androidx.core.content.ContextCompat;
 import androidx.fragment.app.Fragment;
@@ -58,8 +57,6 @@ import ch.threema.app.utils.ConfigUtils;
 import static android.view.KeyEvent.KEYCODE_VOLUME_DOWN;
 import static android.view.KeyEvent.KEYCODE_VOLUME_UP;
 
-
-@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
 public class CameraActivity extends ThreemaAppCompatActivity implements CameraFragment.CameraCallback, CameraFragment.CameraConfiguration {
 	private static final Logger logger = LoggerFactory.getLogger(CameraActivity.class);
 
@@ -80,18 +77,14 @@ public class CameraActivity extends ThreemaAppCompatActivity implements CameraFr
 
 		setContentView(R.layout.camerax_activity_camera);
 
-		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
-			getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
-			getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
-			getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
-			getWindow().setStatusBarColor(Color.TRANSPARENT);
-			if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
-				// we want dark icons, i.e. a light status bar
-				getWindow().getDecorView().setSystemUiVisibility(
-						getWindow().getDecorView().getSystemUiVisibility() | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
-			}
-		} else {
-			getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
+		getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
+		getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
+		getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
+		getWindow().setStatusBarColor(Color.TRANSPARENT);
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+			// we want dark icons, i.e. a light status bar
+			getWindow().getDecorView().setSystemUiVisibility(
+					getWindow().getDecorView().getSystemUiVisibility() | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
 		}
 
 		if (getIntent() != null) {

+ 0 - 3
app/src/main/java/ch/threema/app/camera/CameraFragment.java

@@ -31,7 +31,6 @@ import android.content.pm.ActivityInfo;
 import android.content.pm.PackageManager;
 import android.hardware.display.DisplayManager;
 import android.os.AsyncTask;
-import android.os.Build;
 import android.os.Bundle;
 import android.text.format.DateUtils;
 import android.view.KeyEvent;
@@ -56,7 +55,6 @@ import java.util.concurrent.Executors;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
-import androidx.annotation.RequiresApi;
 import androidx.camera.core.Camera;
 import androidx.camera.core.CameraSelector;
 import androidx.camera.core.ImageCapture;
@@ -86,7 +84,6 @@ import static ch.threema.app.camera.CameraActivity.KEY_EVENT_ACTION;
 import static ch.threema.app.camera.CameraActivity.KEY_EVENT_EXTRA;
 
 @SuppressWarnings("deprecation")
-@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
 public class CameraFragment extends Fragment {
 	private static final Logger logger = LoggerFactory.getLogger(CameraFragment.class);
 	private static final int PERMISSION_REQUEST_CODE_AUDIO = 869;

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

@@ -35,7 +35,6 @@ import java.nio.ByteBuffer;
 import java.util.HashSet;
 
 import androidx.annotation.NonNull;
-import androidx.annotation.RequiresApi;
 import androidx.camera.core.ImageCapture;
 import androidx.camera.core.ImageProxy;
 
@@ -54,7 +53,6 @@ public class CameraUtil {
 	private static final HashSet<String> BLACKLISTED_CAMERAS = new HashSet<String>() {{
 	}};
 
-	@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
 	private static byte[] transformByteArray(@NonNull byte[] data, Rect cropRect, int rotation, boolean flip) throws IOException {
 		Bitmap in = null;
 
@@ -95,7 +93,6 @@ public class CameraUtil {
 		return null;
 	}
 
-	@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
 	static byte[] getJpegBytes(@NonNull ImageProxy image, int rotation, boolean flip) throws IOException {
 		ImageProxy.PlaneProxy[] planes = image.getPlanes();
 		ByteBuffer buffer = planes[0].getBuffer();
@@ -121,7 +118,6 @@ public class CameraUtil {
 		return out.toByteArray();
 	}
 
-	@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
 	private static boolean shouldCropImage(@NonNull ImageProxy image) {
 		Size sourceSize = new Size(image.getWidth(), image.getHeight());
 		Size targetSize = new Size(image.getCropRect().width(), image.getCropRect().height());
@@ -138,6 +134,6 @@ public class CameraUtil {
 	 * @return
 	 */
 	public static boolean isInternalCameraSupported() {
-		return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && !BLACKLISTED_CAMERAS.contains(Build.MODEL);
+		return !BLACKLISTED_CAMERAS.contains(Build.MODEL);
 	}
 }

+ 1 - 3
app/src/main/java/ch/threema/app/camera/VideoEditView.java

@@ -61,7 +61,6 @@ import java.io.File;
 
 import androidx.annotation.MainThread;
 import androidx.annotation.NonNull;
-import androidx.annotation.RequiresApi;
 import androidx.annotation.UiThread;
 import androidx.lifecycle.DefaultLifecycleObserver;
 import androidx.lifecycle.LifecycleOwner;
@@ -143,7 +142,7 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 		this.dimPaint = new Paint();
 
 		this.dimPaint.setStyle(Paint.Style.FILL);
-		this.dimPaint.setColor(context.getResources().getColor(R.color.background_dim_dark));
+		this.dimPaint.setColor(context.getResources().getColor(R.color.dark_background_dim));
 		this.dimPaint.setAntiAlias(false);
 		this.dimPaint.setStrokeWidth(0);
 
@@ -358,7 +357,6 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 	}
 
 	@SuppressLint("StaticFieldLeak")
-	@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
 	@UiThread
 	public void setVideo(MediaItem mediaItem) {
 		int numColumns = calculateNumColumns();

+ 1 - 4
app/src/main/java/ch/threema/app/camera/ZoomView.java

@@ -25,16 +25,13 @@ import android.content.Context;
 import android.graphics.Canvas;
 import android.graphics.Color;
 import android.graphics.Paint;
-import android.os.Build;
 import android.util.AttributeSet;
 import android.widget.FrameLayout;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
-import androidx.annotation.RequiresApi;
 import ch.threema.app.R;
 
-@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
 public class ZoomView extends FrameLayout {
 
 	private Paint linePaint, circlePaint, semiPaint, labelPaint;
@@ -71,7 +68,7 @@ public class ZoomView extends FrameLayout {
 
 		this.semiPaint = new Paint();
 		this.semiPaint.setStyle(Paint.Style.STROKE);
-		this.semiPaint.setColor(getResources().getColor(R.color.background_dim_light));
+		this.semiPaint.setColor(getResources().getColor(R.color.background_dim));
 		this.semiPaint.setAntiAlias(true);
 		this.semiPaint.setStrokeWidth(this.strokeWidth);
 

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

@@ -52,7 +52,7 @@ import ch.threema.app.ui.AvatarEditView;
 import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.ViewUtil;
-import ch.threema.base.Contact;
+import ch.threema.domain.models.Contact;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
 
@@ -239,7 +239,7 @@ public class ContactEditDialog extends ThreemaDialogFragment implements AvatarEd
 		try {
 			contactService = ThreemaApplication.getServiceManager().getContactService();
 			groupService = ThreemaApplication.getServiceManager().getGroupService();
-		} catch (MasterKeyLockedException | FileSystemNotPresentException | NoIdentityException e) {
+		} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
 			logger.error("Exception", e);
 		}
 

+ 29 - 12
app/src/main/java/ch/threema/app/dialogs/MessageDetailDialog.java

@@ -90,8 +90,10 @@ public class MessageDetailDialog extends ThreemaDialogFragment {
 			final TextView createdDate = dialogView.findViewById(R.id.created_date);
 			final TextView postedText = dialogView.findViewById(R.id.posted_text);
 			final TextView postedDate = dialogView.findViewById(R.id.posted_date);
-			final TextView modifiedText = dialogView.findViewById(R.id.modified_text);
-			final TextView modifiedDate = dialogView.findViewById(R.id.modified_date);
+			final TextView deliveredText = dialogView.findViewById(R.id.delivered_text);
+			final TextView deliveredDate = dialogView.findViewById(R.id.delivered_date);
+			final TextView readText = dialogView.findViewById(R.id.read_text);
+			final TextView readDate = dialogView.findViewById(R.id.read_date);
 			final TextView messageIdText = dialogView.findViewById(R.id.messageid_text);
 			final TextView messageIdDate = dialogView.findViewById(R.id.messageid_date);
 			final TextView mimeTypeText = dialogView.findViewById(R.id.filetype_text);
@@ -136,13 +138,28 @@ public class MessageDetailDialog extends ThreemaDialogFragment {
 					}
 
 					if (messageState != MessageState.SENT && !(messageModel.getType() == MessageType.BALLOT && messageModel instanceof GroupMessageModel)) {
+						Date deliveredAt = messageModel.getDeliveredAt();
+						Date readAt = messageModel.getReadAt();
 						Date modifiedAt = messageModel.getModifiedAt();
-						modifiedText.setText(TextUtil.capitalize(getString(stateResource)));
-						modifiedDate.setText(modifiedAt != null ?
-							LocaleUtil.formatTimeStampStringAbsolute(getContext(), messageModel.getModifiedAt().getTime()) :
-							(messageModel.getPostedAt(true) != null ? LocaleUtil.formatTimeStampStringAbsolute(getContext(), messageModel.getPostedAt(true).getTime()) : ""));
-						modifiedText.setVisibility(View.VISIBLE);
-						modifiedDate.setVisibility(View.VISIBLE);
+
+						if (readAt != null && deliveredAt != null) {
+							deliveredText.setText(TextUtil.capitalize(getString(R.string.state_delivered)));
+							deliveredDate.setText(LocaleUtil.formatTimeStampStringAbsolute(getContext(), deliveredAt.getTime()));
+							deliveredText.setVisibility(View.VISIBLE);
+							deliveredDate.setVisibility(View.VISIBLE);
+
+							readText.setText(TextUtil.capitalize(getString(R.string.state_read)));
+							readDate.setText(LocaleUtil.formatTimeStampStringAbsolute(getContext(), readAt.getTime()));
+							readText.setVisibility(View.VISIBLE);
+							readDate.setVisibility(View.VISIBLE);
+						} else {
+							deliveredText.setText(TextUtil.capitalize(getString(stateResource)));
+							deliveredDate.setText(modifiedAt != null ?
+								LocaleUtil.formatTimeStampStringAbsolute(getContext(), messageModel.getModifiedAt().getTime()) :
+								(messageModel.getPostedAt(true) != null ? LocaleUtil.formatTimeStampStringAbsolute(getContext(), messageModel.getPostedAt(true).getTime()) : ""));
+							deliveredText.setVisibility(View.VISIBLE);
+							deliveredDate.setVisibility(View.VISIBLE);
+						}
 					}
 				} else {
 					// incoming msgs
@@ -160,10 +177,10 @@ public class MessageDetailDialog extends ThreemaDialogFragment {
 						postedDate.setVisibility(View.VISIBLE);
 					}
 					if (messageModel.getModifiedAt() != null && messageState != MessageState.READ) {
-						modifiedText.setText(TextUtil.capitalize(getString(R.string.state_read)));
-						modifiedDate.setText(LocaleUtil.formatTimeStampStringAbsolute(getContext(), messageModel.getModifiedAt().getTime()));
-						modifiedText.setVisibility(View.VISIBLE);
-						modifiedDate.setVisibility(View.VISIBLE);
+						deliveredText.setText(TextUtil.capitalize(getString(R.string.state_read)));
+						deliveredDate.setText(LocaleUtil.formatTimeStampStringAbsolute(getContext(), messageModel.getModifiedAt().getTime()));
+						deliveredText.setVisibility(View.VISIBLE);
+						deliveredDate.setVisibility(View.VISIBLE);
 					}
 				}
 

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

@@ -38,7 +38,7 @@ import androidx.appcompat.app.AlertDialog;
 import androidx.appcompat.app.AppCompatDialog;
 import ch.threema.app.R;
 import ch.threema.app.emojis.EmojiEditText;
-import ch.threema.client.ProtocolDefines;
+import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class NewContactDialog extends ThreemaDialogFragment {
 	private NewContactDialogClickListener callback;

+ 32 - 4
app/src/main/java/ch/threema/app/dialogs/PasswordEntryDialog.java

@@ -26,6 +26,7 @@ import android.content.DialogInterface;
 import android.content.res.ColorStateList;
 import android.os.Bundle;
 import android.text.Editable;
+import android.text.Html;
 import android.text.InputFilter;
 import android.text.InputType;
 import android.text.SpannableString;
@@ -48,6 +49,7 @@ import androidx.appcompat.app.AppCompatDialog;
 import androidx.core.text.util.LinkifyCompat;
 import ch.threema.app.R;
 import ch.threema.app.utils.DialogUtil;
+import ch.threema.app.utils.LocaleUtil;
 
 public class PasswordEntryDialog extends ThreemaDialogFragment implements GenericAlertDialog.DialogClickListener {
 	private static final String DIALOG_TAG_CONFIRM_CHECKBOX = "dtcc";
@@ -59,12 +61,18 @@ public class PasswordEntryDialog extends ThreemaDialogFragment implements Generi
 	protected boolean isLengthCheck = true;
 	protected int minLength, maxLength;
 	protected MaterialCheckBox checkBox;
+	public enum ForgotHintType {
+		NONE,
+		SAFE,
+		PIN_PASSPHRASE
+	}
 
 	public static PasswordEntryDialog newInstance(@StringRes int title, @StringRes int message,
 	                                              @StringRes int hint,
 	                                              @StringRes int positive, @StringRes int negative,
 	                                              int minLength, int maxLength,
-	                                              int confirmHint, int inputType, int checkboxText) {
+	                                              int confirmHint, int inputType, int checkboxText,
+	                                              ForgotHintType showForgotPwHint ) {
 		PasswordEntryDialog dialog = new PasswordEntryDialog();
 		Bundle args = new Bundle();
 		args.putInt("title", title);
@@ -77,6 +85,7 @@ public class PasswordEntryDialog extends ThreemaDialogFragment implements Generi
 		args.putInt("confirmHint", confirmHint);
 		args.putInt("inputType", inputType);
 		args.putInt("checkboxText", checkboxText);
+		args.putSerializable("showForgotPwHint", showForgotPwHint);
 
 		dialog.setArguments(args);
 		return dialog;
@@ -106,8 +115,7 @@ public class PasswordEntryDialog extends ThreemaDialogFragment implements Generi
 	}
 
 	@Override
-	public void onYes(String tag, Object data) {
-	}
+	public void onYes(String tag, Object data) { }
 
 	@Override
 	public void onNo(String tag, Object data) {
@@ -152,7 +160,6 @@ public class PasswordEntryDialog extends ThreemaDialogFragment implements Generi
 			return alertDialog;
 		}
 
-
 		final int title = getArguments().getInt("title");
 		int message = getArguments().getInt("message");
 		int hint = getArguments().getInt("hint");
@@ -164,6 +171,7 @@ public class PasswordEntryDialog extends ThreemaDialogFragment implements Generi
 		final int confirmHint = getArguments().getInt("confirmHint", 0);
 		final int checkboxText = getArguments().getInt("checkboxText", 0);
 		final int checkboxConfirmText = getArguments().getInt("checkboxConfirmText", 0);
+		final ForgotHintType showForgotPwHint = (ForgotHintType) getArguments().getSerializable("showForgotPwHint");
 
 		final String tag = this.getTag();
 
@@ -172,6 +180,7 @@ public class PasswordEntryDialog extends ThreemaDialogFragment implements Generi
 
 		final View dialogView = activity.getLayoutInflater().inflate(R.layout.dialog_password_entry, null);
 		final TextView messageTextView = dialogView.findViewById(R.id.message_text);
+		final TextView forgotPwTextView = dialogView.findViewById(R.id.forgot_password);
 		final TextInputEditText editText1 = dialogView.findViewById(R.id.password1);
 		final TextInputEditText editText2 = dialogView.findViewById(R.id.password2);
 		final TextInputLayout editText1Layout = dialogView.findViewById(R.id.password1layout);
@@ -243,6 +252,25 @@ public class PasswordEntryDialog extends ThreemaDialogFragment implements Generi
 			editText1Layout.setHelperText(String.format(activity.getString(R.string.password_too_short), minLength));
 		}
 
+		if (showForgotPwHint != null) {
+			switch (showForgotPwHint) {
+				case SAFE:
+					String safeFaqUrl = String.format(getString(R.string.threema_safe_password_faq), LocaleUtil.getAppLanguage());
+					forgotPwTextView.setText(Html.fromHtml(String.format(getString(R.string.forgot_your_password), safeFaqUrl)));
+					forgotPwTextView.setMovementMethod(LinkMovementMethod.getInstance());
+					forgotPwTextView.setVisibility(View.VISIBLE);
+					break;
+				case PIN_PASSPHRASE:
+					String pinFaqUrl = String.format(getString(R.string.threema_passwords_faq), LocaleUtil.getAppLanguage());
+					forgotPwTextView.setText(Html.fromHtml(String.format(getString(R.string.forgot_your_password), pinFaqUrl)));
+					forgotPwTextView.setMovementMethod(LinkMovementMethod.getInstance());
+					forgotPwTextView.setVisibility(View.VISIBLE);
+					break;
+				case NONE:
+					break;
+			}
+		}
+
 		MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity(), getTheme());
 
 		if (title != 0) {

+ 109 - 0
app/src/main/java/ch/threema/app/dialogs/PublicKeyDialog.java

@@ -0,0 +1,109 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2014-2021 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.dialogs;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.os.Bundle;
+import android.view.ContextMenu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatDialog;
+import ch.threema.app.R;
+import ch.threema.app.ThreemaApplication;
+import ch.threema.app.managers.ServiceManager;
+import ch.threema.base.utils.Utils;
+
+import static android.content.Context.CLIPBOARD_SERVICE;
+
+public class PublicKeyDialog extends SimpleStringAlertDialog {
+	private String publicKeyString;
+	private CharSequence title;
+
+	public static PublicKeyDialog newInstance(CharSequence title, byte[] publicKey) {
+		PublicKeyDialog dialog = new PublicKeyDialog();
+		Bundle args = new Bundle();
+		args.putCharSequence("title", title);
+		args.putByteArray("publicKey", publicKey);
+
+		dialog.setArguments(args);
+		return dialog;
+	}
+
+	@Override
+	public void onCreateContextMenu(@NonNull ContextMenu menu, @NonNull View v, @Nullable ContextMenu.ContextMenuInfo menuInfo) {
+		MenuItem menuItem = menu.add(0, v.getId(), 0, getContext().getString(R.string.copy));
+		menuItem.setOnMenuItemClickListener(item -> {
+			ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(CLIPBOARD_SERVICE);
+
+			ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+			if (serviceManager != null) {
+				ClipData clip = ClipData.newPlainText(title, publicKeyString);
+				clipboard.setPrimaryClip(clip);
+			}
+
+			Toast.makeText(getContext(), R.string.copied, Toast.LENGTH_SHORT).show();
+
+			return true;
+		});
+	}
+
+	@NonNull
+	@Override
+	public AppCompatDialog onCreateDialog(Bundle savedInstanceState) {
+		title = getArguments().getCharSequence("title");
+		byte[] publicKey = getArguments().getByteArray("publicKey");
+		publicKeyString = Utils.byteArrayToHexString(publicKey);
+
+		final View dialogView = activity.getLayoutInflater().inflate(R.layout.dialog_public_key, null);
+		final TextView messageView = dialogView.findViewById(R.id.message);
+
+		registerForContextMenu(messageView);
+
+		StringBuilder message = new StringBuilder();
+
+		for(int i = 0; i < publicKeyString.length(); i++) {
+			if (i != 0 && i % 8 == 0) {
+				message.append("\n");
+			}
+			message.append(publicKeyString.charAt(i));
+		}
+
+		MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity(), getTheme())
+			.setCancelable(false)
+			.setView(dialogView)
+			.setTitle(title)
+			.setPositiveButton(getString(R.string.ok), null)
+			.setCancelable(false);
+
+		messageView.setText(message);
+
+		return builder.create();
+	}
+}

+ 4 - 2
app/src/main/java/ch/threema/app/dialogs/SMSVerificationDialog.java

@@ -32,6 +32,7 @@ import android.widget.EditText;
 
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 
+import androidx.annotation.NonNull;
 import androidx.appcompat.app.AlertDialog;
 import androidx.appcompat.app.AppCompatDialog;
 import ch.threema.app.R;
@@ -80,12 +81,13 @@ public class SMSVerificationDialog extends ThreemaDialogFragment {
 	}
 
 	@Override
-	public void onAttach(Activity activity) {
+	public void onAttach(@NonNull Activity activity) {
 		super.onAttach(activity);
 
 		this.activity = activity;
 	}
 
+	@NonNull
 	@Override
 	public AppCompatDialog onCreateDialog(Bundle savedInstanceState) {
 		String phone = getArguments().getString(ARG_PHONE_NUMBER);
@@ -126,7 +128,7 @@ public class SMSVerificationDialog extends ThreemaDialogFragment {
 	}
 
 	@Override
-	public void onCancel(DialogInterface dialogInterface) {
+	public void onCancel(@NonNull DialogInterface dialogInterface) {
 		callback.onNo(tag);
 	}
 

+ 73 - 56
app/src/main/java/ch/threema/app/dialogs/SelectorDialog.java

@@ -25,6 +25,11 @@ import android.app.Activity;
 import android.content.DialogInterface;
 import android.os.Bundle;
 import android.os.Parcelable;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.ListAdapter;
+import android.widget.TextView;
 
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 
@@ -33,6 +38,8 @@ import java.util.ArrayList;
 import androidx.annotation.NonNull;
 import androidx.appcompat.app.AlertDialog;
 import androidx.appcompat.app.AppCompatDialog;
+import ch.threema.app.R;
+import ch.threema.app.ui.SelectorDialogItem;
 
 public class SelectorDialog extends ThreemaDialogFragment {
 	private SelectorDialogClickListener callback;
@@ -40,51 +47,42 @@ public class SelectorDialog extends ThreemaDialogFragment {
 	private Activity activity;
 	private AlertDialog alertDialog;
 
-	public static SelectorDialog newInstance(String title, ArrayList<String> items, String negative, SelectorDialogInlineClickListener listener) {
-		// do not use inline callbacks in activities that don't have android:configChanges="orientation|screenSize|keyboardHidden" set
-		// or fragments without setRetainInstance(true)
-		SelectorDialog dialog = new SelectorDialog();
-		Bundle args = new Bundle();
-		args.putString("title", title);
-		args.putStringArrayList("items", items);
-		args.putString("negative", negative);
-		args.putParcelable("listener", listener);
-
-		dialog.setArguments(args);
-		return dialog;
-	}
+	private static final String BUNDLE_TITLE_EXTRA = "title";
+	private static final String BUNDLE_ITEMS_EXTRA = "items";
+	private static final String BUNDLE_VALUES_EXTRA = "values";
+	private static final String BUNDLE_NEGATIVE_EXTRA = "negative";
+	private static final String BUNDLE_LISTENER_EXTRA = "listener";
 
-	public static SelectorDialog newInstance(String title, ArrayList<String> items, String negative) {
+	public static SelectorDialog newInstance(String title, ArrayList<SelectorDialogItem> items, String negative) {
 		SelectorDialog dialog = new SelectorDialog();
 		Bundle args = new Bundle();
-		args.putString("title", title);
-		args.putStringArrayList("items", items);
-		args.putString("negative", negative);
+		args.putString(BUNDLE_TITLE_EXTRA, title);
+		args.putSerializable(BUNDLE_ITEMS_EXTRA, items);
+		args.putString(BUNDLE_NEGATIVE_EXTRA, negative);
 
 		dialog.setArguments(args);
 		return dialog;
 	}
 
-	public static SelectorDialog newInstance(String title, ArrayList<String> items, ArrayList<Integer> values, String negative) {
+	public static SelectorDialog newInstance(String title, ArrayList<SelectorDialogItem> items, ArrayList<Integer> values, String negative) {
 		SelectorDialog dialog = new SelectorDialog();
 		Bundle args = new Bundle();
-		args.putString("title", title);
-		args.putIntegerArrayList("values", values);
-		args.putStringArrayList("items", items);
-		args.putString("negative", negative);
+		args.putString(BUNDLE_TITLE_EXTRA, title);
+		args.putIntegerArrayList(BUNDLE_VALUES_EXTRA, values);
+		args.putSerializable(BUNDLE_ITEMS_EXTRA, items);
+		args.putString(BUNDLE_NEGATIVE_EXTRA, negative);
 
 		dialog.setArguments(args);
 		return dialog;
 	}
 
-	public static SelectorDialog newInstance(String title, ArrayList<String> items, ArrayList<Integer> values, String negative, SelectorDialogInlineClickListener listener) {
+	public static SelectorDialog newInstance(String title, ArrayList<SelectorDialogItem> items, String negative, SelectorDialogInlineClickListener listener) {
 		SelectorDialog dialog = new SelectorDialog();
 		Bundle args = new Bundle();
-		args.putString("title", title);
-		args.putIntegerArrayList("values", values);
-		args.putStringArrayList("items", items);
-		args.putString("negative", negative);
-		args.putParcelable("listener", listener);
+		args.putString(BUNDLE_TITLE_EXTRA, title);
+		args.putSerializable(BUNDLE_ITEMS_EXTRA, items);
+		args.putString(BUNDLE_NEGATIVE_EXTRA, negative);
+		args.putParcelable(BUNDLE_LISTENER_EXTRA, listener);
 
 		dialog.setArguments(args);
 		return dialog;
@@ -123,11 +121,12 @@ public class SelectorDialog extends ThreemaDialogFragment {
 	@NonNull
 	@Override
 	public AppCompatDialog onCreateDialog(Bundle savedInstanceState) {
-		String title = getArguments().getString("title");
-		final ArrayList<String> items = getArguments().getStringArrayList("items");
-		final ArrayList<Integer> values = getArguments().getIntegerArrayList("values");
-		String negative = getArguments().getString("negative");
-		SelectorDialogInlineClickListener listener = getArguments().getParcelable("listener");
+		Bundle arguments = getArguments();
+		String title = arguments.getString("title");
+		final ArrayList<SelectorDialogItem> items = (ArrayList<SelectorDialogItem>) arguments.getSerializable("items");
+		final ArrayList<Integer> values = arguments.getIntegerArrayList("values");
+		String negative = arguments.getString("negative");
+		SelectorDialogInlineClickListener listener = arguments.getParcelable("listener");
 
 		if (listener != null) {
 			inlineCallback = listener;
@@ -139,36 +138,54 @@ public class SelectorDialog extends ThreemaDialogFragment {
 		if (title != null) {
 			builder.setTitle(title);
 		}
-		builder.setItems(items.toArray(new String[0]), new DialogInterface.OnClickListener() {
+
+
+		ListAdapter adapter = new ArrayAdapter<SelectorDialogItem> (
+			activity,
+			R.layout.item_selector_dialog,
+			R.id.text1,
+			items){
 			@Override
-			public void onClick(DialogInterface dialog, int which) {
-				dialog.dismiss();
+			public View getView(int position, View convertView, ViewGroup parent) {
+				//Use super class to create the View
+				View v = super.getView(position, convertView, parent);
+				TextView selectorOptionDesc = v.findViewById(R.id.text1);
+
+				//Put the image on the TextView
+				selectorOptionDesc.setCompoundDrawablesWithIntrinsicBounds(items.get(position).getIcon(), 0, 0, 0);
 
-				if (values != null && values.size() > 0) {
-					if (inlineCallback != null) {
-						inlineCallback.onClick(tag, values.get(which), object);
-					} else {
-						callback.onClick(tag, values.get(which), object);
-					}
+				//Add margin between image and text (support various screen densities)
+				selectorOptionDesc.setCompoundDrawablePadding(getResources().getDimensionPixelSize(R.dimen.listitem_standard_margin_left_right));
+
+				return v;
+			}
+		};
+
+		builder.setAdapter(adapter, (dialog, which) -> {
+			dialog.dismiss();
+
+			if (values != null && values.size() > 0) {
+				if (inlineCallback != null) {
+					inlineCallback.onClick(tag, values.get(which), object);
 				} else {
-					if (inlineCallback != null) {
-						inlineCallback.onClick(tag, which, object);
-					} else {
-						callback.onClick(tag, which, object);
-					}
+					callback.onClick(tag, values.get(which), object);
+				}
+			} else {
+				if (inlineCallback != null) {
+					inlineCallback.onClick(tag, which, object);
+				} else {
+					callback.onClick(tag, which, object);
 				}
 			}
 		});
+
 		if (negative != null) {
-			builder.setNegativeButton(negative, new DialogInterface.OnClickListener() {
-				@Override
-				public void onClick(DialogInterface dialog, int which) {
-					dialog.dismiss();
-					if (inlineCallback != null) {
-						inlineCallback.onNo(tag);
-					} else {
-						callback.onNo(tag);
-					}
+			builder.setNegativeButton(negative, (dialog, which) -> {
+				dialog.dismiss();
+				if (inlineCallback != null) {
+					inlineCallback.onNo(tag);
+				} else {
+					callback.onNo(tag);
 				}
 			});
 		}

+ 8 - 2
app/src/main/java/ch/threema/app/dialogs/ShowOnceDialog.java

@@ -24,7 +24,6 @@ package ch.threema.app.dialogs;
 import android.app.Activity;
 import android.content.SharedPreferences;
 import android.os.Bundle;
-import android.preference.PreferenceManager;
 import android.view.View;
 import android.widget.TextView;
 
@@ -36,6 +35,7 @@ import androidx.appcompat.app.AppCompatDialog;
 import androidx.appcompat.widget.AppCompatCheckBox;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentTransaction;
+import androidx.preference.PreferenceManager;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 
@@ -47,7 +47,7 @@ import ch.threema.app.ThreemaApplication;
 public class ShowOnceDialog extends ThreemaDialogFragment {
 	private AlertDialog alertDialog;
 	private Activity activity;
-	private static String PREF_PREFIX = "dialog_";
+	public static final String PREF_PREFIX = "dialog_";
 
 	public static ShowOnceDialog newInstance(@StringRes int title, @StringRes int message) {
 		ShowOnceDialog dialog = new ShowOnceDialog();
@@ -78,6 +78,12 @@ public class ShowOnceDialog extends ThreemaDialogFragment {
 		}
 	}
 
+	// generally allow state loss for simple string alerts
+	public static boolean shouldNotShowAnymore(String tag) {
+		final SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(ThreemaApplication.getAppContext());
+		return sharedPreferences.getBoolean(PREF_PREFIX + tag, false);
+	}
+
 	@Override
 	public AppCompatDialog onCreateDialog(Bundle savedInstanceState) {
 		final SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(ThreemaApplication.getAppContext());

+ 2 - 5
app/src/main/java/ch/threema/app/dialogs/SimpleStringAlertDialog.java

@@ -27,7 +27,6 @@ import android.os.Bundle;
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 
 import androidx.annotation.NonNull;
-import androidx.appcompat.app.AlertDialog;
 import androidx.appcompat.app.AppCompatDialog;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentTransaction;
@@ -35,8 +34,7 @@ import ch.threema.app.R;
 import ch.threema.app.utils.TestUtil;
 
 public class SimpleStringAlertDialog extends ThreemaDialogFragment {
-	private AlertDialog alertDialog;
-	private Activity activity;
+	protected Activity activity;
 
 	public static SimpleStringAlertDialog newInstance(int title, CharSequence message) {
 		SimpleStringAlertDialog dialog = new SimpleStringAlertDialog();
@@ -111,7 +109,6 @@ public class SimpleStringAlertDialog extends ThreemaDialogFragment {
 			builder.setMessage(message);
 		}
 
-		alertDialog = builder.create();
-		return alertDialog;
+		return builder.create();
 	}
 }

+ 2 - 7
app/src/main/java/ch/threema/app/dialogs/TextEntryDialog.java

@@ -24,7 +24,6 @@ package ch.threema.app.dialogs;
 import android.app.Activity;
 import android.content.DialogInterface;
 import android.content.res.ColorStateList;
-import android.os.Build;
 import android.os.Bundle;
 import android.telephony.PhoneNumberFormattingTextWatcher;
 import android.text.Editable;
@@ -45,7 +44,7 @@ import ch.threema.app.emojis.EmojiEditText;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.LocaleService;
 import ch.threema.app.utils.DialogUtil;
-import ch.threema.client.ProtocolDefines;
+import ch.threema.domain.protocol.csp.ProtocolDefines;
 
 public class TextEntryDialog extends ThreemaDialogFragment {
 	private TextEntryDialogClickListener callback;
@@ -260,11 +259,7 @@ public class TextEntryDialog extends ThreemaDialogFragment {
 		if (inputFilterType == INPUT_FILTER_TYPE_IDENTITY) {
 			editText.setFilters(new InputFilter[]{new InputFilter.AllCaps(), new InputFilter.LengthFilter(ProtocolDefines.IDENTITY_LEN)});
 		} else if (inputFilterType == INPUT_FILTER_TYPE_PHONE && localeService != null) {
-			if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
-				editText.addTextChangedListener(new PhoneNumberFormattingTextWatcher(localeService.getCountryIsoCode()));
-			} else {
-				editText.addTextChangedListener(new PhoneNumberFormattingTextWatcher());
-			}
+			editText.addTextChangedListener(new PhoneNumberFormattingTextWatcher(localeService.getCountryIsoCode()));
 			editText.addTextChangedListener(new TextWatcher() {
 				@Override
 				public void beforeTextChanged(CharSequence s, int start, int count, int after) {}

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

@@ -31,6 +31,7 @@ import android.widget.TextView;
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 
 import androidx.appcompat.app.AppCompatDialog;
+import androidx.core.content.res.ResourcesCompat;
 import ch.threema.app.R;
 
 public class WizardDialog extends ThreemaDialogFragment {
@@ -38,16 +39,24 @@ public class WizardDialog extends ThreemaDialogFragment {
 	private static final String ARG_TITLE_STRING = "titleString";
 	private static final String ARG_POSITIVE = "positive";
 	private static final String ARG_NEGATIVE = "negative";
+	private static final String ARG_HIGHLIGHT = "highlight";
 
 	private WizardDialogCallback callback;
 	private Activity activity;
+	public enum Highlight {
+		POSITIVE,
+		NEGATIVE,
+		EQUAL,
+		NONE
+	}
 
-	public static WizardDialog newInstance(int title, int positive, int negative) {
+	public static WizardDialog newInstance(int title, int positive, int negative, Highlight highlight) {
 		WizardDialog dialog = new WizardDialog();
 		Bundle args = new Bundle();
 		args.putInt(ARG_TITLE, title);
 		args.putInt(ARG_POSITIVE, positive);
 		args.putInt(ARG_NEGATIVE, negative);
+		args.putSerializable(ARG_HIGHLIGHT, highlight);
 		dialog.setArguments(args);
 		return dialog;
 	}
@@ -107,6 +116,7 @@ public class WizardDialog extends ThreemaDialogFragment {
 		String titleString = getArguments().getString(ARG_TITLE_STRING);
 		int positive = getArguments().getInt(ARG_POSITIVE);
 		int negative = getArguments().getInt(ARG_NEGATIVE, 0);
+		Highlight highlight = (Highlight) getArguments().getSerializable(ARG_HIGHLIGHT);
 		final String tag = this.getTag();
 
 		final View dialogView = activity.getLayoutInflater().inflate(R.layout.dialog_wizard, null);
@@ -142,11 +152,43 @@ public class WizardDialog extends ThreemaDialogFragment {
 			negativeButton.setVisibility(View.GONE);
 		}
 
+		if (highlight != null) {
+			switch (highlight) {
+				case NONE:
+					hightlightButton(negativeButton, false);
+					hightlightButton(positiveButton, false);
+				case EQUAL:
+					hightlightButton(negativeButton, true);
+					hightlightButton(positiveButton, true);
+					break;
+				case NEGATIVE:
+					hightlightButton(negativeButton, true);
+					hightlightButton(positiveButton, false);
+					break;
+				case POSITIVE:
+				default:
+					positiveButton.setBackground(ResourcesCompat.getDrawable(getResources(), R.drawable.selector_button_green, null));
+					positiveButton.setTextColor(getResources().getColor(R.color.wizard_button_text_inverse));
+					break;
+			}
+		}
+
 		setCancelable(false);
 
 		return builder.create();
 	}
 
+	private void hightlightButton(Button button, boolean hightlight) {
+		if (hightlight) {
+			button.setBackground(ResourcesCompat.getDrawable(getResources(), R.drawable.selector_button_green, null));
+			button.setTextColor(getResources().getColor(R.color.wizard_button_text_inverse));
+		}
+		else {
+			button.setBackground(ResourcesCompat.getDrawable(getResources(), R.drawable.selector_button_green_inverse, null));
+			button.setTextColor(getResources().getColor(R.color.wizard_button_text));
+		}
+	}
+
 	@Override
 	public void onCancel(DialogInterface dialogInterface) {
 		callback.onNo(this.getTag());

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