Threema 3 жил өмнө
parent
commit
316dfd22b3
100 өөрчлөгдсөн 8023 нэмэгдсэн , 4796 устгасан
  1. 6 6
      app/assets/emojis/search-index/be.csv
  2. 6 6
      app/assets/emojis/search-index/ca.csv
  3. 6 6
      app/assets/emojis/search-index/cs.csv
  4. 20 20
      app/assets/emojis/search-index/de.csv
  5. 238 238
      app/assets/emojis/search-index/diversities.csv
  6. 6 6
      app/assets/emojis/search-index/en.csv
  7. 6 6
      app/assets/emojis/search-index/es.csv
  8. 6 6
      app/assets/emojis/search-index/fr.csv
  9. 6 6
      app/assets/emojis/search-index/hu.csv
  10. 6 6
      app/assets/emojis/search-index/it.csv
  11. 6 6
      app/assets/emojis/search-index/nl.csv
  12. 6 6
      app/assets/emojis/search-index/no.csv
  13. 2666 2678
      app/assets/emojis/search-index/orders.csv
  14. 6 6
      app/assets/emojis/search-index/pl.csv
  15. 6 6
      app/assets/emojis/search-index/pt.csv
  16. 6 6
      app/assets/emojis/search-index/ru.csv
  17. 6 6
      app/assets/emojis/search-index/sk.csv
  18. 6 6
      app/assets/emojis/search-index/tr.csv
  19. 6 6
      app/assets/emojis/search-index/uk.csv
  20. 89 99
      app/build.gradle
  21. 2 0
      app/proguard-project.txt
  22. 10 2
      app/src/androidTest/java/ch/threema/app/processors/MessageProcessorTest.java
  23. 3 3
      app/src/androidTest/java/ch/threema/app/service/GroupInviteServiceTest.java
  24. 0 4
      app/src/androidTest/java/ch/threema/app/voip/SdpTest.java
  25. 229 0
      app/src/androidTest/java/ch/threema/storage/SQLDHSessionStoreTest.java
  26. 52 42
      app/src/main/AndroidManifest.xml
  27. 2 1
      app/src/main/java/ch/threema/app/AutostartService.java
  28. 91 54
      app/src/main/java/ch/threema/app/ThreemaApplication.java
  29. 1 1
      app/src/main/java/ch/threema/app/activities/ComposeMessageActivity.java
  30. 24 15
      app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java
  31. 4 4
      app/src/main/java/ch/threema/app/activities/EditSendContactActivity.kt
  32. 32 14
      app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java
  33. 130 29
      app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java
  34. 56 47
      app/src/main/java/ch/threema/app/activities/HomeActivity.java
  35. 554 112
      app/src/main/java/ch/threema/app/activities/ImagePaintActivity.java
  36. 18 40
      app/src/main/java/ch/threema/app/activities/MapActivity.java
  37. 14 9
      app/src/main/java/ch/threema/app/activities/MediaGalleryActivity.java
  38. 35 25
      app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java
  39. 3 4
      app/src/main/java/ch/threema/app/activities/NotificationsActivity.java
  40. 11 12
      app/src/main/java/ch/threema/app/activities/RecipientListBaseActivity.java
  41. 270 359
      app/src/main/java/ch/threema/app/activities/SendMediaActivity.java
  42. 2 2
      app/src/main/java/ch/threema/app/activities/StorageManagementActivity.java
  43. 11 0
      app/src/main/java/ch/threema/app/activities/TextChatBubbleActivity.java
  44. 2 1
      app/src/main/java/ch/threema/app/activities/ThreemaActivity.java
  45. 3 2
      app/src/main/java/ch/threema/app/activities/WhatsNewActivity.java
  46. 7 2
      app/src/main/java/ch/threema/app/activities/ballot/BallotOverviewActivity.java
  47. 66 26
      app/src/main/java/ch/threema/app/activities/wizard/WizardBaseActivity.java
  48. 47 12
      app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java
  49. 74 5
      app/src/main/java/ch/threema/app/adapters/ContactDetailAdapter.java
  50. 386 0
      app/src/main/java/ch/threema/app/adapters/GroupCallParticipantsAdapter.kt
  51. 185 13
      app/src/main/java/ch/threema/app/adapters/GroupDetailAdapter.java
  52. 1 1
      app/src/main/java/ch/threema/app/adapters/MediaGalleryAdapter.java
  53. 209 47
      app/src/main/java/ch/threema/app/adapters/MessageListAdapter.java
  54. 55 177
      app/src/main/java/ch/threema/app/adapters/SendMediaAdapter.kt
  55. 313 0
      app/src/main/java/ch/threema/app/adapters/SendMediaPreviewAdapter.kt
  56. 3 4
      app/src/main/java/ch/threema/app/adapters/decorators/AnimGifChatAdapterDecorator.java
  57. 6 4
      app/src/main/java/ch/threema/app/adapters/decorators/AudioChatAdapterDecorator.java
  58. 6 5
      app/src/main/java/ch/threema/app/adapters/decorators/BallotChatAdapterDecorator.java
  59. 28 14
      app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java
  60. 14 8
      app/src/main/java/ch/threema/app/adapters/decorators/FileChatAdapterDecorator.java
  61. 2 6
      app/src/main/java/ch/threema/app/adapters/decorators/FirstUnreadChatAdapterDecorator.java
  62. 52 0
      app/src/main/java/ch/threema/app/adapters/decorators/ForwardSecurityStatusChatAdapterDecorator.kt
  63. 46 0
      app/src/main/java/ch/threema/app/adapters/decorators/GroupCallStatusDataChatAdapterDecorator.java
  64. 16 6
      app/src/main/java/ch/threema/app/adapters/decorators/ImageChatAdapterDecorator.java
  65. 11 41
      app/src/main/java/ch/threema/app/adapters/decorators/LocationChatAdapterDecorator.java
  66. 4 0
      app/src/main/java/ch/threema/app/adapters/decorators/TextChatAdapterDecorator.java
  67. 47 0
      app/src/main/java/ch/threema/app/adapters/decorators/VerticalGridLayoutGutterDecoration.kt
  68. 12 8
      app/src/main/java/ch/threema/app/adapters/decorators/VideoChatAdapterDecorator.java
  69. 1 1
      app/src/main/java/ch/threema/app/adapters/decorators/VoipStatusDataChatAdapterDecorator.java
  70. 11 9
      app/src/main/java/ch/threema/app/archive/ArchiveActivity.java
  71. 2 1
      app/src/main/java/ch/threema/app/asynctasks/DeleteConversationsAsyncTask.java
  72. 11 6
      app/src/main/java/ch/threema/app/backuprestore/BackupChatServiceImpl.java
  73. 7 12
      app/src/main/java/ch/threema/app/backuprestore/csv/BackupRestoreDataServiceImpl.java
  74. 59 30
      app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java
  75. 147 112
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java
  76. 3 1
      app/src/main/java/ch/threema/app/backuprestore/csv/RestoreSettings.java
  77. 4 0
      app/src/main/java/ch/threema/app/backuprestore/csv/Tags.java
  78. 40 28
      app/src/main/java/ch/threema/app/camera/CameraFragment.kt
  79. 2 2
      app/src/main/java/ch/threema/app/camera/QRCodeAnalyer.kt
  80. 5 5
      app/src/main/java/ch/threema/app/camera/QRScannerActivity.kt
  81. 167 17
      app/src/main/java/ch/threema/app/camera/VideoEditView.java
  82. 13 10
      app/src/main/java/ch/threema/app/dialogs/BottomSheetAbstractDialog.java
  83. 95 0
      app/src/main/java/ch/threema/app/dialogs/CallbackTextEntryDialog.kt
  84. 55 0
      app/src/main/java/ch/threema/app/dialogs/ExpandableTextEntryDialog.java
  85. 9 4
      app/src/main/java/ch/threema/app/dialogs/GenericAlertDialog.java
  86. 105 0
      app/src/main/java/ch/threema/app/dialogs/GroupDescEditDialog.java
  87. 279 15
      app/src/main/java/ch/threema/app/dialogs/MessageDetailDialog.java
  88. 18 0
      app/src/main/java/ch/threema/app/dialogs/SimpleStringAlertDialog.java
  89. 6 10
      app/src/main/java/ch/threema/app/emojis/EmojiDetailPopup.java
  90. 42 7
      app/src/main/java/ch/threema/app/emojis/EmojiMarkupUtil.java
  91. 0 1
      app/src/main/java/ch/threema/app/emojis/EmojiSearchWidget.kt
  92. 4 7
      app/src/main/java/ch/threema/app/emojis/RecentEmojiRemovePopup.java
  93. 1 1
      app/src/main/java/ch/threema/app/emojis/search/DiversityConverters.kt
  94. 1 1
      app/src/main/java/ch/threema/app/emojis/search/EmojiSearchIndex.kt
  95. 1 1
      app/src/main/java/ch/threema/app/filepicker/FilePickerAdapter.java
  96. 224 0
      app/src/main/java/ch/threema/app/fragments/BigMediaFragment.kt
  97. 356 188
      app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java
  98. 40 17
      app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java
  99. 35 2
      app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java
  100. 22 16
      app/src/main/java/ch/threema/app/fragments/mediaviews/AudioViewFragment.java

+ 6 - 6
app/assets/emojis/search-index/be.csv

@@ -257,7 +257,7 @@
 💤|камічны|сон|храп
 👋|вітаю|махае рукой|маханне|рука
 🤚|далонь|паднятая
-🖐|паднятая рука з растапыранымі пальцамі|пальцы|растапырванне|рука
+🖐|паднятая рука з растапыранымі пальцамі|пальцы|растапырванне|рука
 ✋|паднятая рука|рука|стоп|цела
 🖖|вулканскі салют|жэст|спок|стартрэк
 👌|добра|жэст «ок»|ок|рука
@@ -403,7 +403,7 @@
 👮|афіцэр|паліцыянт
 👮‍♂️|афіцэр|мужчына|паліцыянт|паліцэйскі
 👮‍♀️|афіцэр|жанчына-паліцыянт|паліцыянт|паліцэйская
-🕵|дэтэктыў|сышчык|шпіён
+🕵|дэтэктыў|сышчык|шпіён
 🕵️‍♂️|дэтэктыў|мужчына|сышчык|шпіён
 🕵️‍♀️|дэтэктыў|жанчына|сышчыца|шпіёнка
 💂|ахоўнік|варта|ганаровая варта|гвардыя
@@ -505,7 +505,7 @@
 🏇|бегавы конь|гонкі|жакей|конь|скачкі
 ⛷️|лыжнік|лыжныя палкі|лыжы|снег|спорт
 🏂|снаўбардыст|снаўборд|снег|спорт
-🏌|гольф|мяч|чалавек гуляе ў гольф
+🏌|гольф|мяч|чалавек гуляе ў гольф
 🏌️‍♂️|гольф|мужчына гуляе ў гольф
 🏌️‍♀️|гольф|жанчына гуляе ў гольф
 🏄|дошка|сёрфінг|хвалі
@@ -517,10 +517,10 @@
 🏊|плаванне|чалавек плыве
 🏊‍♂️|мужчына|плаванне|плывец
 🏊‍♀️|жанчына|плаванне|плыўчыха
-⛹|гульня|мяч|чалавек з мячом
+⛹|гульня|мяч|чалавек з мячом
 ⛹️‍♂️|гульня|мужчына з мячом|мяч
 ⛹️‍♀️|гульня|жанчына з мячом|мяч
-🏋|атлетыка|цяжкая|чалавек са штангай|штанга
+🏋|атлетыка|цяжкая|чалавек са штангай|штанга
 🏋️‍♂️|атлетыка|мужчына|цяжкаатлет|цяжкая|штанга
 🏋️‍♀️|атлетыка|жанчына|цяжкаатлетка|цяжкая|штанга
 🚴|веласіпед|педалі|ровар|чалавек на веласіпедзе
@@ -1430,7 +1430,7 @@
 📶|антэна|мабільны|палоска|сетка|сігнал|сотавы|тэлефон|узровень сігналу сеткі
 📳|вібрацыя|мабільны|рэжым вібрацыі|сотавы|тэлефон
 📴|выключаны|мабільны|сотавы|тэлефон
-⚧|сімвал трансгендара|трансгендар
+⚧|сімвал трансгендара|трансгендар
 ♾️|безгранічны|бясконцасць|вечны|універсальны
 ‼️|!!|вокліч|два клічнікі|знак|клічнікі|пунктуацыя|шум
 💱|абмен валют|банк|валюта|грошы

+ 6 - 6
app/assets/emojis/search-index/ca.csv

@@ -257,7 +257,7 @@
 💤|còmic|son|zzz
 👋|mà que saluda|salutació
 🤚|alçada|mà|revers de la mà
-🖐|dits oberts|mà amb els dits oberts|mà oberta
+🖐|dits oberts|mà amb els dits oberts|mà oberta
 ✋|cos|mà aixecada
 🖖|dit|mà|salutació vulcaniana|spock|vulcanià
 👌|acceptació|mà d’acceptació|ok|senyal d’aprovació amb la mà
@@ -403,7 +403,7 @@
 👮|agent de policia|policia|seguretat
 👮‍♂️|agent de policia home|home|masculí|policia
 👮‍♀️|agent de policia dona|dona|femení|policia
-🕵|detectiu|espia|investigador
+🕵|detectiu|espia|investigador
 🕵️‍♂️|detectiu|espia|home|investigador
 🕵️‍♀️|detectiu|dona|espia|investigadora
 💂|guarda
@@ -505,7 +505,7 @@
 🏇|cavalls|curses de cavalls|genet
 ⛷️|esquiador|esquiar|neu
 🏂|neu|surf de neu|surfista de neu
-🏌|bola|golfista|pal|pilota
+🏌|bola|golfista|pal|pilota
 🏌️‍♂️|golfista|home que juga a golf|masculí
 🏌️‍♀️|dona que juga a golf|femení|golfista
 🏄|onada|persona que fa surf|surf|taula
@@ -517,10 +517,10 @@
 🏊|natació|nedar|persona nedant|persona que neda
 🏊‍♂️|home que neda|masculí|nedador|nedar
 🏊‍♀️|dona que neda|femení|nedadora|nedar
-⛹|persona amb una pilota|pilota
+⛹|persona amb una pilota|pilota
 ⛹️‍♂️|home amb una pilota|masculí|pilota
 ⛹️‍♀️|dona amb una pilota|femení|pilota
-🏋|aixecador|halterofília|persona que aixeca peses|pesa
+🏋|aixecador|halterofília|persona que aixeca peses|pesa
 🏋️‍♂️|aixecador|home que aixeca peses|masculí|peses
 🏋️‍♀️|aixecadora|dona que aixeca peses|femení|peses
 🚴|bicicleta|ciclisme|ciclista
@@ -1430,7 +1430,7 @@
 📶|barres de cobertura|cobertura|mòbil|telèfon
 📳|mode de vibració|telèfon|vibració
 📴|apagat|desactivat|desconnectat|mòbil|telèfon
-⚧|símbol transgènere|transgènere
+⚧|símbol transgènere|transgènere
 ♾️|il·limitat|infinit|per sempre|universal
 ‼️|!!|doble exclamació|exclamació|signe d’exclamació doble
 💱|canvi de divisa|divisa

+ 6 - 6
app/assets/emojis/search-index/cs.csv

@@ -257,7 +257,7 @@
 💤|chrr|komiks|spánek|spát|zzz
 👋|mávající ruka|mávání|mávat|ruka|tělo
 🤚|hřbet zvednuté ruky|ruka|zdvižená|zvednutá
-🖐|prst|roztažené|ruka s roztaženými prsty|tělo
+🖐|prst|roztažené|ruka s roztaženými prsty|tělo
 ✋|ruka|tělo|zvednutá ruka
 🖖|prst|ruka|spock|tělo|vulkánský pozdrav
 👌|gesto ok|ok|ruka|tělo
@@ -403,7 +403,7 @@
 👮|policajt|policie|policista|strážník
 👮‍♂️|muž|policajt|policie|policista|strážník
 👮‍♀️|policajtka|policie|policistka|strážnice|žena
-🕵|agent|čmuchal|detektiv|inspektor|očko|špeh|špión|vyšetřovatel
+🕵|agent|čmuchal|detektiv|inspektor|očko|špeh|špión|vyšetřovatel
 🕵️‍♂️|agent|čmuchal|detektiv|inspektor|kriminální inspektor|muž|očko|špeh|špión|vyšetřovatel
 🕵️‍♀️|agentka|čmuchalka|detektivka|inspektorka|kriminální inspektorka|očko|špehyně|špiónka|vyšetřovatelka|žena
 💂|gardista|stráž
@@ -505,7 +505,7 @@
 🏇|dostihový kůň|dostihy|kůň|závod|žokej na koni
 ⛷️|lyžař|lyže|sníh
 🏂|lyže|sníh|snowboardista
-🏌|golf|míček|osoba hrající golf
+🏌|golf|míček|osoba hrající golf
 🏌️‍♂️|golfista|muž
 🏌️‍♀️|golfistka|žena
 🏄|osoba na surfu|prkno|surfing|surfování
@@ -517,10 +517,10 @@
 🏊|plavající osoba|plavání
 🏊‍♂️|muž|plavání|plavec
 🏊‍♀️|plavání|plavkyně|žena
-⛹|míč|osoba driblující s míčem
+⛹|míč|osoba driblující s míčem
 ⛹️‍♂️|míč|muž driblující s míčem
 ⛹️‍♀️|míč|žena driblující s míčem
-🏋|činka|osoba vzpírající činku|silák|tíha|váha|vzpěrač|vzpírání
+🏋|činka|osoba vzpírající činku|silák|tíha|váha|vzpěrač|vzpírání
 🏋️‍♂️|činka|muž|silák|tíha|váha|vzpěrač|vzpírání
 🏋️‍♀️|činka|silná|tíha|váha|vzpěračka|vzpírání|žena
 🚴|cyklista|cyklistika|kolo|osoba na kole
@@ -1430,7 +1430,7 @@
 📶|anténa|indikátor síly signálu|mobilní|proužky|signál|síla|telefon|ukazatel
 📳|mobilní|režim|telefon|vibrace|vibrační režim|vibrování
 📴|mobilní|telefon|vypnuto|vypnutý mobilní telefon
-⚧|symbol pro transgender osoby|transgender
+⚧|symbol pro transgender osoby|transgender
 ♾️|bez hranic|nekonečnost|neomezenost|věčnost
 ‼️|!!|dvojitý vykřičník|interpunkční znaménka|vykřičník
 💱|banka|měna|peníze|směna peněz|směnárna|výměna

+ 20 - 20
app/assets/emojis/search-index/de.csv

@@ -165,7 +165,7 @@
 😵‍💫|gesicht mit spiralen als augen|gesicht mit spiralaugen
 🤯|entsetzt|explodierender kopf|geschockt
 🤠|cowboy|gesicht mit cowboyhut|hut
-🥳|feiern|partygesicht
+🥳|feiern|partygesicht|tröte
 🥸|brille mit nase|inkognito|verkleidetes gesicht|verkleidung|gesicht mit maske|incognito|maske|nase
 😎|cool|gesicht|lächelndes gesicht mit sonnenbrille|sonnenbrille
 🤓|gesicht|nerd|strebergesicht
@@ -237,7 +237,7 @@
 💛|gelbes herz|herz
 💚|grünes herz|herz
 💙|blaues herz|herz
-💜|herz|lila
+💜|herz|lila|violett
 🤎|braunes herz|herz
 🖤|böse|herz|schwarzes herz
 🤍|herz|weißes herz|weisses herz
@@ -257,7 +257,7 @@
 💤|comic|schlafen|schnarchen|zzz
 👋|hand|winkende hand
 🤚|erhobene hand von hinten|erhobener handrücken|hand
-🖐|5|finger|fünf|gespreizt|hand mit gespreizten fingern
+🖐|5|finger|fünf|gespreizt|hand mit gespreizten fingern
 ✋|erhobene hand|hand
 🖖|lebe lang und in frieden|spock|spreizen|star trek|vulkanischer gruß|finger|hand|vulkanier(in)|vulkanischer gruss
 👌|exzellent|hand|in ordnung|ok-zeichen|perfekt
@@ -272,10 +272,10 @@
 👉|finger|handrückseite|nach rechts weisender zeigefinger|rechts
 👆|aufwärts|finger|handrückseite|nach oben weisender zeigefinger von hinten
 🖕|finger|hand|mittelfinger
-👇|abwärts|finger|handrückseite|nach unten weisender zeigefinger
-☝️|finger|handvorderseite|nach oben weisender zeigefinger von vorne|zeigefinger
-👍|daumen hoch|gut|hand|nach oben
-👎|daumen runter|hand|nach unten|schlecht
+👇|abwärts|finger|handrückseite|nach unten weisender zeigefinger|runter
+☝️|finger|handvorderseite|nach oben weisender zeigefinger von vorne|zeigefinger|hoch
+👍|daumen hoch|gut|hand|nach oben|hoch
+👎|daumen runter|hand|nach unten|schlecht|runter
 ✊|erhobene faust|faust
 👊|faust|geballte faust|hand
 🤛|faust nach links|nach links
@@ -403,7 +403,7 @@
 👮|gesicht|polizei|polizist(in)
 👮‍♂️|mann|polizei|polizist|männlich
 👮‍♀️|frau|polizei|polizistin|weiblich
-🕵|detektiv(in)|spion
+🕵|detektiv(in)|spion
 🕵️‍♂️|detektiv|mann|spion|männlich
 🕵️‍♀️|detektivin|frau|spionin|weiblich
 💂|buckingham palace|wache|wachfrau|wachmann|wachsoldatin
@@ -505,7 +505,7 @@
 🏇|jockey auf pferd|pferderennen|sport
 ⛷️|schnee|skifahrer(in)|skifahrerin|sport
 🏂|snowboarden|snowboarder(in)|snowboarderin|sport
-🏌|golfer(in)
+🏌|golfer(in)
 🏌️‍♂️|golfen|golfer|golfspieler|mann|männlich
 🏌️‍♀️|frau|golfen|golferin|golfspielerin|weiblich
 🏄|surfen|surfer(in)|wassersport|wellenreiten|wellenreiterin
@@ -517,10 +517,10 @@
 🏊|kraulen|schwimmen|schwimmer(in)|sport|wasser
 🏊‍♂️|kraulen|pool|schwimmbad|schwimmen|schwimmer
 🏊‍♀️|kraulen|pool|schwimmbad|schwimmen|schwimmerin
-⛹|ball|basketball|person mit ball
+⛹|ball|basketball|person mit ball
 ⛹️‍♂️|ballsport|handball|mann mit ball|männlich
 ⛹️‍♀️|ballsport|frau mit ball|handball|weiblich
-🏋|gewichtheber(in)
+🏋|gewichtheber(in)
 🏋️‍♂️|gewicht heben|gewichtheber|mann|männlich
 🏋️‍♀️|frau|gewicht heben|gewichtheberin|weiblich
 🚴|radfahren|radfahrer(in)
@@ -1323,7 +1323,7 @@
 💊|arzt|kapsel|medizin|tabletten
 🩹|heftpflaster|pflaster
 🩺|arzt|herz|medizin|stethoskop
-🚪|eingang|geschlossen|tür
+🚪|eingang|geschlossen|tür|ausgang
 🛗|aufzug|fahrstuhl|lift
 🪞|reflexion|spiegelbild
 🪟|aussicht|durchsichtig|fenster|frische luft|öffnung|rahmen
@@ -1366,9 +1366,9 @@
 🛃|zollkontrolle
 🛄|gepäckausgabe
 🛅|gepäckaufbewahrung|schließfach|schliessfach
-⚠️|dreieck|warnung
+⚠️|dreieck|warnung|vorsicht|achtung
 🚸|kinder überqueren die straße|vorsicht|kinder-queren-die-strasse-schild
-⛔|keine durchfahrt|verboten|zutritt verboten
+⛔|keine durchfahrt|verboten|zutritt verboten|einbahn|halt
 🚫|verboten|verbotszeichen
 🚳|fahrräder verboten|radfahren verboten
 🚭|rauchen verboten|rauchverbot
@@ -1377,9 +1377,9 @@
 🚷|fußgänger verboten|verboten|fussgänger-verboten-schild
 📵|mobiltelefone verboten|verbot
 🔞|erwachsene|minderjährige verboten|mindestalter|nicht jugendfrei
-⬆️|aufwärtspfeil|nach oben|norden|pfeil nach oben
+⬆️|aufwärtspfeil|nach oben|norden|pfeil nach oben|hoch
 ➡️|nach rechts|osten|pfeil nach rechts|rechtspfeil
-⬇️|abwärtspfeil|nach unten|pfeil nach unten|süden
+⬇️|abwärtspfeil|nach unten|pfeil nach unten|süden|runter
 ⬅️|linkspfeil|nach links|pfeil nach links|westen
 ↩️|geschwungener pfeil nach links|links|nach links|pfeil
 ↪️|geschwungener pfeil nach rechts|nach rechts|pfeil|rechts
@@ -1389,7 +1389,7 @@
 🔚|end-pfeil|links|pfeil
 🔛|on!-pfeil|pfeil|rechts und links
 🔜|pfeil|rechts|soon-pfeil
-🔝|pfeil nach oben|top-pfeil
+🔝|pfeil nach oben|top-pfeil|hoch
 🛐|religion|religiöse stätte
 ⚛️|atheist|atomzeichen
 🕉️|hinduismus|om|religion
@@ -1430,7 +1430,7 @@
 📶|balkenförmige signalstärkenanzeige|empfang|mobilfunksignal|mobiltelefon|signalstärke
 📳|mobiltelefon|vibrationsmodus
 📴|ausschalten|handy aus|mobiltelefon aus
-⚧|symbol für transgender|transgender-symbol
+⚧|symbol für transgender|transgender-symbol
 ♾️|ewig|grenzenlos|unendlichkeit
 ‼️|ausrufezeichen|doppeltes ausrufezeichen|rot|satzzeichen
 💱|geldwechsel|währung|wechsel
@@ -1521,8 +1521,8 @@
 🎌|japanische flaggen|überkreuzte flaggen|flaggen
 🏴|fahne|schwarze fahne|schwarze flagge|wehen|flagge
 🏳️|fahne|weiße fahne|weiße flagge|wehende weisse fahne|weisse fahne|flagge
-🏳️‍🌈|bunt|fahne|regenbogenflagge|flagge
-🏳️‍⚧️|flagge|transgender-flagge|hellblau|pink|transgenderflagge|weiss
+🏳️‍🌈|bunt|fahne|regenbogenflagge|flagge|lgbtqia|pride
+🏳️‍⚧️|flagge|transgender-flagge|hellblau|pink|transgenderflagge|weiss|lgbtqia
 🏴‍☠️|jolly roger|piratenfahne|piratenflagge|schatz|flagge
 🇦🇩|andorra
 🇦🇪|vereinigte arabische emirate

+ 238 - 238
app/assets/emojis/search-index/diversities.csv

@@ -1,301 +1,301 @@
-🤲|🤲🏻|🤲🏼|🤲🏽|🤲🏾|🤲🏿
-👐|👐🏻|👐🏼|👐🏽|👐🏾|👐🏿
-🙌|🙌🏻|🙌🏼|🙌🏽|🙌🏾|🙌🏿
-👏|👏🏻|👏🏼|👏🏽|👏🏾|👏🏿
+👋|👋🏻|👋🏼|👋🏽|👋🏾|👋🏿
+🤚|🤚🏻|🤚🏼|🤚🏽|🤚🏾|🤚🏿
+🖐️|🖐🏻|🖐🏼|🖐🏽|🖐🏾|🖐🏿
+✋|✋🏻|✋🏼|✋🏽|✋🏾|✋🏿
+🖖|🖖🏻|🖖🏼|🖖🏽|🖖🏾|🖖🏿
 🫱|🫱🏻|🫱🏼|🫱🏽|🫱🏾|🫱🏿
 🫲|🫲🏻|🫲🏼|🫲🏽|🫲🏾|🫲🏿
 🫳|🫳🏻|🫳🏼|🫳🏽|🫳🏾|🫳🏿
 🫴|🫴🏻|🫴🏼|🫴🏽|🫴🏾|🫴🏿
-🫰|🫰🏻|🫰🏼|🫰🏽|🫰🏾|🫰🏿
-🫵|🫵🏻|🫵🏼|🫵🏽|🫵🏾|🫵🏿
-🫶|🫶🏻|🫶🏼|🫶🏽|🫶🏾|🫶🏿
-🤝|🤝🏻|🤝🏼|🤝🏽|🤝🏾|🤝🏿
-🫅|🫅🏻|🫅🏼|🫅🏽|🫅🏾|🫅🏿
-🫃|🫃🏻|🫃🏼|🫃🏽|🫃🏾|🫃🏿
-🫄|🫄🏻|🫄🏼|🫄🏽|🫄🏾|🫄🏿
-👍|👍🏻|👍🏼|👍🏽|👍🏾|👍🏿
-👎|👎🏻|👎🏼|👎🏽|👎🏾|👎🏿
-👊|👊🏻|👊🏼|👊🏽|👊🏾|👊🏿
-✊|✊🏻|✊🏼|✊🏽|✊🏾|✊🏿
-🤛|🤛🏻|🤛🏼|🤛🏽|🤛🏾|🤛🏿
-🤜|🤜🏻|🤜🏼|🤜🏽|🤜🏾|🤜🏿
-🤞|🤞🏻|🤞🏼|🤞🏽|🤞🏾|🤞🏿
+👌|👌🏻|👌🏼|👌🏽|👌🏾|👌🏿
+🤌|🤌🏻|🤌🏼|🤌🏽|🤌🏾|🤌🏿
+🤏|🤏🏻|🤏🏼|🤏🏽|🤏🏾|🤏🏿
 ✌️|✌🏻|✌🏼|✌🏽|✌🏾|✌🏿
+🤞|🤞🏻|🤞🏼|🤞🏽|🤞🏾|🤞🏿
+🫰|🫰🏻|🫰🏼|🫰🏽|🫰🏾|🫰🏿
 🤟|🤟🏻|🤟🏼|🤟🏽|🤟🏾|🤟🏿
 🤘|🤘🏻|🤘🏼|🤘🏽|🤘🏾|🤘🏿
-👌|👌🏻|👌🏼|👌🏽|👌🏾|👌🏿
-🤏|🤏🏻|🤏🏼|🤏🏽|🤏🏾|🤏🏿
-🤌|🤌🏻|🤌🏼|🤌🏽|🤌🏾|🤌🏿
+🤙|🤙🏻|🤙🏼|🤙🏽|🤙🏾|🤙🏿
 👈|👈🏻|👈🏼|👈🏽|👈🏾|👈🏿
 👉|👉🏻|👉🏼|👉🏽|👉🏾|👉🏿
 👆|👆🏻|👆🏼|👆🏽|👆🏾|👆🏿
+🖕|🖕🏻|🖕🏼|🖕🏽|🖕🏾|🖕🏿
 👇|👇🏻|👇🏼|👇🏽|👇🏾|👇🏿
 ☝️|☝🏻|☝🏼|☝🏽|☝🏾|☝🏿
-✋|✋🏻|✋🏼|✋🏽|✋🏾|✋🏿
-🤚|🤚🏻|🤚🏼|🤚🏽|🤚🏾|🤚🏿
-🖐|🖐🏻|🖐🏼|🖐🏽|🖐🏾|🖐🏿
-🖖|🖖🏻|🖖🏼|🖖🏽|🖖🏾|🖖🏿
-👋|👋🏻|👋🏼|👋🏽|👋🏾|👋🏿
-🤙|🤙🏻|🤙🏼|🤙🏽|🤙🏾|🤙🏿
-💪|💪🏻|💪🏼|💪🏽|💪🏾|💪🏿
-🖕|🖕🏻|🖕🏼|🖕🏽|🖕🏾|🖕🏿
-✍️|✍🏻|✍🏼|✍🏽|✍🏾|✍🏿
+🫵|🫵🏻|🫵🏼|🫵🏽|🫵🏾|🫵🏿
+👍|👍🏻|👍🏼|👍🏽|👍🏾|👍🏿
+👎|👎🏻|👎🏼|👎🏽|👎🏾|👎🏿
+✊|✊🏻|✊🏼|✊🏽|✊🏾|✊🏿
+👊|👊🏻|👊🏼|👊🏽|👊🏾|👊🏿
+🤛|🤛🏻|🤛🏼|🤛🏽|🤛🏾|🤛🏿
+🤜|🤜🏻|🤜🏼|🤜🏽|🤜🏾|🤜🏿
+👏|👏🏻|👏🏼|👏🏽|👏🏾|👏🏿
+🙌|🙌🏻|🙌🏼|🙌🏽|🙌🏾|🙌🏿
+🫶|🫶🏻|🫶🏼|🫶🏽|🫶🏾|🫶🏿
+👐|👐🏻|👐🏼|👐🏽|👐🏾|👐🏿
+🤲|🤲🏻|🤲🏼|🤲🏽|🤲🏾|🤲🏿
+🤝|🤝🏻|🤝🏼|🤝🏽|🤝🏾|🤝🏿
 🙏|🙏🏻|🙏🏼|🙏🏽|🙏🏾|🙏🏿
-🦶|🦶🏻|🦶🏼|🦶🏽|🦶🏾|🦶🏿
+✍️|✍🏻|✍🏼|✍🏽|✍🏾|✍🏿
+💅|💅🏻|💅🏼|💅🏽|💅🏾|💅🏿
+🤳|🤳🏻|🤳🏼|🤳🏽|🤳🏾|🤳🏿
+💪|💪🏻|💪🏼|💪🏽|💪🏾|💪🏿
 🦵|🦵🏻|🦵🏼|🦵🏽|🦵🏾|🦵🏿
+🦶|🦶🏻|🦶🏼|🦶🏽|🦶🏾|🦶🏿
 👂|👂🏻|👂🏼|👂🏽|👂🏾|👂🏿
 🦻|🦻🏻|🦻🏼|🦻🏽|🦻🏾|🦻🏿
 👃|👃🏻|👃🏼|👃🏽|👃🏾|👃🏿
 👶|👶🏻|👶🏼|👶🏽|👶🏾|👶🏿
-👧|👧🏻|👧🏼|👧🏽|👧🏾|👧🏿
 🧒|🧒🏻|🧒🏼|🧒🏽|🧒🏾|🧒🏿
 👦|👦🏻|👦🏼|👦🏽|👦🏾|👦🏿
-👩|👩🏻|👩🏼|👩🏽|👩🏾|👩🏿
+👧|👧🏻|👧🏼|👧🏽|👧🏾|👧🏿
 🧑|🧑🏻|🧑🏼|🧑🏽|🧑🏾|🧑🏿
+👱|👱🏻|👱🏼|👱🏽|👱🏾|👱🏿
 👨|👨🏻|👨🏼|👨🏽|👨🏾|👨🏿
-🧑‍🦱|🧑🏻‍🦱|🧑🏼‍🦱|🧑🏽‍🦱|🧑🏾‍🦱|🧑🏿‍🦱
-👩‍🦱|👩🏻‍🦱|👩🏼‍🦱|👩🏽‍🦱|👩🏾‍🦱|👩🏿‍🦱
+🧔|🧔🏻|🧔🏼|🧔🏽|🧔🏾|🧔🏿
+🧔‍♂️|🧔🏻‍♂️|🧔🏼‍♂️|🧔🏽‍♂️|🧔🏾‍♂️|🧔🏿‍♂️
+🧔‍♀️|🧔🏻‍♀️|🧔🏼‍♀️|🧔🏽‍♀️|🧔🏾‍♀️|🧔🏿‍♀️
+👨‍🦰|👨🏻‍🦰|👨🏼‍🦰|👨🏽‍🦰|👨🏾‍🦰|👨🏿‍🦰
 👨‍🦱|👨🏻‍🦱|👨🏼‍🦱|👨🏽‍🦱|👨🏾‍🦱|👨🏿‍🦱
-🧑‍🦰|🧑🏻‍🦰|🧑🏼‍🦰|🧑🏽‍🦰|🧑🏾‍🦰|🧑🏿‍🦰
+👨‍🦳|👨🏻‍🦳|👨🏼‍🦳|👨🏽‍🦳|👨🏾‍🦳|👨🏿‍🦳
+👨‍🦲|👨🏻‍🦲|👨🏼‍🦲|👨🏽‍🦲|👨🏾‍🦲|👨🏿‍🦲
+👩|👩🏻|👩🏼|👩🏽|👩🏾|👩🏿
 👩‍🦰|👩🏻‍🦰|👩🏼‍🦰|👩🏽‍🦰|👩🏾‍🦰|👩🏿‍🦰
-👨‍🦰|👨🏻‍🦰|👨🏼‍🦰|👨🏽‍🦰|👨🏾‍🦰|👨🏿‍🦰
-👱‍♀️|👱🏿‍♀️|👱🏾‍♀️|👱🏽‍♀️|👱🏼‍♀️|👱🏻‍♀️
-👱|👱🏻|👱🏼|👱🏽|👱🏾|👱🏿
-👱‍♂️|👱🏿‍♂️|👱🏾‍♂️|👱🏽‍♂️|👱🏼‍♂️|👱🏻‍♂️
-🧑‍🦳|🧑🏻‍🦳|🧑🏼‍🦳|🧑🏽‍🦳|🧑🏾‍🦳|🧑🏿‍🦳
+🧑‍🦰|🧑🏻‍🦰|🧑🏼‍🦰|🧑🏽‍🦰|🧑🏾‍🦰|🧑🏿‍🦰
+👩‍🦱|👩🏻‍🦱|👩🏼‍🦱|👩🏽‍🦱|👩🏾‍🦱|👩🏿‍🦱
+🧑‍🦱|🧑🏻‍🦱|🧑🏼‍🦱|🧑🏽‍🦱|🧑🏾‍🦱|🧑🏿‍🦱
 👩‍🦳|👩🏻‍🦳|👩🏼‍🦳|👩🏽‍🦳|👩🏾‍🦳|👩🏿‍🦳
-👨‍🦳|👨🏻‍🦳|👨🏼‍🦳|👨🏽‍🦳|👨🏾‍🦳|👨🏿‍🦳
-🧑‍🦲|🧑🏻‍🦲|🧑🏼‍🦲|🧑🏽‍🦲|🧑🏾‍🦲|🧑🏿‍🦲
+🧑‍🦳|🧑🏻‍🦳|🧑🏼‍🦳|🧑🏽‍🦳|🧑🏾‍🦳|🧑🏿‍🦳
 👩‍🦲|👩🏻‍🦲|👩🏼‍🦲|👩🏽‍🦲|👩🏾‍🦲|👩🏿‍🦲
-👨‍🦲|👨🏻‍🦲|👨🏼‍🦲|👨🏽‍🦲|👨🏾‍🦲|👨🏿‍🦲
-🧔|🧔🏻|🧔🏼|🧔🏽|🧔🏾|🧔🏿
-🧔‍♂️|🧔🏻‍♂️|🧔🏼‍♂️|🧔🏽‍♂️|🧔🏾‍♂️|🧔🏿‍♂️
-🧔‍♀️|🧔🏻‍♀️|🧔🏼‍♀️|🧔🏽‍♀️|🧔🏾‍♀️|🧔🏿‍♀️
-👵|👵🏻|👵🏼|👵🏽|👵🏾|👵🏿
+🧑‍🦲|🧑🏻‍🦲|🧑🏼‍🦲|🧑🏽‍🦲|🧑🏾‍🦲|🧑🏿‍🦲
+👱‍♀️|👱🏻‍♀️|👱🏼‍♀️|👱🏽‍♀️|👱🏾‍♀️|👱🏿‍♀️
+👱‍♂️|👱🏻‍♂️|👱🏼‍♂️|👱🏽‍♂️|👱🏾‍♂️|👱🏿‍♂️
 🧓|🧓🏻|🧓🏼|🧓🏽|🧓🏾|🧓🏿
 👴|👴🏻|👴🏼|👴🏽|👴🏾|👴🏿
-👲|👲🏻|👲🏼|👲🏽|👲🏾|👲🏿
-👳|👳🏻|👳🏼|👳🏽|👳🏾|👳🏿
-👳‍♀️|👳🏿‍♀️|👳🏾‍♀️|👳🏽‍♀️|👳🏼‍♀️|👳🏻‍♀️
-👳‍♂️|👳🏿‍♂️|👳🏾‍♂️|👳🏽‍♂️|👳🏼‍♂️|👳🏻‍♂️
-🧕|🧕🏻|🧕🏼|🧕🏽|🧕🏾|🧕🏿
-👮|👮🏻|👮🏼|👮🏽|👮🏾|👮🏿
-👮‍♀️|👮🏿‍♀️|👮🏾‍♀️|👮🏽‍♀️|👮🏼‍♀️|👮🏻‍♀️
-👮‍♂️|👮🏿‍♂️|👮🏾‍♂️|👮🏽‍♂️|👮🏼‍♂️|👮🏻‍♂️
-👷|👷🏻|👷🏼|👷🏽|👷🏾|👷🏿
-👷‍♀️|👷🏿‍♀️|👷🏾‍♀️|👷🏽‍♀️|👷🏼‍♀️|👷🏻‍♀️
-👷‍♂️|👷🏿‍♂️|👷🏾‍♂️|👷🏽‍♂️|👷🏼‍♂️|👷🏻‍♂️
-💂|💂🏻|💂🏼|💂🏽|💂🏾|💂🏿
-💂‍♀️|💂🏿‍♀️|💂🏾‍♀️|💂🏽‍♀️|💂🏼‍♀️|💂🏻‍♀️
-💂‍♂️|💂🏿‍♂️|💂🏾‍♂️|💂🏽‍♂️|💂🏼‍♂️|💂🏻‍♂️
-🕵|🕵🏻|🕵🏼|🕵🏽|🕵🏾|🕵🏿
-🕵️‍♀️|🕵🏿‍♀️|🕵🏾‍♀️|🕵🏽‍♀️|🕵🏼‍♀️|🕵🏻‍♀️
-🕵️‍♂️|🕵🏿‍♂️|🕵🏾‍♂️|🕵🏽‍♂️|🕵🏼‍♂️|🕵🏻‍♂️
+👵|👵🏻|👵🏼|👵🏽|👵🏾|👵🏿
+🙍|🙍🏻|🙍🏼|🙍🏽|🙍🏾|🙍🏿
+🙍‍♂️|🙍🏻‍♂️|🙍🏼‍♂️|🙍🏽‍♂️|🙍🏾‍♂️|🙍🏿‍♂️
+🙍‍♀️|🙍🏻‍♀️|🙍🏼‍♀️|🙍🏽‍♀️|🙍🏾‍♀️|🙍🏿‍♀️
+🙎|🙎🏻|🙎🏼|🙎🏽|🙎🏾|🙎🏿
+🙎‍♂️|🙎🏻‍♂️|🙎🏼‍♂️|🙎🏽‍♂️|🙎🏾‍♂️|🙎🏿‍♂️
+🙎‍♀️|🙎🏻‍♀️|🙎🏼‍♀️|🙎🏽‍♀️|🙎🏾‍♀️|🙎🏿‍♀️
+🙅|🙅🏻|🙅🏼|🙅🏽|🙅🏾|🙅🏿
+🙅‍♂️|🙅🏻‍♂️|🙅🏼‍♂️|🙅🏽‍♂️|🙅🏾‍♂️|🙅🏿‍♂️
+🙅‍♀️|🙅🏻‍♀️|🙅🏼‍♀️|🙅🏽‍♀️|🙅🏾‍♀️|🙅🏿‍♀️
+🙆|🙆🏻|🙆🏼|🙆🏽|🙆🏾|🙆🏿
+🙆‍♂️|🙆🏻‍♂️|🙆🏼‍♂️|🙆🏽‍♂️|🙆🏾‍♂️|🙆🏿‍♂️
+🙆‍♀️|🙆🏻‍♀️|🙆🏼‍♀️|🙆🏽‍♀️|🙆🏾‍♀️|🙆🏿‍♀️
+💁|💁🏻|💁🏼|💁🏽|💁🏾|💁🏿
+💁‍♂️|💁🏻‍♂️|💁🏼‍♂️|💁🏽‍♂️|💁🏾‍♂️|💁🏿‍♂️
+💁‍♀️|💁🏻‍♀️|💁🏼‍♀️|💁🏽‍♀️|💁🏾‍♀️|💁🏿‍♀️
+🙋|🙋🏻|🙋🏼|🙋🏽|🙋🏾|🙋🏿
+🙋‍♂️|🙋🏻‍♂️|🙋🏼‍♂️|🙋🏽‍♂️|🙋🏾‍♂️|🙋🏿‍♂️
+🙋‍♀️|🙋🏻‍♀️|🙋🏼‍♀️|🙋🏽‍♀️|🙋🏾‍♀️|🙋🏿‍♀️
+🧏|🧏🏻|🧏🏼|🧏🏽|🧏🏾|🧏🏿
+🧏‍♂️|🧏🏻‍♂️|🧏🏼‍♂️|🧏🏽‍♂️|🧏🏾‍♂️|🧏🏿‍♂️
+🧏‍♀️|🧏🏻‍♀️|🧏🏼‍♀️|🧏🏽‍♀️|🧏🏾‍♀️|🧏🏿‍♀️
+🙇|🙇🏻|🙇🏼|🙇🏽|🙇🏾|🙇🏿
+🙇‍♂️|🙇🏻‍♂️|🙇🏼‍♂️|🙇🏽‍♂️|🙇🏾‍♂️|🙇🏿‍♂️
+🙇‍♀️|🙇🏻‍♀️|🙇🏼‍♀️|🙇🏽‍♀️|🙇🏾‍♀️|🙇🏿‍♀️
+🤦|🤦🏻|🤦🏼|🤦🏽|🤦🏾|🤦🏿
+🤦‍♂️|🤦🏻‍♂️|🤦🏼‍♂️|🤦🏽‍♂️|🤦🏾‍♂️|🤦🏿‍♂️
+🤦‍♀️|🤦🏻‍♀️|🤦🏼‍♀️|🤦🏽‍♀️|🤦🏾‍♀️|🤦🏿‍♀️
+🤷|🤷🏻|🤷🏼|🤷🏽|🤷🏾|🤷🏿
+🤷‍♂️|🤷🏻‍♂️|🤷🏼‍♂️|🤷🏽‍♂️|🤷🏾‍♂️|🤷🏿‍♂️
+🤷‍♀️|🤷🏻‍♀️|🤷🏼‍♀️|🤷🏽‍♀️|🤷🏾‍♀️|🤷🏿‍♀️
 🧑‍⚕️|🧑🏻‍⚕️|🧑🏼‍⚕️|🧑🏽‍⚕️|🧑🏾‍⚕️|🧑🏿‍⚕️
-👩‍⚕️|👩🏿‍⚕️|👩🏾‍⚕️|👩🏽‍⚕️|👩🏼‍⚕️|👩🏻‍⚕️
-👨‍⚕️|👨🏿‍⚕️|👨🏾‍⚕️|👨🏽‍⚕️|👨🏼‍⚕️|👨🏻‍⚕️
-🧑‍🌾|🧑🏻‍🌾|🧑🏼‍🌾|🧑🏽‍🌾|🧑🏾‍🌾|🧑🏿‍🌾
-👩‍🌾|👩🏿‍🌾|👩🏾‍🌾|👩🏽‍🌾|👩🏼‍🌾|👩🏻‍🌾
-👨‍🌾|👨🏿‍🌾|👨🏾‍🌾|👨🏽‍🌾|👨🏼‍🌾|👨🏻‍🌾
-🧑‍🍳|🧑🏻‍🍳|🧑🏼‍🍳|🧑🏽‍🍳|🧑🏾‍🍳|🧑🏿‍🍳
-👩‍🍳|👩🏿‍🍳|👩🏾‍🍳|👩🏽‍🍳|👩🏼‍🍳|👩🏻‍🍳
-👨‍🍳|👨🏿‍🍳|👨🏾‍🍳|👨🏽‍🍳|👨🏼‍🍳|👨🏻‍🍳
+👨‍⚕️|👨🏻‍⚕️|👨🏼‍⚕️|👨🏽‍⚕️|👨🏾‍⚕️|👨🏿‍⚕️
+👩‍⚕️|👩🏻‍⚕️|👩🏼‍⚕️|👩🏽‍⚕️|👩🏾‍⚕️|👩🏿‍⚕️
 🧑‍🎓|🧑🏻‍🎓|🧑🏼‍🎓|🧑🏽‍🎓|🧑🏾‍🎓|🧑🏿‍🎓
-👩‍🎓|👩🏿‍🎓|👩🏾‍🎓|👩🏽‍🎓|👩🏼‍🎓|👩🏻‍🎓
-👨‍🎓|👨🏿‍🎓|👨🏾‍🎓|👨🏽‍🎓|👨🏼‍🎓|👨🏻‍🎓
-🧑‍🎤|🧑🏻‍🎤|🧑🏼‍🎤|🧑🏽‍🎤|🧑🏾‍🎤|🧑🏿‍🎤
-👩‍🎤|👩🏿‍🎤|👩🏾‍🎤|👩🏽‍🎤|👩🏼‍🎤|👩🏻‍🎤
-👨‍🎤|👨🏿‍🎤|👨🏾‍🎤|👨🏽‍🎤|👨🏼‍🎤|👨🏻‍🎤
+👨‍🎓|👨🏻‍🎓|👨🏼‍🎓|👨🏽‍🎓|👨🏾‍🎓|👨🏿‍🎓
+👩‍🎓|👩🏻‍🎓|👩🏼‍🎓|👩🏽‍🎓|👩🏾‍🎓|👩🏿‍🎓
 🧑‍🏫|🧑🏻‍🏫|🧑🏼‍🏫|🧑🏽‍🏫|🧑🏾‍🏫|🧑🏿‍🏫
-👩‍🏫|👩🏿‍🏫|👩🏾‍🏫|👩🏽‍🏫|👩🏼‍🏫|👩🏻‍🏫
-👨‍🏫|👨🏿‍🏫|👨🏾‍🏫|👨🏽‍🏫|👨🏼‍🏫|👨🏻‍🏫
+👨‍🏫|👨🏻‍🏫|👨🏼‍🏫|👨🏽‍🏫|👨🏾‍🏫|👨🏿‍🏫
+👩‍🏫|👩🏻‍🏫|👩🏼‍🏫|👩🏽‍🏫|👩🏾‍🏫|👩🏿‍🏫
+🧑‍⚖️|🧑🏻‍⚖️|🧑🏼‍⚖️|🧑🏽‍⚖️|🧑🏾‍⚖️|🧑🏿‍⚖️
+👨‍⚖️|👨🏻‍⚖️|👨🏼‍⚖️|👨🏽‍⚖️|👨🏾‍⚖️|👨🏿‍⚖️
+👩‍⚖️|👩🏻‍⚖️|👩🏼‍⚖️|👩🏽‍⚖️|👩🏾‍⚖️|👩🏿‍⚖️
+🧑‍🌾|🧑🏻‍🌾|🧑🏼‍🌾|🧑🏽‍🌾|🧑🏾‍🌾|🧑🏿‍🌾
+👨‍🌾|👨🏻‍🌾|👨🏼‍🌾|👨🏽‍🌾|👨🏾‍🌾|👨🏿‍🌾
+👩‍🌾|👩🏻‍🌾|👩🏼‍🌾|👩🏽‍🌾|👩🏾‍🌾|👩🏿‍🌾
+🧑‍🍳|🧑🏻‍🍳|🧑🏼‍🍳|🧑🏽‍🍳|🧑🏾‍🍳|🧑🏿‍🍳
+👨‍🍳|👨🏻‍🍳|👨🏼‍🍳|👨🏽‍🍳|👨🏾‍🍳|👨🏿‍🍳
+👩‍🍳|👩🏻‍🍳|👩🏼‍🍳|👩🏽‍🍳|👩🏾‍🍳|👩🏿‍🍳
+🧑‍🔧|🧑🏻‍🔧|🧑🏼‍🔧|🧑🏽‍🔧|🧑🏾‍🔧|🧑🏿‍🔧
+👨‍🔧|👨🏻‍🔧|👨🏼‍🔧|👨🏽‍🔧|👨🏾‍🔧|👨🏿‍🔧
+👩‍🔧|👩🏻‍🔧|👩🏼‍🔧|👩🏽‍🔧|👩🏾‍🔧|👩🏿‍🔧
 🧑‍🏭|🧑🏻‍🏭|🧑🏼‍🏭|🧑🏽‍🏭|🧑🏾‍🏭|🧑🏿‍🏭
-👩‍🏭|👩🏿‍🏭|👩🏾‍🏭|👩🏽‍🏭|👩🏼‍🏭|👩🏻‍🏭
-👨‍🏭|👨🏿‍🏭|👨🏾‍🏭|👨🏽‍🏭|👨🏼‍🏭|👨🏻‍🏭
-🧑‍💻|🧑🏻‍💻|🧑🏼‍💻|🧑🏽‍💻|🧑🏾‍💻|🧑🏿‍💻
-👩‍💻|👩🏿‍💻|👩🏾‍💻|👩🏽‍💻|👩🏼‍💻|👩🏻‍💻
-👨‍💻|👨🏿‍💻|👨🏾‍💻|👨🏽‍💻|👨🏼‍💻|👨🏻‍💻
+👨‍🏭|👨🏻‍🏭|👨🏼‍🏭|👨🏽‍🏭|👨🏾‍🏭|👨🏿‍🏭
+👩‍🏭|👩🏻‍🏭|👩🏼‍🏭|👩🏽‍🏭|👩🏾‍🏭|👩🏿‍🏭
 🧑‍💼|🧑🏻‍💼|🧑🏼‍💼|🧑🏽‍💼|🧑🏾‍💼|🧑🏿‍💼
-👩‍💼|👩🏿‍💼|👩🏾‍💼|👩🏽‍💼|👩🏼‍💼|👩🏻‍💼
-👨‍💼|👨🏿‍💼|👨🏾‍💼|👨🏽‍💼|👨🏼‍💼|👨🏻‍💼
-🧑‍🔧|🧑🏻‍🔧|🧑🏼‍🔧|🧑🏽‍🔧|🧑🏾‍🔧|🧑🏿‍🔧
-👩‍🔧|👩🏿‍🔧|👩🏾‍🔧|👩🏽‍🔧|👩🏼‍🔧|👩🏻‍🔧
-👨‍🔧|👨🏿‍🔧|👨🏾‍🔧|👨🏽‍🔧|👨🏼‍🔧|👨🏻‍🔧
+👨‍💼|👨🏻‍💼|👨🏼‍💼|👨🏽‍💼|👨🏾‍💼|👨🏿‍💼
+👩‍💼|👩🏻‍💼|👩🏼‍💼|👩🏽‍💼|👩🏾‍💼|👩🏿‍💼
 🧑‍🔬|🧑🏻‍🔬|🧑🏼‍🔬|🧑🏽‍🔬|🧑🏾‍🔬|🧑🏿‍🔬
-👩‍🔬|👩🏿‍🔬|👩🏾‍🔬|👩🏽‍🔬|👩🏼‍🔬|👩🏻‍🔬
-👨‍🔬|👨🏿‍🔬|👨🏾‍🔬|👨🏽‍🔬|👨🏼‍🔬|👨🏻‍🔬
+👨‍🔬|👨🏻‍🔬|👨🏼‍🔬|👨🏽‍🔬|👨🏾‍🔬|👨🏿‍🔬
+👩‍🔬|👩🏻‍🔬|👩🏼‍🔬|👩🏽‍🔬|👩🏾‍🔬|👩🏿‍🔬
+🧑‍💻|🧑🏻‍💻|🧑🏼‍💻|🧑🏽‍💻|🧑🏾‍💻|🧑🏿‍💻
+👨‍💻|👨🏻‍💻|👨🏼‍💻|👨🏽‍💻|👨🏾‍💻|👨🏿‍💻
+👩‍💻|👩🏻‍💻|👩🏼‍💻|👩🏽‍💻|👩🏾‍💻|👩🏿‍💻
+🧑‍🎤|🧑🏻‍🎤|🧑🏼‍🎤|🧑🏽‍🎤|🧑🏾‍🎤|🧑🏿‍🎤
+👨‍🎤|👨🏻‍🎤|👨🏼‍🎤|👨🏽‍🎤|👨🏾‍🎤|👨🏿‍🎤
+👩‍🎤|👩🏻‍🎤|👩🏼‍🎤|👩🏽‍🎤|👩🏾‍🎤|👩🏿‍🎤
 🧑‍🎨|🧑🏻‍🎨|🧑🏼‍🎨|🧑🏽‍🎨|🧑🏾‍🎨|🧑🏿‍🎨
-👩‍🎨|👩🏻‍🎨|👩🏼‍🎨|👩🏽‍🎨|👩🏾‍🎨|👩🏿‍🎨
 👨‍🎨|👨🏻‍🎨|👨🏼‍🎨|👨🏽‍🎨|👨🏾‍🎨|👨🏿‍🎨
-🧑‍🚒|🧑🏻‍🚒|🧑🏼‍🚒|🧑🏽‍🚒|🧑🏾‍🚒|🧑🏿‍🚒
-👩‍🚒|👩🏻‍🚒|👩🏼‍🚒|👩🏽‍🚒|👩🏾‍🚒|👩🏿‍🚒
-👨‍🚒|👨🏻‍🚒|👨🏼‍🚒|👨🏽‍🚒|👨🏾‍🚒|👨🏿‍🚒
+👩‍🎨|👩🏻‍🎨|👩🏼‍🎨|👩🏽‍🎨|👩🏾‍🎨|👩🏿‍🎨
 🧑‍✈️|🧑🏻‍✈️|🧑🏼‍✈️|🧑🏽‍✈️|🧑🏾‍✈️|🧑🏿‍✈️
-👩‍✈️|👩🏻‍✈️|👩🏼‍✈️|👩🏽‍✈️|👩🏾‍✈️|👩🏿‍✈️
 👨‍✈️|👨🏻‍✈️|👨🏼‍✈️|👨🏽‍✈️|👨🏾‍✈️|👨🏿‍✈️
+👩‍✈️|👩🏻‍✈️|👩🏼‍✈️|👩🏽‍✈️|👩🏾‍✈️|👩🏿‍✈️
 🧑‍🚀|🧑🏻‍🚀|🧑🏼‍🚀|🧑🏽‍🚀|🧑🏾‍🚀|🧑🏿‍🚀
-👩‍🚀|👩🏻‍🚀|👩🏼‍🚀|👩🏽‍🚀|👩🏾‍🚀|👩🏿‍🚀
 👨‍🚀|👨🏻‍🚀|👨🏼‍🚀|👨🏽‍🚀|👨🏾‍🚀|👨🏿‍🚀
-🧑‍⚖️|🧑🏻‍⚖️|🧑🏼‍⚖️|🧑🏽‍⚖️|🧑🏾‍⚖️|🧑🏿‍⚖️
-👩‍⚖️|👩🏻‍⚖️|👩🏼‍⚖️|👩🏽‍⚖️|👩🏾‍⚖️|👩🏿‍⚖️
-👨‍⚖️|👨🏻‍⚖️|👨🏼‍⚖️|👨🏽‍⚖️|👨🏾‍⚖️|👨🏿‍⚖️
-👰|👰🏻|👰🏼|👰🏽|👰🏾|👰🏿
-👰‍♀️|👰🏻‍♀️|👰🏼‍♀️|👰🏽‍♀️|👰🏾‍♀️|👰🏿‍♀️
-👰‍♂️|👰🏻‍♂️|👰🏼‍♂️|👰🏽‍♂️|👰🏾‍♂️|👰🏿‍♂️
+👩‍🚀|👩🏻‍🚀|👩🏼‍🚀|👩🏽‍🚀|👩🏾‍🚀|👩🏿‍🚀
+🧑‍🚒|🧑🏻‍🚒|🧑🏼‍🚒|🧑🏽‍🚒|🧑🏾‍🚒|🧑🏿‍🚒
+👨‍🚒|👨🏻‍🚒|👨🏼‍🚒|👨🏽‍🚒|👨🏾‍🚒|👨🏿‍🚒
+👩‍🚒|👩🏻‍🚒|👩🏼‍🚒|👩🏽‍🚒|👩🏾‍🚒|👩🏿‍🚒
+👮|👮🏻|👮🏼|👮🏽|👮🏾|👮🏿
+👮‍♂️|👮🏻‍♂️|👮🏼‍♂️|👮🏽‍♂️|👮🏾‍♂️|👮🏿‍♂️
+👮‍♀️|👮🏻‍♀️|👮🏼‍♀️|👮🏽‍♀️|👮🏾‍♀️|👮🏿‍♀️
+🕵️|🕵🏻|🕵🏼|🕵🏽|🕵🏾|🕵🏿
+🕵️‍♂️|🕵🏻‍♂️|🕵🏼‍♂️|🕵🏽‍♂️|🕵🏾‍♂️|🕵🏿‍♂️
+🕵️‍♀️|🕵🏻‍♀️|🕵🏼‍♀️|🕵🏽‍♀️|🕵🏾‍♀️|🕵🏿‍♀️
+💂|💂🏻|💂🏼|💂🏽|💂🏾|💂🏿
+💂‍♂️|💂🏻‍♂️|💂🏼‍♂️|💂🏽‍♂️|💂🏾‍♂️|💂🏿‍♂️
+💂‍♀️|💂🏻‍♀️|💂🏼‍♀️|💂🏽‍♀️|💂🏾‍♀️|💂🏿‍♀️
+🥷|🥷🏻|🥷🏼|🥷🏽|🥷🏾|🥷🏿
+👷|👷🏻|👷🏼|👷🏽|👷🏾|👷🏿
+👷‍♂️|👷🏻‍♂️|👷🏼‍♂️|👷🏽‍♂️|👷🏾‍♂️|👷🏿‍♂️
+👷‍♀️|👷🏻‍♀️|👷🏼‍♀️|👷🏽‍♀️|👷🏾‍♀️|👷🏿‍♀️
+🫅|🫅🏻|🫅🏼|🫅🏽|🫅🏾|🫅🏿
+🤴|🤴🏻|🤴🏼|🤴🏽|🤴🏾|🤴🏿
+👸|👸🏻|👸🏼|👸🏽|👸🏾|👸🏿
+👳|👳🏻|👳🏼|👳🏽|👳🏾|👳🏿
+👳‍♂️|👳🏻‍♂️|👳🏼‍♂️|👳🏽‍♂️|👳🏾‍♂️|👳🏿‍♂️
+👳‍♀️|👳🏻‍♀️|👳🏼‍♀️|👳🏽‍♀️|👳🏾‍♀️|👳🏿‍♀️
+👲|👲🏻|👲🏼|👲🏽|👲🏾|👲🏿
+🧕|🧕🏻|🧕🏼|🧕🏽|🧕🏾|🧕🏿
 🤵|🤵🏻|🤵🏼|🤵🏽|🤵🏾|🤵🏿
-🤵‍♀️|🤵🏻‍♀️|🤵🏼‍♀️|🤵🏽‍♀️|🤵🏾‍♀️|🤵🏿‍♀️
 🤵‍♂️|🤵🏻‍♂️|🤵🏼‍♂️|🤵🏽‍♂️|🤵🏾‍♂️|🤵🏿‍♂️
-👸|👸🏻|👸🏼|👸🏽|👸🏾|👸🏿
-🤴|🤴🏻|🤴🏼|🤴🏽|🤴🏾|🤴🏿
+🤵‍♀️|🤵🏻‍♀️|🤵🏼‍♀️|🤵🏽‍♀️|🤵🏾‍♀️|🤵🏿‍♀️
+👰|👰🏻|👰🏼|👰🏽|👰🏾|👰🏿
+👰‍♂️|👰🏻‍♂️|👰🏼‍♂️|👰🏽‍♂️|👰🏾‍♂️|👰🏿‍♂️
+👰‍♀️|👰🏻‍♀️|👰🏼‍♀️|👰🏽‍♀️|👰🏾‍♀️|👰🏿‍♀️
+🤰|🤰🏻|🤰🏼|🤰🏽|🤰🏾|🤰🏿
+🫃|🫃🏻|🫃🏼|🫃🏽|🫃🏾|🫃🏿
+🫄|🫄🏻|🫄🏼|🫄🏽|🫄🏾|🫄🏿
+🤱|🤱🏻|🤱🏼|🤱🏽|🤱🏾|🤱🏿
+👩‍🍼|👩🏻‍🍼|👩🏼‍🍼|👩🏽‍🍼|👩🏾‍🍼|👩🏿‍🍼
+👨‍🍼|👨🏻‍🍼|👨🏼‍🍼|👨🏽‍🍼|👨🏾‍🍼|👨🏿‍🍼
+🧑‍🍼|🧑🏻‍🍼|🧑🏼‍🍼|🧑🏽‍🍼|🧑🏾‍🍼|🧑🏿‍🍼
+👼|👼🏻|👼🏼|👼🏽|👼🏾|👼🏿
+🎅|🎅🏻|🎅🏼|🎅🏽|🎅🏾|🎅🏿
+🤶|🤶🏻|🤶🏼|🤶🏽|🤶🏾|🤶🏿
+🧑‍🎄|🧑🏻‍🎄|🧑🏼‍🎄|🧑🏽‍🎄|🧑🏾‍🎄|🧑🏿‍🎄
 🦸|🦸🏻|🦸🏼|🦸🏽|🦸🏾|🦸🏿
-🦸‍♀️|🦸🏻‍♀️|🦸🏼‍♀️|🦸🏽‍♀️|🦸🏾‍♀️|🦸🏿‍♀️
 🦸‍♂️|🦸🏻‍♂️|🦸🏼‍♂️|🦸🏽‍♂️|🦸🏾‍♂️|🦸🏿‍♂️
+🦸‍♀️|🦸🏻‍♀️|🦸🏼‍♀️|🦸🏽‍♀️|🦸🏾‍♀️|🦸🏿‍♀️
 🦹|🦹🏻|🦹🏼|🦹🏽|🦹🏾|🦹🏿
-🦹‍♀️|🦹🏻‍♀️|🦹🏼‍♀️|🦹🏽‍♀️|🦹🏾‍♀️|🦹🏿‍♀️
 🦹‍♂️|🦹🏻‍♂️|🦹🏼‍♂️|🦹🏽‍♂️|🦹🏾‍♂️|🦹🏿‍♂️
-🥷|🥷🏻|🥷🏼|🥷🏽|🥷🏾|🥷🏿
-🧑‍🎄|🧑🏻‍🎄|🧑🏼‍🎄|🧑🏽‍🎄|🧑🏾‍🎄|🧑🏿‍🎄
-🤶|🤶🏻|🤶🏼|🤶🏽|🤶🏾|🤶🏿
-🎅|🎅🏻|🎅🏼|🎅🏽|🎅🏾|🎅🏿
+🦹‍♀️|🦹🏻‍♀️|🦹🏼‍♀️|🦹🏽‍♀️|🦹🏾‍♀️|🦹🏿‍♀️
 🧙|🧙🏻|🧙🏼|🧙🏽|🧙🏾|🧙🏿
-🧙‍♀️|🧙🏻‍♀️|🧙🏼‍♀️|🧙🏽‍♀️|🧙🏾‍♀️|🧙🏿‍♀️
 🧙‍♂️|🧙🏻‍♂️|🧙🏼‍♂️|🧙🏽‍♂️|🧙🏾‍♂️|🧙🏿‍♂️
-🧝|🧝🏻|🧝🏼|🧝🏽|🧝🏾|🧝🏿
-🧝‍♀️|🧝🏻‍♀️|🧝🏼‍♀️|🧝🏽‍♀️|🧝🏾‍♀️|🧝🏿‍♀️
-🧝‍♂️|🧝🏻‍♂️|🧝🏼‍♂️|🧝🏽‍♂️|🧝🏾‍♂️|🧝🏿‍♂️
+🧙‍♀️|🧙🏻‍♀️|🧙🏼‍♀️|🧙🏽‍♀️|🧙🏾‍♀️|🧙🏿‍♀️
+🧚|🧚🏻|🧚🏼|🧚🏽|🧚🏾|🧚🏿
+🧚‍♂️|🧚🏻‍♂️|🧚🏼‍♂️|🧚🏽‍♂️|🧚🏾‍♂️|🧚🏿‍♂️
+🧚‍♀️|🧚🏻‍♀️|🧚🏼‍♀️|🧚🏽‍♀️|🧚🏾‍♀️|🧚🏿‍♀️
 🧛|🧛🏻|🧛🏼|🧛🏽|🧛🏾|🧛🏿
-🧛‍♀️|🧛🏻‍♀️|🧛🏼‍♀️|🧛🏽‍♀️|🧛🏾‍♀️|🧛🏿‍♀️
 🧛‍♂️|🧛🏻‍♂️|🧛🏼‍♂️|🧛🏽‍♂️|🧛🏾‍♂️|🧛🏿‍♂️
+🧛‍♀️|🧛🏻‍♀️|🧛🏼‍♀️|🧛🏽‍♀️|🧛🏾‍♀️|🧛🏿‍♀️
 🧜|🧜🏻|🧜🏼|🧜🏽|🧜🏾|🧜🏿
-🧜‍♀️|🧜🏻‍♀️|🧜🏼‍♀️|🧜🏽‍♀️|🧜🏾‍♀️|🧜🏿‍♀️
 🧜‍♂️|🧜🏻‍♂️|🧜🏼‍♂️|🧜🏽‍♂️|🧜🏾‍♂️|🧜🏿‍♂️
-🧚|🧚🏻|🧚🏼|🧚🏽|🧚🏾|🧚🏿
-🧚‍♀️|🧚🏻‍♀️|🧚🏼‍♀️|🧚🏽‍♀️|🧚🏾‍♀️|🧚🏿‍♀️
-🧚‍♂️|🧚🏻‍♂️|🧚🏼‍♂️|🧚🏽‍♂️|🧚🏾‍♂️|🧚🏿‍♂️
-👼|👼🏻|👼🏼|👼🏽|👼🏾|👼🏿
-🤰|🤰🏻|🤰🏼|🤰🏽|🤰🏾|🤰🏿
-🤱|🤱🏻|🤱🏼|🤱🏽|🤱🏾|🤱🏿
-🧑‍🍼|🧑🏻‍🍼|🧑🏼‍🍼|🧑🏽‍🍼|🧑🏾‍🍼|🧑🏿‍🍼
-👩‍🍼|👩🏻‍🍼|👩🏼‍🍼|👩🏽‍🍼|👩🏾‍🍼|👩🏿‍🍼
-👨‍🍼|👨🏻‍🍼|👨🏼‍🍼|👨🏽‍🍼|👨🏾‍🍼|👨🏿‍🍼
-🙇|🙇🏻|🙇🏼|🙇🏽|🙇🏾|🙇🏿
-🙇‍♀️|🙇🏿‍♀️|🙇🏾‍♀️|🙇🏽‍♀️|🙇🏼‍♀️|🙇🏻‍♀️
-🙇‍♂️|🙇🏿‍♂️|🙇🏾‍♂️|🙇🏽‍♂️|🙇🏼‍♂️|🙇🏻‍♂️
-💁|💁🏻|💁🏼|💁🏽|💁🏾|💁🏿
-💁‍♀️|💁🏿‍♀️|💁🏾‍♀️|💁🏽‍♀️|💁🏼‍♀️|💁🏻‍♀️
-💁‍♂️|💁🏿‍♂️|💁🏾‍♂️|💁🏽‍♂️|💁🏼‍♂️|💁🏻‍♂️
-🙅|🙅🏻|🙅🏼|🙅🏽|🙅🏾|🙅🏿
-🙅‍♀️|🙅🏿‍♀️|🙅🏾‍♀️|🙅🏽‍♀️|🙅🏼‍♀️|🙅🏻‍♀️
-🙅‍♂️|🙅🏿‍♂️|🙅🏾‍♂️|🙅🏽‍♂️|🙅🏼‍♂️|🙅🏻‍♂️
-🙆|🙆🏻|🙆🏼|🙆🏽|🙆🏾|🙆🏿
-🙆‍♀️|🙆🏿‍♀️|🙆🏾‍♀️|🙆🏽‍♀️|🙆🏼‍♀️|🙆🏻‍♀️
-🙆‍♂️|🙆🏿‍♂️|🙆🏾‍♂️|🙆🏽‍♂️|🙆🏼‍♂️|🙆🏻‍♂️
-🙋|🙋🏻|🙋🏼|🙋🏽|🙋🏾|🙋🏿
-🙋‍♀️|🙋🏿‍♀️|🙋🏾‍♀️|🙋🏽‍♀️|🙋🏼‍♀️|🙋🏻‍♀️
-🙋‍♂️|🙋🏿‍♂️|🙋🏾‍♂️|🙋🏽‍♂️|🙋🏼‍♂️|🙋🏻‍♂️
-🧏|🧏🏻|🧏🏼|🧏🏽|🧏🏾|🧏🏿
-🧏‍♀️|🧏🏻‍♀️|🧏🏼‍♀️|🧏🏽‍♀️|🧏🏾‍♀️|🧏🏿‍♀️
-🧏‍♂️|🧏🏻‍♂️|🧏🏼‍♂️|🧏🏽‍♂️|🧏🏾‍♂️|🧏🏿‍♂️
-🤦|🤦🏻|🤦🏼|🤦🏽|🤦🏾|🤦🏿
-🤦‍♀️|🤦🏿‍♀️|🤦🏾‍♀️|🤦🏽‍♀️|🤦🏼‍♀️|🤦🏻‍♀️
-🤦‍♂️|🤦🏿‍♂️|🤦🏾‍♂️|🤦🏽‍♂️|🤦🏼‍♂️|🤦🏻‍♂️
-🤷|🤷🏻|🤷🏼|🤷🏽|🤷🏾|🤷🏿
-🤷‍♀️|🤷🏿‍♀️|🤷🏾‍♀️|🤷🏽‍♀️|🤷🏼‍♀️|🤷🏻‍♀️
-🤷‍♂️|🤷🏿‍♂️|🤷🏾‍♂️|🤷🏽‍♂️|🤷🏼‍♂️|🤷🏻‍♂️
-🙎|🙎🏻|🙎🏼|🙎🏽|🙎🏾|🙎🏿
-🙎‍♀️|🙎🏿‍♀️|🙎🏾‍♀️|🙎🏽‍♀️|🙎🏼‍♀️|🙎🏻‍♀️
-🙎‍♂️|🙎🏿‍♂️|🙎🏾‍♂️|🙎🏽‍♂️|🙎🏼‍♂️|🙎🏻‍♂️
-🙍|🙍🏻|🙍🏼|🙍🏽|🙍🏾|🙍🏿
-🙍‍♀️|🙍🏿‍♀️|🙍🏾‍♀️|🙍🏽‍♀️|🙍🏼‍♀️|🙍🏻‍♀️
-🙍‍♂️|🙍🏿‍♂️|🙍🏾‍♂️|🙍🏽‍♂️|🙍🏼‍♂️|🙍🏻‍♂️
-💇|💇🏻|💇🏼|💇🏽|💇🏾|💇🏿
-💇‍♀️|💇🏿‍♀️|💇🏾‍♀️|💇🏽‍♀️|💇🏼‍♀️|💇🏻‍♀️
-💇‍♂️|💇🏿‍♂️|💇🏾‍♂️|💇🏽‍♂️|💇🏼‍♂️|💇🏻‍♂️
+🧜‍♀️|🧜🏻‍♀️|🧜🏼‍♀️|🧜🏽‍♀️|🧜🏾‍♀️|🧜🏿‍♀️
+🧝|🧝🏻|🧝🏼|🧝🏽|🧝🏾|🧝🏿
+🧝‍♂️|🧝🏻‍♂️|🧝🏼‍♂️|🧝🏽‍♂️|🧝🏾‍♂️|🧝🏿‍♂️
+🧝‍♀️|🧝🏻‍♀️|🧝🏼‍♀️|🧝🏽‍♀️|🧝🏾‍♀️|🧝🏿‍♀️
 💆|💆🏻|💆🏼|💆🏽|💆🏾|💆🏿
-💆‍♀️|💆🏿‍♀️|💆🏾‍♀️|💆🏽‍♀️|💆🏼‍♀️|💆🏻‍♀️
-💆‍♂️|💆🏿‍♂️|💆🏾‍♂️|💆🏽‍♂️|💆🏼‍♂️|💆🏻‍♂️
-🧖|🧖🏻|🧖🏼|🧖🏽|🧖🏾|🧖🏿
-🧖‍♀️|🧖🏻‍♀️|🧖🏼‍♀️|🧖🏽‍♀️|🧖🏾‍♀️|🧖🏿‍♀️
-🧖‍♂️|🧖🏻‍♂️|🧖🏼‍♂️|🧖🏽‍♂️|🧖🏾‍♂️|🧖🏿‍♂️
-💅|💅🏻|💅🏼|💅🏽|💅🏾|💅🏿
-🤳|🤳🏻|🤳🏼|🤳🏽|🤳🏾|🤳🏿
-💃|💃🏻|💃🏼|💃🏽|💃🏾|💃🏿
-🕺|🕺🏻|🕺🏼|🕺🏽|🕺🏾|🕺🏿
-🕴️|🕴🏻|🕴🏼|🕴🏽|🕴🏾|🕴🏿
-🧑‍🦽|🧑🏻‍🦽|🧑🏼‍🦽|🧑🏽‍🦽|🧑🏾‍🦽|🧑🏿‍🦽
-👩‍🦽|👩🏻‍🦽|👩🏼‍🦽|👩🏽‍🦽|👩🏾‍🦽|👩🏿‍🦽
-👨‍🦽|👨🏻‍🦽|👨🏼‍🦽|👨🏽‍🦽|👨🏾‍🦽|👨🏿‍🦽
-🧑‍🦼|🧑🏻‍🦼|🧑🏼‍🦼|🧑🏽‍🦼|🧑🏾‍🦼|🧑🏿‍🦼
-👩‍🦼|👩🏻‍🦼|👩🏼‍🦼|👩🏽‍🦼|👩🏾‍🦼|👩🏿‍🦼
-👨‍🦼|👨🏻‍🦼|👨🏼‍🦼|👨🏽‍🦼|👨🏾‍🦼|👨🏿‍🦼
+💆‍♂️|💆🏻‍♂️|💆🏼‍♂️|💆🏽‍♂️|💆🏾‍♂️|💆🏿‍♂️
+💆‍♀️|💆🏻‍♀️|💆🏼‍♀️|💆🏽‍♀️|💆🏾‍♀️|💆🏿‍♀️
+💇|💇🏻|💇🏼|💇🏽|💇🏾|💇🏿
+💇‍♂️|💇🏻‍♂️|💇🏼‍♂️|💇🏽‍♂️|💇🏾‍♂️|💇🏿‍♂️
+💇‍♀️|💇🏻‍♀️|💇🏼‍♀️|💇🏽‍♀️|💇🏾‍♀️|💇🏿‍♀️
 🚶|🚶🏻|🚶🏼|🚶🏽|🚶🏾|🚶🏿
-🚶‍♀️|🚶🏿‍♀️|🚶🏾‍♀️|🚶🏽‍♀️|🚶🏼‍♀️|🚶🏻‍♀
-🚶‍♂️|🚶🏿‍♂️|🚶🏾‍♂️|🚶🏽‍♂️|🚶🏼‍♂️|🚶🏻‍♂
-🧑‍🦯|🧑🏻‍🦯|🧑🏼‍🦯|🧑🏽‍🦯|🧑🏾‍🦯|🧑🏿‍🦯
-👩‍🦯|👩🏻‍🦯|👩🏼‍🦯|👩🏽‍🦯|👩🏾‍🦯|👩🏿‍🦯
-👨‍🦯|👨🏻‍🦯|👨🏼‍🦯|👨🏽‍🦯|👨🏾‍🦯|👨🏿‍🦯
+🚶‍♂️|🚶🏻‍♂️|🚶🏼‍♂️|🚶🏽‍♂️|🚶🏾‍♂️|🚶🏿‍♂️
+🚶‍♀️|🚶🏻‍♀️|🚶🏼‍♀️|🚶🏽‍♀️|🚶🏾‍♀️|🚶🏿‍♀️
+🧍|🧍🏻|🧍🏼|🧍🏽|🧍🏾|🧍🏿
+🧍‍♂️|🧍🏻‍♂️|🧍🏼‍♂️|🧍🏽‍♂️|🧍🏾‍♂️|🧍🏿‍♂️
+🧍‍♀️|🧍🏻‍♀️|🧍🏼‍♀️|🧍🏽‍♀️|🧍🏾‍♀️|🧍🏿‍♀️
 🧎|🧎🏻|🧎🏼|🧎🏽|🧎🏾|🧎🏿
-🧎‍♀️|🧎🏻‍♀️|🧎🏼‍♀️|🧎🏽‍♀️|🧎🏾‍♀️|🧎🏿‍♀️
 🧎‍♂️|🧎🏻‍♂️|🧎🏼‍♂️|🧎🏽‍♂️|🧎🏾‍♂️|🧎🏿‍♂️
+🧎‍♀️|🧎🏻‍♀️|🧎🏼‍♀️|🧎🏽‍♀️|🧎🏾‍♀️|🧎🏿‍♀️
+🧑‍🦯|🧑🏻‍🦯|🧑🏼‍🦯|🧑🏽‍🦯|🧑🏾‍🦯|🧑🏿‍🦯
+👨‍🦯|👨🏻‍🦯|👨🏼‍🦯|👨🏽‍🦯|👨🏾‍🦯|👨🏿‍🦯
+👩‍🦯|👩🏻‍🦯|👩🏼‍🦯|👩🏽‍🦯|👩🏾‍🦯|👩🏿‍🦯
+🧑‍🦼|🧑🏻‍🦼|🧑🏼‍🦼|🧑🏽‍🦼|🧑🏾‍🦼|🧑🏿‍🦼
+👨‍🦼|👨🏻‍🦼|👨🏼‍🦼|👨🏽‍🦼|👨🏾‍🦼|👨🏿‍🦼
+👩‍🦼|👩🏻‍🦼|👩🏼‍🦼|👩🏽‍🦼|👩🏾‍🦼|👩🏿‍🦼
+🧑‍🦽|🧑🏻‍🦽|🧑🏼‍🦽|🧑🏽‍🦽|🧑🏾‍🦽|🧑🏿‍🦽
+👨‍🦽|👨🏻‍🦽|👨🏼‍🦽|👨🏽‍🦽|👨🏾‍🦽|👨🏿‍🦽
+👩‍🦽|👩🏻‍🦽|👩🏼‍🦽|👩🏽‍🦽|👩🏾‍🦽|👩🏿‍🦽
 🏃|🏃🏻|🏃🏼|🏃🏽|🏃🏾|🏃🏿
-🏃‍♀️|🏃🏿‍♀️|🏃🏾‍♀️|🏃🏽‍♀️|🏃🏼‍♀️|🏃🏻‍♀️
-🏃‍♂️|🏃🏿‍♂️|🏃🏾‍♂️|🏃🏽‍♂️|🏃🏼‍♂️|🏃🏻‍♂️
-🧍|🧍🏻|🧍🏼|🧍🏽|🧍🏾|🧍🏿
-🧍‍♀️|🧍🏻‍♀️|🧍🏼‍♀️|🧍🏽‍♀️|🧍🏾‍♀️|🧍🏿‍♀️
-🧍‍♂️|🧍🏻‍♂️|🧍🏼‍♂️|🧍🏽‍♂️|🧍🏾‍♂️|🧍🏿‍♂️
+🏃‍♂️|🏃🏻‍♂️|🏃🏼‍♂️|🏃🏽‍♂️|🏃🏾‍♂️|🏃🏿‍♂️
+🏃‍♀️|🏃🏻‍♀️|🏃🏼‍♀️|🏃🏽‍♀️|🏃🏾‍♀️|🏃🏿‍♀️
+💃|💃🏻|💃🏼|💃🏽|💃🏾|💃🏿
+🕺|🕺🏻|🕺🏼|🕺🏽|🕺🏾|🕺🏿
+🕴️|🕴🏻|🕴🏼|🕴🏽|🕴🏾|🕴🏿
+🧖|🧖🏻|🧖🏼|🧖🏽|🧖🏾|🧖🏿
+🧖‍♂️|🧖🏻‍♂️|🧖🏼‍♂️|🧖🏽‍♂️|🧖🏾‍♂️|🧖🏿‍♂️
+🧖‍♀️|🧖🏻‍♀️|🧖🏼‍♀️|🧖🏽‍♀️|🧖🏾‍♀️|🧖🏿‍♀️
+🧗|🧗🏻|🧗🏼|🧗🏽|🧗🏾|🧗🏿
+🧗‍♂️|🧗🏻‍♂️|🧗🏼‍♂️|🧗🏽‍♂️|🧗🏾‍♂️|🧗🏿‍♂️
+🧗‍♀️|🧗🏻‍♀️|🧗🏼‍♀️|🧗🏽‍♀️|🧗🏾‍♀️|🧗🏿‍♀️
+🏇|🏇🏻|🏇🏼|🏇🏽|🏇🏾|🏇🏿
+🏌️|🏌🏻|🏌🏼|🏌🏽|🏌🏾|🏌🏿
+🏌️‍♂️|🏌🏻‍♂️|🏌🏼‍♂️|🏌🏽‍♂️|🏌🏾‍♂️|🏌🏿‍♂️
+🏌️‍♀️|🏌🏻‍♀️|🏌🏼‍♀️|🏌🏽‍♀️|🏌🏾‍♀️|🏌🏿‍♀️
+🏄|🏄🏻|🏄🏼|🏄🏽|🏄🏾|🏄🏿
+🏄‍♂️|🏄🏻‍♂️|🏄🏼‍♂️|🏄🏽‍♂️|🏄🏾‍♂️|🏄🏿‍♂️
+🏄‍♀️|🏄🏻‍♀️|🏄🏼‍♀️|🏄🏽‍♀️|🏄🏾‍♀️|🏄🏿‍♀️
+🚣|🚣🏻|🚣🏼|🚣🏽|🚣🏾|🚣🏿
+🚣‍♂️|🚣🏻‍♂️|🚣🏼‍♂️|🚣🏽‍♂️|🚣🏾‍♂️|🚣🏿‍♂️
+🚣‍♀️|🚣🏻‍♀️|🚣🏼‍♀️|🚣🏽‍♀️|🚣🏾‍♀️|🚣🏿‍♀️
+🏊|🏊🏻|🏊🏼|🏊🏽|🏊🏾|🏊🏿
+🏊‍♂️|🏊🏻‍♂️|🏊🏼‍♂️|🏊🏽‍♂️|🏊🏾‍♂️|🏊🏿‍♂️
+🏊‍♀️|🏊🏻‍♀️|🏊🏼‍♀️|🏊🏽‍♀️|🏊🏾‍♀️|🏊🏿‍♀️
+⛹️|⛹🏻|⛹🏼|⛹🏽|⛹🏾|⛹🏿
+⛹️‍♂️|⛹🏻‍♂️|⛹🏼‍♂️|⛹🏽‍♂️|⛹🏾‍♂️|⛹🏿‍♂️
+⛹️‍♀️|⛹🏻‍♀️|⛹🏼‍♀️|⛹🏽‍♀️|⛹🏾‍♀️|⛹🏿‍♀️
+🏋️|🏋🏻|🏋🏼|🏋🏽|🏋🏾|🏋🏿
+🏋️‍♂️|🏋🏻‍♂️|🏋🏼‍♂️|🏋🏽‍♂️|🏋🏾‍♂️|🏋🏿‍♂️
+🏋️‍♀️|🏋🏻‍♀️|🏋🏼‍♀️|🏋🏽‍♀️|🏋🏾‍♀️|🏋🏿‍♀️
+🚴|🚴🏻|🚴🏼|🚴🏽|🚴🏾|🚴🏿
+🚴‍♂️|🚴🏻‍♂️|🚴🏼‍♂️|🚴🏽‍♂️|🚴🏾‍♂️|🚴🏿‍♂️
+🚴‍♀️|🚴🏻‍♀️|🚴🏼‍♀️|🚴🏽‍♀️|🚴🏾‍♀️|🚴🏿‍♀️
+🚵|🚵🏻|🚵🏼|🚵🏽|🚵🏾|🚵🏿
+🚵‍♂️|🚵🏻‍♂️|🚵🏼‍♂️|🚵🏽‍♂️|🚵🏾‍♂️|🚵🏿‍♂️
+🚵‍♀️|🚵🏻‍♀️|🚵🏼‍♀️|🚵🏽‍♀️|🚵🏾‍♀️|🚵🏿‍♀️
+🤸|🤸🏻|🤸🏼|🤸🏽|🤸🏾|🤸🏿
+🤸‍♂️|🤸🏻‍♂️|🤸🏼‍♂️|🤸🏽‍♂️|🤸🏾‍♂️|🤸🏿‍♂️
+🤸‍♀️|🤸🏻‍♀️|🤸🏼‍♀️|🤸🏽‍♀️|🤸🏾‍♀️|🤸🏿‍♀️
+🤽|🤽🏻|🤽🏼|🤽🏽|🤽🏾|🤽🏿
+🤽‍♂️|🤽🏻‍♂️|🤽🏼‍♂️|🤽🏽‍♂️|🤽🏾‍♂️|🤽🏿‍♂️
+🤽‍♀️|🤽🏻‍♀️|🤽🏼‍♀️|🤽🏽‍♀️|🤽🏾‍♀️|🤽🏿‍♀️
+🤾|🤾🏻|🤾🏼|🤾🏽|🤾🏾|🤾🏿
+🤾‍♂️|🤾🏻‍♂️|🤾🏼‍♂️|🤾🏽‍♂️|🤾🏾‍♂️|🤾🏿‍♂️
+🤾‍♀️|🤾🏻‍♀️|🤾🏼‍♀️|🤾🏽‍♀️|🤾🏾‍♀️|🤾🏿‍♀️
+🤹|🤹🏻|🤹🏼|🤹🏽|🤹🏾|🤹🏿
+🤹‍♂️|🤹🏻‍♂️|🤹🏼‍♂️|🤹🏽‍♂️|🤹🏾‍♂️|🤹🏿‍♂️
+🤹‍♀️|🤹🏻‍♀️|🤹🏼‍♀️|🤹🏽‍♀️|🤹🏾‍♀️|🤹🏿‍♀️
+🧘|🧘🏻|🧘🏼|🧘🏽|🧘🏾|🧘🏿
+🧘‍♂️|🧘🏻‍♂️|🧘🏼‍♂️|🧘🏽‍♂️|🧘🏾‍♂️|🧘🏿‍♂️
+🧘‍♀️|🧘🏻‍♀️|🧘🏼‍♀️|🧘🏽‍♀️|🧘🏾‍♀️|🧘🏿‍♀️
+🛀|🛀🏻|🛀🏼|🛀🏽|🛀🏾|🛀🏿
 🧑‍🤝‍🧑|🧑🏻‍🤝‍🧑🏻|🧑🏼‍🤝‍🧑🏼|🧑🏽‍🤝‍🧑🏽|🧑🏾‍🤝‍🧑🏾|🧑🏿‍🤝‍🧑🏿
-👫|👫🏻|👫🏼|👫🏽|👫🏾|👫🏿
 👭|👭🏻|👭🏼|👭🏽|👭🏾|👭🏿
+👫|👫🏻|👫🏼|👫🏽|👫🏾|👫🏿
 👬|👬🏻|👬🏼|👬🏽|👬🏾|👬🏿
-💑|💑🏻|💑🏼|💑🏽|💑🏾|💑🏿
-👩‍❤️‍👨|👩🏻‍❤️‍👨🏻|👩🏼‍❤️‍👨🏼|👩🏽‍❤️‍👨🏽|👩🏾‍❤️‍👨🏾|👩🏿‍❤️‍👨🏿
-👩‍❤️‍👩|👩🏻‍❤️‍👩🏻|👩🏼‍❤️‍👩🏼|👩🏽‍❤️‍👩🏽|👩🏾‍❤️‍👩🏾|👩🏿‍❤️‍👩🏿
-👨‍❤️‍👨|👨🏻‍❤️‍👨🏻|👨🏼‍❤️‍👨🏼|👨🏽‍❤️‍👨🏽|👨🏾‍❤️‍👨🏾|👨🏿‍❤️‍👨🏿
 💏|💏🏻|💏🏼|💏🏽|💏🏾|💏🏿
 👩‍❤️‍💋‍👨|👩🏻‍❤️‍💋‍👨🏻|👩🏼‍❤️‍💋‍👨🏼|👩🏽‍❤️‍💋‍👨🏽|👩🏾‍❤️‍💋‍👨🏾|👩🏿‍❤️‍💋‍👨🏿
-👩‍❤️‍💋‍👩|👩🏻‍❤️‍💋‍👩🏻|👩🏼‍❤️‍💋‍👩🏼|👩🏽‍❤️‍💋‍👩🏽|👩🏾‍❤️‍💋‍👩🏾|👩🏿‍❤️‍💋‍👩🏿
 👨‍❤️‍💋‍👨|👨🏻‍❤️‍💋‍👨🏻|👨🏼‍❤️‍💋‍👨🏼|👨🏽‍❤️‍💋‍👨🏽|👨🏾‍❤️‍💋‍👨🏾|👨🏿‍❤️‍💋‍👨🏿
-🏋|🏋🏻|🏋🏼|🏋🏽|🏋🏾|🏋🏿
-🏋️‍♀️|🏋🏿‍♀️|🏋🏾‍♀️|🏋🏽‍♀️|🏋🏼‍♀️|🏋🏻‍♀️
-🏋️‍♂️|🏋🏿‍♂️|🏋🏾‍♂️|🏋🏽‍♂️|🏋🏼‍♂️|🏋🏻‍♂️
-🤸|🤸🏻|🤸🏼|🤸🏽|🤸🏾|🤸🏿
-🤸‍♀️|🤸🏿‍♀️|🤸🏾‍♀️|🤸🏽‍♀️|🤸🏼‍♀️|🤸🏻‍♀️
-🤸‍♂️|🤸🏿‍♂️|🤸🏾‍♂️|🤸🏽‍♂️|🤸🏼‍♂️|🤸🏻‍♂️
-⛹|⛹🏻|⛹🏼|⛹🏽|⛹🏾|⛹🏿
-⛹️‍♀️|⛹🏿‍♀️|⛹🏾‍♀️|⛹🏽‍♀️|⛹🏼‍♀️|⛹🏻‍♀️
-⛹️‍♂️|⛹🏿‍♂️|⛹🏾‍♂️|⛹🏽‍♂️|⛹🏼‍♂️|⛹🏻‍♂️
-🤾|🤾🏻|🤾🏼|🤾🏽|🤾🏾|🤾🏿
-🤾‍♀️|🤾🏿‍♀️|🤾🏾‍♀️|🤾🏽‍♀️|🤾🏼‍♀️|🤾🏻‍♀️
-🤾‍♂️|🤾🏿‍♂️|🤾🏾‍♂️|🤾🏽‍♂️|🤾🏼‍♂️|🤾🏻‍♂️
-🏌|🏌🏻|🏌🏼|🏌🏽|🏌🏾|🏌🏿
-🏌️‍♀️|🏌🏻‍♀️|🏌🏼‍♀️|🏌🏽‍♀️|🏌🏾‍♀️|🏌🏿‍♀️
-🏌️‍♂️|🏌🏻‍♂️|🏌🏼‍♂️|🏌🏽‍♂️|🏌🏾‍♂️|🏌🏿‍♂️
-🏇|🏇🏻|🏇🏼|🏇🏽|🏇🏾|🏇🏿
-🧘|🧘🏻|🧘🏼|🧘🏽|🧘🏾|🧘🏿
-🧘‍♀️|🧘🏻‍♀️|🧘🏼‍♀️|🧘🏽‍♀️|🧘🏾‍♀️|🧘🏿‍♀️
-🧘‍♂️|🧘🏻‍♂️|🧘🏼‍♂️|🧘🏽‍♂️|🧘🏾‍♂️|🧘🏿‍♂️
-🏄|🏄🏻|🏄🏼|🏄🏽|🏄🏾|🏄🏿
-🏄‍♀️|🏄🏿‍♀️|🏄🏾‍♀️|🏄🏽‍♀️|🏄🏼‍♀️|🏄🏻‍♀️
-🏄‍♂️|🏄🏿‍♂️|🏄🏾‍♂️|🏄🏽‍♂️|🏄🏼‍♂️|🏄🏻‍♂️
-🏊|🏊🏻|🏊🏼|🏊🏽|🏊🏾|🏊🏿
-🏊‍♀️|🏊🏿‍♀️|🏊🏾‍♀️|🏊🏽‍♀️|🏊🏼‍♀️|🏊🏻‍♀️
-🏊‍♂️|🏊🏿‍♂️|🏊🏾‍♂️|🏊🏽‍♂️|🏊🏼‍♂️|🏊🏻‍♂️
-🤽|🤽🏻|🤽🏼|🤽🏽|🤽🏾|🤽🏿
-🤽‍♀️|🤽🏿‍♀️|🤽🏾‍♀️|🤽🏽‍♀️|🤽🏼‍♀️|🤽🏻‍♀️
-🤽‍♂️|🤽🏿‍♂️|🤽🏾‍♂️|🤽🏽‍♂️|🤽🏼‍♂️|🤽🏻‍♂️
-🚣|🚣🏻|🚣🏼|🚣🏽|🚣🏾|🚣🏿
-🚣‍♀️|🚣🏿‍♀️|🚣🏾‍♀️|🚣🏽‍♀️|🚣🏼‍♀️|🚣🏻‍♀️
-🚣‍♂️|🚣🏿‍♂️|🚣🏾‍♂️|🚣🏽‍♂️|🚣🏼‍♂️|🚣🏻‍♂️
-🧗|🧗🏻|🧗🏼|🧗🏽|🧗🏾|🧗🏿
-🧗‍♀️|🧗🏻‍♀️|🧗🏼‍♀️|🧗🏽‍♀️|🧗🏾‍♀️|🧗🏿‍♀️
-🧗‍♂️|🧗🏻‍♂️|🧗🏼‍♂️|🧗🏽‍♂️|🧗🏾‍♂️|🧗🏿‍♂️
-🚵|🚵🏻|🚵🏼|🚵🏽|🚵🏾|🚵🏿
-🚵‍♀️|🚵🏿‍♀️|🚵🏾‍♀️|🚵🏽‍♀️|🚵🏼‍♀️|🚵🏻‍♀️
-🚵‍♂️|🚵🏿‍♂️|🚵🏾‍♂️|🚵🏽‍♂️|🚵🏼‍♂️|🚵🏻‍♂️
-🚴|🚴🏻|🚴🏼|🚴🏽|🚴🏾|🚴🏿
-🚴‍♀️|🚴🏿‍♀️|🚴🏾‍♀️|🚴🏽‍♀️|🚴🏼‍♀️|🚴🏻‍♀️
-🚴‍♂️|🚴🏿‍♂️|🚴🏾‍♂️|🚴🏽‍♂️|🚴🏼‍♂️|🚴🏻‍♂️
-🤹|🤹🏻|🤹🏼|🤹🏽|🤹🏾|🤹🏿
-🤹‍♀️|🤹🏿‍♀️|🤹🏾‍♀️|🤹🏽‍♀️|🤹🏼‍♀️|🤹🏻‍♀️
-🤹‍♂️|🤹🏿‍♂️|🤹🏾‍♂️|🤹🏽‍♂️|🤹🏼‍♂️|🤹🏻‍♂️
-🛀|🛀🏻|🛀🏼|🛀🏽|🛀🏾|🛀🏿
+👩‍❤️‍💋‍👩|👩🏻‍❤️‍💋‍👩🏻|👩🏼‍❤️‍💋‍👩🏼|👩🏽‍❤️‍💋‍👩🏽|👩🏾‍❤️‍💋‍👩🏾|👩🏿‍❤️‍💋‍👩🏿
+💑|💑🏻|💑🏼|💑🏽|💑🏾|💑🏿
+👩‍❤️‍👨|👩🏻‍❤️‍👨🏻|👩🏼‍❤️‍👨🏼|👩🏽‍❤️‍👨🏽|👩🏾‍❤️‍👨🏾|👩🏿‍❤️‍👨🏿
+👨‍❤️‍👨|👨🏻‍❤️‍👨🏻|👨🏼‍❤️‍👨🏼|👨🏽‍❤️‍👨🏽|👨🏾‍❤️‍👨🏾|👨🏿‍❤️‍👨🏿
+👩‍❤️‍👩|👩🏻‍❤️‍👩🏻|👩🏼‍❤️‍👩🏼|👩🏽‍❤️‍👩🏽|👩🏾‍❤️‍👩🏾|👩🏿‍❤️‍👩🏿

+ 6 - 6
app/assets/emojis/search-index/en.csv

@@ -257,7 +257,7 @@
 💤|comic|good night|sleep|zzz
 👋|hand|wave|waving
 🤚|backhand|raised back of hand
-🖐|finger|hand with fingers splayed|splayed
+🖐|finger|hand with fingers splayed|splayed
 ✋|hand|high 5|high five|raised hand
 🖖|finger|hand|spock|vulcan salute
 👌|hand|ok|perfect
@@ -403,7 +403,7 @@
 👮|cop|officer|police
 👮‍♂️|cop|man|officer|police|male
 👮‍♀️|cop|officer|police|woman|female
-🕵|detective|sleuth|spy|investigator
+🕵|detective|sleuth|spy|investigator
 🕵️‍♂️|detective|man|sleuth|spy|investigator|male
 🕵️‍♀️|detective|sleuth|spy|woman|investigator|female
 💂|guard
@@ -505,7 +505,7 @@
 🏇|horse|jockey|racehorse|racing
 ⛷️|skier|snow
 🏂|ski|snowboarder
-🏌|ball|person golfing|golfer
+🏌|ball|person golfing|golfer
 🏌️‍♂️|man golfing|golfer|male
 🏌️‍♀️|woman golfing|golfer|female
 🏄|person surfing|surfing|surfer
@@ -517,10 +517,10 @@
 🏊|person swimming|swimmer
 🏊‍♂️|man swimming|swimmer
 🏊‍♀️|woman swimming|swimmer
-⛹|ball|person bouncing ball
+⛹|ball|person bouncing ball
 ⛹️‍♂️|ball|man bouncing ball|male
 ⛹️‍♀️|ball|woman bouncing ball|female
-🏋|lifter|person lifting weights|weightlifter
+🏋|lifter|person lifting weights|weightlifter
 🏋️‍♂️|man lifting weights|weight lifter|male
 🏋️‍♀️|weight lifter|woman lifting weights|female
 🚴|bicycle|biking|cyclist|person biking|person riding a bike
@@ -1430,7 +1430,7 @@
 📶|antenna bars|bar|cell|mobile|phone
 📳|cell|mobile|mode|phone|telephone|vibration|vibrate
 📴|cell|mobile|off|phone|telephone
-⚧|transgender symbol
+⚧|transgender symbol
 ♾️|forever|infinity|unbounded|universal|eternal
 ‼️|!!|bangbang|double exclamation mark|exclamation|mark|punctuation
 💱|bank|currency|exchange|money

+ 6 - 6
app/assets/emojis/search-index/es.csv

@@ -257,7 +257,7 @@
 💤|dormir|símbolo de sueño|sueño|zzz|cómics|dormido|emoción|estar durmiendo|roncar|comic
 👋|agitar|mano saludando|saludar|saludo
 🤚|levantado|mano|palma de la mano|dorso de la mano alzado|dorso de la mano levantada|dorso de la mano saludando|levantada
-🖐|abierta|dedos|yo|mano abierta|palma de la mano|separados
+🖐|abierta|dedos|yo|mano abierta|palma de la mano|separados
 ✋|levantada|abierta|mano levantada|papel|mano abierta|palma de la mano
 🖖|mano|saludo|spock|vulcano|dedos|star|trek
 👌|aprobación|mano|ok|señal de aprobación con la mano|gesto|señal de ok
@@ -403,7 +403,7 @@
 👮|agente de policía|personas|policía|cara|oficial
 👮‍♂️|agente de policía hombre|hombre|policía|oficial hombre
 👮‍♀️|agente de policía mujer|mujer|policía|oficial mujer
-🕵|cara|detective|espía|agente|búsqueda|investigación|persona|investigador|investigar|lupa
+🕵|cara|detective|espía|agente|búsqueda|investigación|persona|investigador|investigar|lupa
 🕵️‍♂️|agente|detective|espía|hombre|investigador
 🕵️‍♀️|agente|detective|espía|investigadora|mujer
 💂|personas|cara|hombre|inglés|sombrero|guardia real británica|guardia real con sombrero|guardia real inglesa
@@ -505,7 +505,7 @@
 🏇|caballo de carreras|carrera de caballos|carreras|jinete|deporte
 ⛷️|esquí|esquiador|nieve|deporte|esquiar
 🏂|deporte|practicante de snowboard|snowboard|nieve|tabla|persona en snowboard
-🏌|golfista|pelota|jugador de golf|persona jugando al golf|persona jugando golf
+🏌|golfista|pelota|jugador de golf|persona jugando al golf|persona jugando golf
 🏌️‍♂️|golf|hombre jugando al golf|jugador
 🏌️‍♀️|golf|jugadora|mujer jugando al golf
 🏄|deporte|persona haciendo surf|surfista
@@ -517,10 +517,10 @@
 🏊|deporte|nadar|natación|persona nadando
 🏊‍♂️|hombre nadando|nadar|natación
 🏊‍♀️|mujer nadando|nadar|natación
-⛹|balón|botar|pelota|persona botando un balón|básquet|deporte|persona con una pelota
+⛹|balón|botar|pelota|persona botando un balón|básquet|deporte|persona con una pelota
 ⛹️‍♂️|balón|botar|hombre botando un balón|pelota
 ⛹️‍♀️|balón|botar|mujer botando un balón|pelota
-🏋|halterofilia|levantador|persona levantando pesas|pesas|peso|deporte|fisicoculturismo|gimnasio|gym
+🏋|halterofilia|levantador|persona levantando pesas|pesas|peso|deporte|fisicoculturismo|gimnasio|gym
 🏋️‍♂️|halterofilia|hombre levantando pesas|levantador de pesas|pesas
 🏋️‍♀️|halterofilia|levantadora de pesas|mujer levantando pesas|pesas
 🚴|bicicleta|ciclismo|ciclista|persona en bicicleta
@@ -1430,7 +1430,7 @@
 📶|antena|barras de cobertura|celular|móvil|señal|teléfono|cobertura
 📳|modo vibración|móvil|teléfono celular|vibración|celular
 📴|apagado|móvil|teléfono celular|celular
-⚧|símbolo de transgénero|transgénero
+⚧|símbolo de transgénero|transgénero
 ♾️|ilimitado|infinito|siempre|universal|eterno
 ‼️|!!|exclamación doble|puntuación|sorpresa
 💱|cambio de divisas|dinero|moneda|banco|divisas

+ 6 - 6
app/assets/emojis/search-index/fr.csv

@@ -257,7 +257,7 @@
 💤|dormir|endormi|ronfler|zzz
 👋|coucou|main|signe de la main
 🤚|dos de main levée|levée|main
-🖐|main levée doigts écartés|doigts écartés|main aux doigts écartés
+🖐|main levée doigts écartés|doigts écartés|main aux doigts écartés
 ✋|feuille|main levée|levée
 🖖|main|salut vulcain|vulcain|spock
 👌|geste|main|ok
@@ -403,7 +403,7 @@
 👮|flic|officier de police|police|policier
 👮‍♂️|flic|homme|officier|police|policier
 👮‍♀️|femme|flic|officier|police|policière
-🕵|détective|enquêteur|espion
+🕵|détective|enquêteur|espion
 🕵️‍♂️|détective|enquêteur|espion|homme
 🕵️‍♀️|détective|enquêteuse|espionne|femme|enquêtrice
 💂|garde
@@ -505,7 +505,7 @@
 🏇|cheval|course hippique|jockey|sport|chevaux|hippique
 ⛷️|neige|skieur|sport|skier
 🏂|neige|snowboardeur|sport|planche de neige|planchiste|ski
-🏌|balle|golf|joueur de golf
+🏌|balle|golf|joueur de golf
 🏌️‍♂️|golfeur|homme
 🏌️‍♀️|femme|golfeuse
 🏄|personne faisant du surf|sport|personne qui fait du surf|surf
@@ -517,10 +517,10 @@
 🏊|personne nageant|sport|nager|natation|personne qui nage
 🏊‍♂️|homme|nageur
 🏊‍♀️|femme|nageuse
-⛹|personne avec ballon|sport|ballon|personne qui fait rebondir un ballon|rebondir
+⛹|personne avec ballon|sport|ballon|personne qui fait rebondir un ballon|rebondir
 ⛹️‍♂️|ballon|homme avec ballon|homme qui fait rebondir un ballon|rebondir
 ⛹️‍♀️|ballon|femme avec ballon|femme qui fait rebondir un ballon|rebondir
-🏋|haltérophile|sport|haltère|haltérophilie|levée|lever|poids
+🏋|haltérophile|sport|haltère|haltérophilie|levée|lever|poids
 🏋️‍♂️|haltérophile|homme|haltère|levée|lever|poids
 🏋️‍♀️|femme|haltérophile|haltère|levée|lever|poids
 🚴|cycliste|vélo|bicyclette|cyclisme|faire du vélo|personne
@@ -1430,7 +1430,7 @@
 📶|barres de réseau|portable|réseau|téléphone|barres de signal
 📳|mode vibreur|portable|téléphone|vibreur|cellulaire|mobile|vibration
 📴|portable|téléphone éteint|cellulaire|éteint|mobile
-⚧|symbole de la communauté transgenre|transgenre|symbole transgenre
+⚧|symbole de la communauté transgenre|transgenre|symbole transgenre
 ♾️|éternité|illimité|infini|universel
 ‼️|!!|double point d’exclamation|exclamation|point
 💱|argent|banque|change|conversion de devise|devise

+ 6 - 6
app/assets/emojis/search-index/hu.csv

@@ -257,7 +257,7 @@
 💤|alvás|képregény
 👋|integetés|integető kéz|kéz|test
 🤚|felemelt|kézfej
-🖐|felemelt kéz szétálló ujjakkal|kéz|szétálló|test|ujj
+🖐|felemelt kéz szétálló ujjakkal|kéz|szétálló|test|ujj
 ✋|felemelt kéz|kéz|test
 🖖|kéz|spock|star trek-üdvözlet|test|ujj|vulcan
 👌|kéz|ok jel|oké|rendben|test
@@ -403,7 +403,7 @@
 👮|rendőrség|zsaru
 👮‍♂️|férfi|rendőr|zsaru
 👮‍♀️|nő|rendőrnő|zsaru
-🕵|detektív|kopó
+🕵|detektív|kopó
 🕵️‍♂️|detektív|férfi|kém|nyomozó
 🕵️‍♀️|detektív|kémnő|nő|nyomozónő
 💂|gárdista|őrség
@@ -505,7 +505,7 @@
 🏇|lóverseny|versenyló|zsoké
 ⛷️|hó|síelés|síelő|téli sportok
 🏂|hódeszka|snowboardozó|téli sportok
-🏌|golfozó|golfütő|labda
+🏌|golfozó|golfütő|labda
 🏌️‍♂️|férfi|golfozó férfi
 🏌️‍♀️|golfozó nő|nő
 🏄|szörföző
@@ -517,10 +517,10 @@
 🏊|úszás|úszó
 🏊‍♂️|férfi|úszás|úszó
 🏊‍♀️|nő|úszás|úszó
-⛹|ember labdával|labda|sportoló
+⛹|ember labdával|labda|sportoló
 ⛹️‍♂️|férfi labdával|labda
 ⛹️‍♀️|labda|nő labdával
-🏋|sportoló|súlyemelő
+🏋|sportoló|súlyemelő
 🏋️‍♂️|férfi|súlyemelő
 🏋️‍♀️|nő|súlyemelő
 🚴|biciklista|kerékpáros|sportoló
@@ -1430,7 +1430,7 @@
 📶|antennasávok|mobiltelefon|térerő
 📳|mobiltelefon|rádiótelefon|rezgés|rezgő mód|telefon|tiltott
 📴|kikapcsolva|mobiltelefon|rádiótelefon|telefon|tiltott
-⚧|transznemű szimbólum
+⚧|transznemű szimbólum
 ♾️|korlátlan|örökké|univerzális|végtelen
 ‼️|dupla felkiáltójel|felkiáltás|írásjel|központozás
 💱|átváltás|bank|pénzváltás|pénzváltó|valuta

+ 6 - 6
app/assets/emojis/search-index/it.csv

@@ -257,7 +257,7 @@
 💤|dormire|russare|sonno|zzz
 👋|mano che saluta|salutare
 🤚|alzata|dorso mano alzata|mano
-🖐|cinque dita|mano alzata|mano aperta|palmo della mano
+🖐|cinque dita|mano alzata|mano aperta|palmo della mano
 ✋|alzata|carta|mano
 🖖|saluto vulcaniano|spock|star trek
 👌|mano che fa ok|ok|segno di ok
@@ -403,7 +403,7 @@
 👮|agente di polizia|persone|polizia|poliziotto
 👮‍♂️|agente|divisa|polizia|poliziotto uomo|uomo
 👮‍♀️|agente|divisa|donna|polizia|poliziotta
-🕵|detective|investigatore|mistero
+🕵|detective|investigatore|mistero
 🕵️‍♂️|detective|investigatore|spia|uomo
 🕵️‍♀️|detective|donna|investigatrice|spia
 💂|guardia
@@ -505,7 +505,7 @@
 🏇|cavallo da corsa|corse|fantino|ippica
 ⛷️|neve|sciatore|settimana bianca|sport
 🏂|persona sullo snowboard|snowboard|sport|uomo sullo snowboard
-🏌|buca|giocatore di golf|mazza|pallina|persona che gioca a golf|tiro
+🏌|buca|giocatore di golf|mazza|pallina|persona che gioca a golf|tiro
 🏌️‍♂️|golfista uomo|mazza|sport|uomo
 🏌️‍♀️|donna|golfista donna|mazza|sport
 🏄|persona che fa surf|sport
@@ -517,10 +517,10 @@
 🏊|nuotare|nuoto|persona che nuota|sport
 🏊‍♂️|nuotatore|nuoto|piscina|sport|uomo
 🏊‍♀️|donna|nuotatrice|nuoto|piscina|sport
-⛹|basket|campo|giocatore|palla|persona che fa rimbalzare una palla
+⛹|basket|campo|giocatore|palla|persona che fa rimbalzare una palla
 ⛹️‍♂️|palla|sport|uomo che fa rimbalzare una palla
 ⛹️‍♀️|donna che fa rimbalzare una palla|palla|sport
-🏋|bilanciere|persona che solleva pesi|pesi massimi|sollevamento pesi|sport
+🏋|bilanciere|persona che solleva pesi|pesi massimi|sollevamento pesi|sport
 🏋️‍♂️|bilanciere|pesi|sport|uomo che solleva pesi
 🏋️‍♀️|bilanciere|donna che solleva pesi|pesi|sport
 🚴|bici|ciclista
@@ -1430,7 +1430,7 @@
 📶|antenna con tacche intensità segnale|cellulare|segnale|telefono
 📳|cellulare|modalità telefono|modalità vibrazione
 📴|cellulare|spento|telefono
-⚧|simbolo transgender|transgender
+⚧|simbolo transgender|transgender
 ♾️|eternità|illimitato|per sempre|simbolo dell’infinito|universale
 ‼️|doppio punto esclamativo|esclamazione|punteggiatura|punto esclamativo
 💱|cambio di valuta|denaro|valuta

+ 6 - 6
app/assets/emojis/search-index/nl.csv

@@ -257,7 +257,7 @@
 💤|slaapsymbool|slapen|strip
 👋|hand|zwaaiende hand
 🤚|achterkant van opgeheven hand|handrug|opgestoken
-🖐|gespreid|hand|opgeheven hand met uitgestoken vingers|vinger
+🖐|gespreid|hand|opgeheven hand met uitgestoken vingers|vinger
 ✋|hand|opgeheven hand
 🖖|hand|spock|vinger|vulcaanse groet|vulcan
 👌|hand|ok-handgebaar
@@ -403,7 +403,7 @@
 👮|agent|politieagent
 👮‍♂️|agent|man|politieman
 👮‍♀️|agent|politievrouw|vrouw
-🕵|detective|speurder|spion
+🕵|detective|speurder|spion
 🕵️‍♂️|detective|mannelijke detective|speurder|spion
 🕵️‍♀️|detective|speurder|spionne|vrouwelijke detective
 💂|wachter
@@ -505,7 +505,7 @@
 🏇|jockey op renpaard|paard|racen|renpaard
 ⛷️|skiër|sneeuw
 🏂|ski|sneeuw|snowboarder
-🏌|bal|golfer
+🏌|bal|golfer
 🏌️‍♂️|golfende man|man
 🏌️‍♀️|golfende vrouw|vrouw
 🏄|surfen|surfer
@@ -517,10 +517,10 @@
 🏊|zwemmende persoon
 🏊‍♂️|man|zwemmende man
 🏊‍♀️|vrouw|zwemmende vrouw
-⛹|bal|basketbalspeler
+⛹|bal|basketbalspeler
 ⛹️‍♂️|bal|basketballer|man
 ⛹️‍♀️|bal|basketbalster|vrouw
-🏋|gewichtheffer|heffer
+🏋|gewichtheffer|heffer
 🏋️‍♂️|gewichtheffen|mannelijke gewichtheffer
 🏋️‍♀️|gewichtheffen|vrouwelijke gewichtheffer
 🚴|fietsende persoon|wielrennen|wielrenner
@@ -1430,7 +1430,7 @@
 📶|antenne|mobiel signaal|mobiele telefoon|streep|telefoon
 📳|mobiele telefoon|modus|telefoon|trilstand
 📴|mobiele telefoon uit|telefoon|uit
-⚧|transgendersymbool
+⚧|transgendersymbool
 ♾️|eeuwig|grenzeloos|oneindig|universeel
 ‼️|!!|dubbel uitroepteken|interpunctie|teken|uitroep
 💱|geld|valuta|wisselen

+ 6 - 6
app/assets/emojis/search-index/no.csv

@@ -257,7 +257,7 @@
 💤|følelse|snorker|sover|soving|tegneserie|zzz
 👋|hånd|vinkende hånd|vinking
 🤚|håndbak|løftet
-🖐|finger|flat hånd med spredte fingre|hånd|spredt
+🖐|finger|flat hånd med spredte fingre|hånd|spredt
 ✋|flat hånd|håndflate|hevet hånd
 🖖|finger|hånd|spock|vulcan-hilsen
 👌|hånd|ok-hånd|tegn
@@ -403,7 +403,7 @@
 👮|betjent|menneske|politibetjent
 👮‍♂️|betjent|mannlig politibetjent|politibetjent
 👮‍♀️|betjent|kvinnelig politibetjent|politibetjent
-🕵|detektiv|spion
+🕵|detektiv|spion
 🕵️‍♂️|etterforske|forbrytelse|mannlig detektiv|privatdetektiv
 🕵️‍♀️|etterforske|forbrytelse|kvinnelig detektiv|privatdetektiv
 💂|gardist|menneske|vakt
@@ -505,7 +505,7 @@
 🏇|galopp|hesteveddeløp|jockey|løp|sport|veddeløpshest
 ⛷️|skiløper|snø
 🏂|snøbrettkjøring|snowboarder|snowboarding|vintersport
-🏌|ball|golfspiller
+🏌|ball|golfspiller
 🏌️‍♂️|golfspiller|mannlig golfspiller
 🏌️‍♀️|golfspiller|kvinnelig golfspiller
 🏄|sport|surfer|surfing
@@ -517,10 +517,10 @@
 🏊|sport|svømmer|svømming
 🏊‍♂️|mannlig svømmer|svømme|svømming
 🏊‍♀️|kvinnelig svømmer|svømme|svømming
-⛹|ballsport|person med ball
+⛹|ballsport|person med ball
 ⛹️‍♂️|ballsport|mann med ball
 ⛹️‍♀️|ballsport|kvinne med ball
-🏋|løfter|vektløfter
+🏋|løfter|vektløfter
 🏋️‍♂️|bodybuilding|mannlig vektløfter|vektløfter
 🏋️‍♀️|bodybuilding|kvinnelig vektløfter|vektløfting
 🚴|sykkel|sykle|syklist
@@ -1430,7 +1430,7 @@
 📶|antenne|mobilsignaler|signalstyrke|telefon
 📳|mobiltelefon|modus|vibrasjon|vibreringsmodus
 📴|av|mobiltelefon
-⚧|transperson-symbol
+⚧|transperson-symbol
 ♾️|for alltid|grenseløs|uendelig|universal|universell
 ‼️|!!|bangbang|dobbelt utropstegn|tegnsetting|utropstegn
 💱|bank|penger|valutaveksling|veksling

+ 2666 - 2678
app/assets/emojis/search-index/orders.csv

@@ -72,106 +72,112 @@
 😁|4
 😆|5
 😅|6
-😂|8
 🤣|7
-☺️|20
-😊|13
-😇|14
+😂|8
 🙂|9
 🙃|10
+🫠|11
 😉|12
-😌|50
-🥲|23
-😍|16
+😊|13
+😇|14
 🥰|15
+😍|16
+🤩|17
 😘|18
 😗|19
-😙|22
+☺️|20
 😚|21
+😙|22
+🥲|23
 😋|24
 😛|25
-😝|28
 😜|26
 🤪|27
-🤨|38
-🧐|72
-🤓|71
-😎|70
-🤩|17
-🥳|68
-😏|44
-😒|45
-😞|94
-😔|51
-😟|75
-😕|73
-🙁|76
-😣|93
-😖|92
-😫|97
-😩|96
-🥺|82
-😢|89
-😭|90
-😤|99
-😮‍💨|48
-😠|101
-😡|100
-🤬|102
-🤯|66
-😳|81
-😶‍🌫️|43
-🥵|61
-🥶|62
-😱|91
-😨|86
-😰|87
-😥|88
-😓|95
+😝|28
+🤑|29
 🤗|30
-🤔|35
 🤭|31
-🥱|98
+🫢|32
+🫣|33
 🤫|34
-🤥|49
-😶|41
+🤔|35
+🫡|36
+🤐|37
+🤨|38
 😐|39
 😑|40
-😬|47
+😶|41
+🫥|42
+😶‍🌫️|43
+😏|44
+😒|45
 🙄|46
-😯|79
-😦|84
-😧|85
-😮|78
-😲|80
-😴|54
-🤤|53
+😬|47
+😮‍💨|48
+🤥|49
+😌|50
+😔|51
 😪|52
-😵|64
-😵‍💫|65
-🤐|37
-🥴|63
-🤢|58
-🤮|59
-🤧|60
+🤤|53
+😴|54
 😷|55
 🤒|56
 🤕|57
-🤑|29
+🤢|58
+🤮|59
+🤧|60
+🥵|61
+🥶|62
+🥴|63
+😵|64
+😵‍💫|65
+🤯|66
 🤠|67
+🥳|68
 🥸|69
+😎|70
+🤓|71
+🧐|72
+😕|73
+🫤|74
+😟|75
+🙁|76
+😮|78
+😯|79
+😲|80
+😳|81
+🥺|82
+🥹|83
+😦|84
+😧|85
+😨|86
+😰|87
+😥|88
+😢|89
+😭|90
+😱|91
+😖|92
+😣|93
+😞|94
+😓|95
+😩|96
+😫|97
+🥱|98
+😤|99
+😡|100
+😠|101
+🤬|102
 😈|103
 👿|104
+💀|105
+💩|107
+🤡|108
 👹|109
 👺|110
-🤡|108
-💩|107
 👻|111
-💀|105
 👽|112
 👾|113
 🤖|114
-🎃|2803
 😺|115
 😸|116
 😹|117
@@ -181,37 +187,74 @@
 🙀|121
 😿|122
 😾|123
-🤲|374
-🤲🏻|375
-🤲🏼|376
-🤲🏽|377
-🤲🏾|378
-🤲🏿|379
-👐|368
-👐🏻|369
-👐🏼|370
-👐🏽|371
-👐🏾|372
-👐🏿|373
-🙌|356
-🙌🏻|357
-🙌🏼|358
-🙌🏽|359
-🙌🏾|360
-🙌🏿|361
-👏|350
-👏🏻|351
-👏🏼|352
-👏🏽|353
-👏🏾|354
-👏🏿|355
-🫠|11
-🫢|32
-🫣|33
-🫡|36
-🫥|42
-🫤|74
-🥹|83
+🙈|124
+🙉|125
+🙊|126
+💋|127
+💌|128
+💘|129
+💝|130
+💖|131
+💗|132
+💓|133
+💞|134
+💕|135
+💟|136
+💔|138
+❤️‍🔥|139
+❤️‍🩹|140
+🧡|142
+💛|143
+💚|144
+💙|145
+💜|146
+🤎|147
+🖤|148
+🤍|149
+💯|150
+💢|151
+💥|152
+💫|153
+💦|154
+💨|155
+🕳️|156
+💣|157
+💬|158
+👁️‍🗨️|159
+🗨️|160
+🗯️|161
+💭|162
+💤|163
+👋|164
+👋🏻|165
+👋🏼|166
+👋🏽|167
+👋🏾|168
+👋🏿|169
+🤚|170
+🤚🏻|171
+🤚🏼|172
+🤚🏽|173
+🤚🏾|174
+🤚🏿|175
+🖐️|176
+🖐🏻|177
+🖐🏼|178
+🖐🏽|179
+🖐🏾|180
+🖐🏿|181
+✋|182
+✋🏻|183
+✋🏼|184
+✋🏽|185
+✋🏾|186
+✋🏿|187
+🖖|188
+🖖🏻|189
+🖖🏼|190
+🖖🏽|191
+🖖🏾|192
+🖖🏿|193
 🫱|194
 🫱🏻|195
 🫱🏼|196
@@ -236,116 +279,42 @@
 🫴🏽|215
 🫴🏾|216
 🫴🏿|217
-🫰|248
-🫰🏻|249
-🫰🏼|250
-🫰🏽|251
-🫰🏾|252
-🫰🏿|253
-🫵|308
-🫵🏻|309
-🫵🏼|310
-🫵🏽|311
-🫵🏾|312
-🫵🏿|313
-🫶|362
-🫶🏻|363
-🫶🏼|364
-🫶🏽|365
-🫶🏾|366
-🫶🏿|367
-🤝|380
-🤝🏻|381
-🤝🏼|382
-🤝🏽|383
-🤝🏾|384
-🤝🏿|385
-🫦|477
-🫅|1192
-🫅🏻|1193
-🫅🏼|1194
-🫅🏽|1195
-🫅🏾|1196
-🫅🏿|1197
-🫃|1282
-🫃🏻|1283
-🫃🏼|1284
-🫃🏽|1285
-🫃🏾|1286
-🫃🏿|1287
-🫄|1288
-🫄🏻|1289
-🫄🏼|1290
-🫄🏽|1291
-🫄🏾|1292
-🫄🏿|1293
-🧌|1474
-🪸|2410
-🪷|2430
-🪹|2451
-🪺|2452
-🫘|2486
-🫗|2572
-🫙|2583
-🛝|2646
-🛞|2695
-🛟|2702
-🪬|2864
-🪩|2872
-🪫|2968
-🩼|3109
-🩻|3111
-🫧|3132
-🪪|3143
-🟰|3247
-👍|314
-👍🏻|315
-👍🏼|316
-👍🏽|317
-👍🏾|318
-👍🏿|319
-👎|320
-👎🏻|321
-👎🏼|322
-👎🏽|323
-👎🏾|324
-👎🏿|325
-👊|332
-👊🏻|333
-👊🏼|334
-👊🏽|335
-👊🏾|336
-👊🏿|337
-✊|326
-✊🏻|327
-✊🏼|328
-✊🏽|329
-✊🏾|330
-✊🏿|331
-🤛|338
-🤛🏻|339
-🤛🏼|340
-🤛🏽|341
-🤛🏾|342
-🤛🏿|343
-🤜|344
-🤜🏻|345
-🤜🏼|346
-🤜🏽|347
-🤜🏾|348
-🤜🏿|349
-🤞|242
-🤞🏻|243
-🤞🏼|244
-🤞🏽|245
-🤞🏾|246
-🤞🏿|247
+👌|218
+👌🏻|219
+👌🏼|220
+👌🏽|221
+👌🏾|222
+👌🏿|223
+🤌|224
+🤌🏻|225
+🤌🏼|226
+🤌🏽|227
+🤌🏾|228
+🤌🏿|229
+🤏|230
+🤏🏻|231
+🤏🏼|232
+🤏🏽|233
+🤏🏾|234
+🤏🏿|235
 ✌️|236
 ✌🏻|237
 ✌🏼|238
 ✌🏽|239
 ✌🏾|240
 ✌🏿|241
+🤞|242
+🤞🏻|243
+🤞🏼|244
+🤞🏽|245
+🤞🏾|246
+🤞🏿|247
+🫰|248
+🫰🏻|249
+🫰🏼|250
+🫰🏽|251
+🫰🏾|252
+🫰🏿|253
 🤟|254
 🤟🏻|255
 🤟🏼|256
@@ -358,24 +327,12 @@
 🤘🏽|263
 🤘🏾|264
 🤘🏿|265
-👌|218
-👌🏻|219
-👌🏼|220
-👌🏽|221
-👌🏾|222
-👌🏿|223
-🤏|230
-🤏🏻|231
-🤏🏼|232
-🤏🏽|233
-🤏🏾|234
-🤏🏿|235
-🤌|224
-🤌🏼|226
-🤌🏻|225
-🤌🏽|227
-🤌🏾|228
-🤌🏿|229
+🤙|266
+🤙🏻|267
+🤙🏼|268
+🤙🏽|269
+🤙🏾|270
+🤙🏿|271
 👈|272
 👈🏻|273
 👈🏼|274
@@ -394,6 +351,12 @@
 👆🏽|287
 👆🏾|288
 👆🏿|289
+🖕|290
+🖕🏻|291
+🖕🏼|292
+🖕🏽|293
+🖕🏾|294
+🖕🏿|295
 👇|296
 👇🏻|297
 👇🏼|298
@@ -406,126 +369,162 @@
 ☝🏽|305
 ☝🏾|306
 ☝🏿|307
-✋|182
-✋🏻|183
-✋🏼|184
-✋🏽|185
-✋🏾|186
-✋🏿|187
-🤚|170
-🤚🏻|171
-🤚🏼|172
-🤚🏽|173
-🤚🏾|174
-🤚🏿|175
-🖐|176
-🖐🏻|177
-🖐🏼|178
-🖐🏽|179
-🖐🏾|180
-🖐🏿|181
-🖖|188
-🖖🏻|189
-🖖🏼|190
-🖖🏽|191
-🖖🏾|192
-🖖🏿|193
-👋|164
-👋🏻|165
-👋🏼|166
-👋🏽|167
-👋🏾|168
-👋🏿|169
-🤙|266
-🤙🏻|267
-🤙🏼|268
-🤙🏽|269
-🤙🏾|270
-🤙🏿|271
-💪|430
-💪🏻|431
-💪🏼|432
-💪🏽|433
-💪🏾|434
-💪🏿|435
-🦾|436
-🖕|290
-🖕🏻|291
-🖕🏼|292
-🖕🏽|293
-🖕🏾|294
-🖕🏿|295
-✍️|412
-✍🏻|413
-✍🏼|414
-✍🏽|415
-✍🏾|416
-✍🏿|417
-🙏|406
-🙏🏻|407
-🙏🏼|408
-🙏🏽|409
-🙏🏾|410
-🙏🏿|411
-🦶|444
-🦶🏻|445
-🦶🏼|446
-🦶🏽|447
-🦶🏾|448
-🦶🏿|449
-🦵|438
-🦵🏻|439
-🦵🏼|440
-🦵🏽|441
-🦵🏾|442
-🦵🏿|443
-🦿|437
-💄|2931
-💋|127
-👄|476
-🦷|471
-👅|475
-👂|450
-👂🏻|451
-👂🏼|452
-👂🏽|453
-👂🏾|454
-👂🏿|455
-🦻|456
-🦻🏻|457
-🦻🏼|458
-🦻🏽|459
-🦻🏾|460
-🦻🏿|461
-👃|462
-👃🏻|463
-👃🏼|464
-👃🏽|465
-👃🏾|466
-👃🏿|467
-👣|2299
-👁️|474
-👀|473
-🧠|468
-🫀|469
-🫁|470
-🦴|472
-🗣️|2295
-👤|2296
-👥|2297
-🫂|2298
-👶|478
+🫵|308
+🫵🏻|309
+🫵🏼|310
+🫵🏽|311
+🫵🏾|312
+🫵🏿|313
+👍|314
+👍🏻|315
+👍🏼|316
+👍🏽|317
+👍🏾|318
+👍🏿|319
+👎|320
+👎🏻|321
+👎🏼|322
+👎🏽|323
+👎🏾|324
+👎🏿|325
+✊|326
+✊🏻|327
+✊🏼|328
+✊🏽|329
+✊🏾|330
+✊🏿|331
+👊|332
+👊🏻|333
+👊🏼|334
+👊🏽|335
+👊🏾|336
+👊🏿|337
+🤛|338
+🤛🏻|339
+🤛🏼|340
+🤛🏽|341
+🤛🏾|342
+🤛🏿|343
+🤜|344
+🤜🏻|345
+🤜🏼|346
+🤜🏽|347
+🤜🏾|348
+🤜🏿|349
+👏|350
+👏🏻|351
+👏🏼|352
+👏🏽|353
+👏🏾|354
+👏🏿|355
+🙌|356
+🙌🏻|357
+🙌🏼|358
+🙌🏽|359
+🙌🏾|360
+🙌🏿|361
+🫶|362
+🫶🏻|363
+🫶🏼|364
+🫶🏽|365
+🫶🏾|366
+🫶🏿|367
+👐|368
+👐🏻|369
+👐🏼|370
+👐🏽|371
+👐🏾|372
+👐🏿|373
+🤲|374
+🤲🏻|375
+🤲🏼|376
+🤲🏽|377
+🤲🏾|378
+🤲🏿|379
+🤝|380
+🤝🏻|381
+🤝🏼|382
+🤝🏽|383
+🤝🏾|384
+🤝🏿|385
+🙏|406
+🙏🏻|407
+🙏🏼|408
+🙏🏽|409
+🙏🏾|410
+🙏🏿|411
+✍️|412
+✍🏻|413
+✍🏼|414
+✍🏽|415
+✍🏾|416
+✍🏿|417
+💅|418
+💅🏻|419
+💅🏼|420
+💅🏽|421
+💅🏾|422
+💅🏿|423
+🤳|424
+🤳🏻|425
+🤳🏼|426
+🤳🏽|427
+🤳🏾|428
+🤳🏿|429
+💪|430
+💪🏻|431
+💪🏼|432
+💪🏽|433
+💪🏾|434
+💪🏿|435
+🦾|436
+🦿|437
+🦵|438
+🦵🏻|439
+🦵🏼|440
+🦵🏽|441
+🦵🏾|442
+🦵🏿|443
+🦶|444
+🦶🏻|445
+🦶🏼|446
+🦶🏽|447
+🦶🏾|448
+🦶🏿|449
+👂|450
+👂🏻|451
+👂🏼|452
+👂🏽|453
+👂🏾|454
+👂🏿|455
+🦻|456
+🦻🏻|457
+🦻🏼|458
+🦻🏽|459
+🦻🏾|460
+🦻🏿|461
+👃|462
+👃🏻|463
+👃🏼|464
+👃🏽|465
+👃🏾|466
+👃🏿|467
+🧠|468
+🫀|469
+🫁|470
+🦷|471
+🦴|472
+👀|473
+👁️|474
+👅|475
+👄|476
+🫦|477
+👶|478
 👶🏻|479
 👶🏼|480
 👶🏽|481
 👶🏾|482
 👶🏿|483
-👧|496
-👧🏻|497
-👧🏼|498
-👧🏽|499
-👧🏾|500
-👧🏿|501
 🧒|484
 🧒🏻|485
 🧒🏼|486
@@ -538,138 +537,138 @@
 👦🏽|493
 👦🏾|494
 👦🏿|495
-👩|562
-👩🏻|563
-👩🏼|564
-👩🏽|565
-👩🏾|566
-👩🏿|567
+👧|496
+👧🏻|497
+👧🏼|498
+👧🏽|499
+👧🏾|500
+👧🏿|501
 🧑|502
 🧑🏻|503
 🧑🏼|504
 🧑🏽|505
 🧑🏾|506
 🧑🏿|507
+👱|508
+👱🏻|509
+👱🏼|510
+👱🏽|511
+👱🏾|512
+👱🏿|513
 👨|514
 👨🏻|515
 👨🏼|516
 👨🏽|517
 👨🏾|518
 👨🏿|519
-🧑‍🦱|586
-🧑🏻‍🦱|587
-🧑🏼‍🦱|588
-🧑🏽‍🦱|589
-🧑🏾‍🦱|590
-🧑🏿‍🦱|591
-👩‍🦱|580
-👩🏻‍🦱|581
-👩🏼‍🦱|582
-👩🏽‍🦱|583
-👩🏾‍🦱|584
-👩🏿‍🦱|585
+🧔|520
+🧔🏻|521
+🧔🏼|522
+🧔🏽|523
+🧔🏾|524
+🧔🏿|525
+🧔‍♂️|526
+🧔🏻‍♂️|527
+🧔🏼‍♂️|528
+🧔🏽‍♂️|529
+🧔🏾‍♂️|530
+🧔🏿‍♂️|531
+🧔‍♀️|532
+🧔🏻‍♀️|533
+🧔🏼‍♀️|534
+🧔🏽‍♀️|535
+🧔🏾‍♀️|536
+🧔🏿‍♀️|537
+👨‍🦰|538
+👨🏻‍🦰|539
+👨🏼‍🦰|540
+👨🏽‍🦰|541
+👨🏾‍🦰|542
+👨🏿‍🦰|543
 👨‍🦱|544
 👨🏻‍🦱|545
 👨🏼‍🦱|546
 👨🏽‍🦱|547
 👨🏾‍🦱|548
 👨🏿‍🦱|549
-🧑‍🦰|574
-🧑🏻‍🦰|575
-🧑🏼‍🦰|576
-🧑🏽‍🦰|577
-🧑🏾‍🦰|578
-🧑🏿‍🦰|579
+👨‍🦳|550
+👨🏻‍🦳|551
+👨🏼‍🦳|552
+👨🏽‍🦳|553
+👨🏾‍🦳|554
+👨🏿‍🦳|555
+👨‍🦲|556
+👨🏻‍🦲|557
+👨🏼‍🦲|558
+👨🏽‍🦲|559
+👨🏾‍🦲|560
+👨🏿‍🦲|561
+👩|562
+👩🏻|563
+👩🏼|564
+👩🏽|565
+👩🏾|566
+👩🏿|567
 👩‍🦰|568
 👩🏻‍🦰|569
 👩🏼‍🦰|570
 👩🏽‍🦰|571
 👩🏾‍🦰|572
 👩🏿‍🦰|573
-👨‍🦰|538
-👨🏻‍🦰|539
-👨🏼‍🦰|540
-👨🏽‍🦰|541
-👨🏾‍🦰|542
-👨🏿‍🦰|543
-👱‍♀️|616
-👱🏻‍♀️|617
-👱🏼‍♀️|618
-👱🏽‍♀️|619
-👱🏾‍♀️|620
-👱🏿‍♀️|621
-👱|508
-👱🏻|509
-👱🏼|510
-👱🏽|511
-👱🏾|512
-👱🏿|513
-👱‍♂️|622
-👱🏻‍♂️|623
-👱🏼‍♂️|624
-👱🏽‍♂️|625
-👱🏾‍♂️|626
-👱🏿‍♂️|627
-🧑‍🦳|598
-🧑🏻‍🦳|599
-🧑🏼‍🦳|600
-🧑🏽‍🦳|601
-🧑🏾‍🦳|602
-🧑🏿‍🦳|603
+🧑‍🦰|574
+🧑🏻‍🦰|575
+🧑🏼‍🦰|576
+🧑🏽‍🦰|577
+🧑🏾‍🦰|578
+🧑🏿‍🦰|579
+👩‍🦱|580
+👩🏻‍🦱|581
+👩🏼‍🦱|582
+👩🏽‍🦱|583
+👩🏾‍🦱|584
+👩🏿‍🦱|585
+🧑‍🦱|586
+🧑🏻‍🦱|587
+🧑🏼‍🦱|588
+🧑🏽‍🦱|589
+🧑🏾‍🦱|590
+🧑🏿‍🦱|591
 👩‍🦳|592
 👩🏻‍🦳|593
 👩🏼‍🦳|594
 👩🏽‍🦳|595
 👩🏾‍🦳|596
 👩🏿‍🦳|597
-👨‍🦳|550
-👨🏻‍🦳|551
-👨🏼‍🦳|552
-👨🏽‍🦳|553
-👨🏾‍🦳|554
-👨🏿‍🦳|555
-🧑‍🦲|610
-🧑🏻‍🦲|611
-🧑🏼‍🦲|612
-🧑🏽‍🦲|613
-🧑🏾‍🦲|614
-🧑🏿‍🦲|615
+🧑‍🦳|598
+🧑🏻‍🦳|599
+🧑🏼‍🦳|600
+🧑🏽‍🦳|601
+🧑🏾‍🦳|602
+🧑🏿‍🦳|603
 👩‍🦲|604
 👩🏻‍🦲|605
 👩🏼‍🦲|606
 👩🏽‍🦲|607
 👩🏾‍🦲|608
 👩🏿‍🦲|609
-👨‍🦲|556
-👨🏻‍🦲|557
-👨🏼‍🦲|558
-👨🏽‍🦲|559
-👨🏾‍🦲|560
-👨🏿‍🦲|561
-🧔|520
-🧔🏻|521
-🧔🏼|522
-🧔🏽|523
-🧔🏾|524
-🧔🏿|525
-🧔‍♂️|526
-🧔🏻‍♂️|527
-🧔🏼‍♂️|528
-🧔🏽‍♂️|529
-🧔🏾‍♂️|530
-🧔🏿‍♂️|531
-🧔‍♀️|532
-🧔🏻‍♀️|533
-🧔🏼‍♀️|534
-🧔🏽‍♀️|535
-🧔🏾‍♀️|536
-🧔🏿‍♀️|537
-👵|640
-👵🏻|641
-👵🏼|642
-👵🏽|643
-👵🏾|644
-👵🏿|645
+🧑‍🦲|610
+🧑🏻‍🦲|611
+🧑🏼‍🦲|612
+🧑🏽‍🦲|613
+🧑🏾‍🦲|614
+🧑🏿‍🦲|615
+👱‍♀️|616
+👱🏻‍♀️|617
+👱🏼‍♀️|618
+👱🏽‍♀️|619
+👱🏾‍♀️|620
+👱🏿‍♀️|621
+👱‍♂️|622
+👱🏻‍♂️|623
+👱🏼‍♂️|624
+👱🏽‍♂️|625
+👱🏾‍♂️|626
+👱🏿‍♂️|627
 🧓|628
 🧓🏻|629
 🧓🏼|630
@@ -682,2658 +681,2647 @@
 👴🏽|637
 👴🏾|638
 👴🏿|639
-👲|1228
-👲🏻|1229
-👲🏼|1230
-👲🏽|1231
-👲🏾|1232
-👲🏿|1233
-👳|1210
-👳🏻|1211
-👳🏼|1212
-👳🏽|1213
-👳🏾|1214
-👳🏿|1215
-👳‍♀️|1222
-👳🏻‍♀️|1223
-👳🏼‍♀️|1224
-👳🏽‍♀️|1225
-👳🏾‍♀️|1226
-👳🏿‍♀️|1227
-👳‍♂️|1216
-👳🏻‍♂️|1217
-👳🏼‍♂️|1218
-👳🏽‍♂️|1219
-👳🏾‍♂️|1220
-👳🏿‍♂️|1221
-🧕|1234
-🧕🏻|1235
-🧕🏼|1236
-🧕🏽|1237
-🧕🏾|1238
-🧕🏿|1239
-👮|1114
-👮🏻|1115
-👮🏼|1116
-👮🏽|1117
-👮🏾|1118
-👮🏿|1119
-👮‍♀️|1126
-👮🏻‍♀️|1127
-👮🏼‍♀️|1128
-👮🏽‍♀️|1129
-👮🏾‍♀️|1130
-👮🏿‍♀️|1131
-👮‍♂️|1120
-👮🏻‍♂️|1121
-👮🏼‍♂️|1122
-👮🏽‍♂️|1123
-👮🏾‍♂️|1124
-👮🏿‍♂️|1125
-👷|1174
-👷🏻|1175
-👷🏼|1176
-👷🏽|1177
-👷🏾|1178
-👷🏿|1179
-👷‍♀️|1186
-👷🏻‍♀️|1187
-👷🏼‍♀️|1188
-👷🏽‍♀️|1189
-👷🏾‍♀️|1190
-👷🏿‍♀️|1191
-👷‍♂️|1180
-👷🏻‍♂️|1181
-👷🏼‍♂️|1182
-👷🏽‍♂️|1183
-👷🏾‍♂️|1184
-👷🏿‍♂️|1185
-💂|1150
-💂🏻|1151
-💂🏼|1152
-💂🏽|1153
-💂🏾|1154
-💂🏿|1155
-💂‍♀️|1162
-💂🏻‍♀️|1163
-💂🏼‍♀️|1164
-💂🏽‍♀️|1165
-💂🏾‍♀️|1166
-💂🏿‍♀️|1167
-💂‍♂️|1156
-💂🏻‍♂️|1157
-💂🏼‍♂️|1158
-💂🏽‍♂️|1159
-💂🏾‍♂️|1160
-💂🏿‍♂️|1161
-🕵|1132
-🕵🏻|1133
-🕵🏼|1134
-🕵🏽|1135
-🕵🏾|1136
-🕵🏿|1137
-🕵️‍♀️|1144
-🕵🏻‍♀️|1145
-🕵🏼‍♀️|1146
-🕵🏽‍♀️|1147
-🕵🏾‍♀️|1148
-🕵🏿‍♀️|1149
-🕵️‍♂️|1138
-🕵🏻‍♂️|1139
-🕵🏼‍♂️|1140
-🕵🏽‍♂️|1141
-🕵🏾‍♂️|1142
-🕵🏿‍♂️|1143
-🧑‍⚕️|826
-🧑🏻‍⚕️|827
-🧑🏼‍⚕️|828
-🧑🏽‍⚕️|829
-🧑🏾‍⚕️|830
-🧑🏿‍⚕️|831
-👩‍⚕️|838
-👩🏻‍⚕️|839
-👩🏼‍⚕️|840
-👩🏽‍⚕️|841
-👩🏾‍⚕️|842
-👩🏿‍⚕️|843
-👨‍⚕️|832
-👨🏻‍⚕️|833
-👨🏼‍⚕️|834
-👨🏽‍⚕️|835
-👨🏾‍⚕️|836
-👨🏿‍⚕️|837
-🧑‍🌾|898
-🧑🏻‍🌾|899
-🧑🏼‍🌾|900
-🧑🏽‍🌾|901
-🧑🏾‍🌾|902
-🧑🏿‍🌾|903
-👩‍🌾|910
-👩🏻‍🌾|911
-👩🏼‍🌾|912
-👩🏽‍🌾|913
-👩🏾‍🌾|914
-👩🏿‍🌾|915
-👨‍🌾|904
-👨🏻‍🌾|905
-👨🏼‍🌾|906
-👨🏽‍🌾|907
-👨🏾‍🌾|908
-👨🏿‍🌾|909
-🧑‍🍳|916
-🧑🏻‍🍳|917
-🧑🏼‍🍳|918
-🧑🏽‍🍳|919
-🧑🏾‍🍳|920
-🧑🏿‍🍳|921
-👩‍🍳|928
-👩🏻‍🍳|929
-👩🏼‍🍳|930
-👩🏽‍🍳|931
-👩🏾‍🍳|932
-👩🏿‍🍳|933
-👨‍🍳|922
-👨🏻‍🍳|923
-👨🏼‍🍳|924
-👨🏽‍🍳|925
-👨🏾‍🍳|926
-👨🏿‍🍳|927
-🧑‍🎓|844
-🧑🏻‍🎓|845
-🧑🏼‍🎓|846
-🧑🏽‍🎓|847
-🧑🏾‍🎓|848
-🧑🏿‍🎓|849
-👩‍🎓|856
-👩🏻‍🎓|857
-👩🏼‍🎓|858
-👩🏽‍🎓|859
-👩🏾‍🎓|860
-👩🏿‍🎓|861
+👵|640
+👵🏻|641
+👵🏼|642
+👵🏽|643
+👵🏾|644
+👵🏿|645
+🙍|646
+🙍🏻|647
+🙍🏼|648
+🙍🏽|649
+🙍🏾|650
+🙍🏿|651
+🙍‍♂️|652
+🙍🏻‍♂️|653
+🙍🏼‍♂️|654
+🙍🏽‍♂️|655
+🙍🏾‍♂️|656
+🙍🏿‍♂️|657
+🙍‍♀️|658
+🙍🏻‍♀️|659
+🙍🏼‍♀️|660
+🙍🏽‍♀️|661
+🙍🏾‍♀️|662
+🙍🏿‍♀️|663
+🙎|664
+🙎🏻|665
+🙎🏼|666
+🙎🏽|667
+🙎🏾|668
+🙎🏿|669
+🙎‍♂️|670
+🙎🏻‍♂️|671
+🙎🏼‍♂️|672
+🙎🏽‍♂️|673
+🙎🏾‍♂️|674
+🙎🏿‍♂️|675
+🙎‍♀️|676
+🙎🏻‍♀️|677
+🙎🏼‍♀️|678
+🙎🏽‍♀️|679
+🙎🏾‍♀️|680
+🙎🏿‍♀️|681
+🙅|682
+🙅🏻|683
+🙅🏼|684
+🙅🏽|685
+🙅🏾|686
+🙅🏿|687
+🙅‍♂️|688
+🙅🏻‍♂️|689
+🙅🏼‍♂️|690
+🙅🏽‍♂️|691
+🙅🏾‍♂️|692
+🙅🏿‍♂️|693
+🙅‍♀️|694
+🙅🏻‍♀️|695
+🙅🏼‍♀️|696
+🙅🏽‍♀️|697
+🙅🏾‍♀️|698
+🙅🏿‍♀️|699
+🙆|700
+🙆🏻|701
+🙆🏼|702
+🙆🏽|703
+🙆🏾|704
+🙆🏿|705
+🙆‍♂️|706
+🙆🏻‍♂️|707
+🙆🏼‍♂️|708
+🙆🏽‍♂️|709
+🙆🏾‍♂️|710
+🙆🏿‍♂️|711
+🙆‍♀️|712
+🙆🏻‍♀️|713
+🙆🏼‍♀️|714
+🙆🏽‍♀️|715
+🙆🏾‍♀️|716
+🙆🏿‍♀️|717
+💁|718
+💁🏻|719
+💁🏼|720
+💁🏽|721
+💁🏾|722
+💁🏿|723
+💁‍♂️|724
+💁🏻‍♂️|725
+💁🏼‍♂️|726
+💁🏽‍♂️|727
+💁🏾‍♂️|728
+💁🏿‍♂️|729
+💁‍♀️|730
+💁🏻‍♀️|731
+💁🏼‍♀️|732
+💁🏽‍♀️|733
+💁🏾‍♀️|734
+💁🏿‍♀️|735
+🙋|736
+🙋🏻|737
+🙋🏼|738
+🙋🏽|739
+🙋🏾|740
+🙋🏿|741
+🙋‍♂️|742
+🙋🏻‍♂️|743
+🙋🏼‍♂️|744
+🙋🏽‍♂️|745
+🙋🏾‍♂️|746
+🙋🏿‍♂️|747
+🙋‍♀️|748
+🙋🏻‍♀️|749
+🙋🏼‍♀️|750
+🙋🏽‍♀️|751
+🙋🏾‍♀️|752
+🙋🏿‍♀️|753
+🧏|754
+🧏🏻|755
+🧏🏼|756
+🧏🏽|757
+🧏🏾|758
+🧏🏿|759
+🧏‍♂️|760
+🧏🏻‍♂️|761
+🧏🏼‍♂️|762
+🧏🏽‍♂️|763
+🧏🏾‍♂️|764
+🧏🏿‍♂️|765
+🧏‍♀️|766
+🧏🏻‍♀️|767
+🧏🏼‍♀️|768
+🧏🏽‍♀️|769
+🧏🏾‍♀️|770
+🧏🏿‍♀️|771
+🙇|772
+🙇🏻|773
+🙇🏼|774
+🙇🏽|775
+🙇🏾|776
+🙇🏿|777
+🙇‍♂️|778
+🙇🏻‍♂️|779
+🙇🏼‍♂️|780
+🙇🏽‍♂️|781
+🙇🏾‍♂️|782
+🙇🏿‍♂️|783
+🙇‍♀️|784
+🙇🏻‍♀️|785
+🙇🏼‍♀️|786
+🙇🏽‍♀️|787
+🙇🏾‍♀️|788
+🙇🏿‍♀️|789
+🤦|790
+🤦🏻|791
+🤦🏼|792
+🤦🏽|793
+🤦🏾|794
+🤦🏿|795
+🤦‍♂️|796
+🤦🏻‍♂️|797
+🤦🏼‍♂️|798
+🤦🏽‍♂️|799
+🤦🏾‍♂️|800
+🤦🏿‍♂️|801
+🤦‍♀️|802
+🤦🏻‍♀️|803
+🤦🏼‍♀️|804
+🤦🏽‍♀️|805
+🤦🏾‍♀️|806
+🤦🏿‍♀️|807
+🤷|808
+🤷🏻|809
+🤷🏼|810
+🤷🏽|811
+🤷🏾|812
+🤷🏿|813
+🤷‍♂️|814
+🤷🏻‍♂️|815
+🤷🏼‍♂️|816
+🤷🏽‍♂️|817
+🤷🏾‍♂️|818
+🤷🏿‍♂️|819
+🤷‍♀️|820
+🤷🏻‍♀️|821
+🤷🏼‍♀️|822
+🤷🏽‍♀️|823
+🤷🏾‍♀️|824
+🤷🏿‍♀️|825
+🧑‍⚕️|826
+🧑🏻‍⚕️|827
+🧑🏼‍⚕️|828
+🧑🏽‍⚕️|829
+🧑🏾‍⚕️|830
+🧑🏿‍⚕️|831
+👨‍⚕️|832
+👨🏻‍⚕️|833
+👨🏼‍⚕️|834
+👨🏽‍⚕️|835
+👨🏾‍⚕️|836
+👨🏿‍⚕️|837
+👩‍⚕️|838
+👩🏻‍⚕️|839
+👩🏼‍⚕️|840
+👩🏽‍⚕️|841
+👩🏾‍⚕️|842
+👩🏿‍⚕️|843
+🧑‍🎓|844
+🧑🏻‍🎓|845
+🧑🏼‍🎓|846
+🧑🏽‍🎓|847
+🧑🏾‍🎓|848
+🧑🏿‍🎓|849
 👨‍🎓|850
 👨🏻‍🎓|851
 👨🏼‍🎓|852
 👨🏽‍🎓|853
 👨🏾‍🎓|854
 👨🏿‍🎓|855
-🧑‍🎤|1024
-🧑🏻‍🎤|1025
-🧑🏼‍🎤|1026
-🧑🏽‍🎤|1027
-🧑🏾‍🎤|1028
-🧑🏿‍🎤|1029
-👩‍🎤|1036
-👩🏻‍🎤|1037
-👩🏼‍🎤|1038
-👩🏽‍🎤|1039
-👩🏾‍🎤|1040
-👩🏿‍🎤|1041
-👨‍🎤|1030
-👨🏻‍🎤|1031
-👨🏼‍🎤|1032
-👨🏽‍🎤|1033
-👨🏾‍🎤|1034
-👨🏿‍🎤|1035
+👩‍🎓|856
+👩🏻‍🎓|857
+👩🏼‍🎓|858
+👩🏽‍🎓|859
+👩🏾‍🎓|860
+👩🏿‍🎓|861
 🧑‍🏫|862
 🧑🏻‍🏫|863
 🧑🏼‍🏫|864
 🧑🏽‍🏫|865
 🧑🏾‍🏫|866
 🧑🏿‍🏫|867
-👩‍🏫|874
-👩🏻‍🏫|875
-👩🏼‍🏫|876
-👩🏽‍🏫|877
-👩🏾‍🏫|878
-👩🏿‍🏫|879
 👨‍🏫|868
 👨🏻‍🏫|869
 👨🏼‍🏫|870
 👨🏽‍🏫|871
 👨🏾‍🏫|872
 👨🏿‍🏫|873
-🧑‍🏭|952
-🧑🏻‍🏭|953
-🧑🏼‍🏭|954
-🧑🏽‍🏭|955
-🧑🏾‍🏭|956
-🧑🏿‍🏭|957
-👩‍🏭|964
-👩🏻‍🏭|965
-👩🏼‍🏭|966
-👩🏽‍🏭|967
-👩🏾‍🏭|968
-👩🏿‍🏭|969
+👩‍🏫|874
+👩🏻‍🏫|875
+👩🏼‍🏫|876
+👩🏽‍🏫|877
+👩🏾‍🏫|878
+👩🏿‍🏫|879
+🧑‍⚖️|880
+🧑🏻‍⚖️|881
+🧑🏼‍⚖️|882
+🧑🏽‍⚖️|883
+🧑🏾‍⚖️|884
+🧑🏿‍⚖️|885
+👨‍⚖️|886
+👨🏻‍⚖️|887
+👨🏼‍⚖️|888
+👨🏽‍⚖️|889
+👨🏾‍⚖️|890
+👨🏿‍⚖️|891
+👩‍⚖️|892
+👩🏻‍⚖️|893
+👩🏼‍⚖️|894
+👩🏽‍⚖️|895
+👩🏾‍⚖️|896
+👩🏿‍⚖️|897
+🧑‍🌾|898
+🧑🏻‍🌾|899
+🧑🏼‍🌾|900
+🧑🏽‍🌾|901
+🧑🏾‍🌾|902
+🧑🏿‍🌾|903
+👨‍🌾|904
+👨🏻‍🌾|905
+👨🏼‍🌾|906
+👨🏽‍🌾|907
+👨🏾‍🌾|908
+👨🏿‍🌾|909
+👩‍🌾|910
+👩🏻‍🌾|911
+👩🏼‍🌾|912
+👩🏽‍🌾|913
+👩🏾‍🌾|914
+👩🏿‍🌾|915
+🧑‍🍳|916
+🧑🏻‍🍳|917
+🧑🏼‍🍳|918
+🧑🏽‍🍳|919
+🧑🏾‍🍳|920
+🧑🏿‍🍳|921
+👨‍🍳|922
+👨🏻‍🍳|923
+👨🏼‍🍳|924
+👨🏽‍🍳|925
+👨🏾‍🍳|926
+👨🏿‍🍳|927
+👩‍🍳|928
+👩🏻‍🍳|929
+👩🏼‍🍳|930
+👩🏽‍🍳|931
+👩🏾‍🍳|932
+👩🏿‍🍳|933
+🧑‍🔧|934
+🧑🏻‍🔧|935
+🧑🏼‍🔧|936
+🧑🏽‍🔧|937
+🧑🏾‍🔧|938
+🧑🏿‍🔧|939
+👨‍🔧|940
+👨🏻‍🔧|941
+👨🏼‍🔧|942
+👨🏽‍🔧|943
+👨🏾‍🔧|944
+👨🏿‍🔧|945
+👩‍🔧|946
+👩🏻‍🔧|947
+👩🏼‍🔧|948
+👩🏽‍🔧|949
+👩🏾‍🔧|950
+👩🏿‍🔧|951
+🧑‍🏭|952
+🧑🏻‍🏭|953
+🧑🏼‍🏭|954
+🧑🏽‍🏭|955
+🧑🏾‍🏭|956
+🧑🏿‍🏭|957
 👨‍🏭|958
 👨🏻‍🏭|959
 👨🏼‍🏭|960
 👨🏽‍🏭|961
 👨🏾‍🏭|962
 👨🏿‍🏭|963
-🧑‍💻|1006
-🧑🏻‍💻|1007
-🧑🏼‍💻|1008
-🧑🏽‍💻|1009
-🧑🏾‍💻|1010
-🧑🏿‍💻|1011
-👩‍💻|1018
-👩🏻‍💻|1019
-👩🏼‍💻|1020
-👩🏽‍💻|1021
-👩🏾‍💻|1022
-👩🏿‍💻|1023
-👨‍💻|1012
-👨🏻‍💻|1013
-👨🏼‍💻|1014
-👨🏽‍💻|1015
-👨🏾‍💻|1016
-👨🏿‍💻|1017
+👩‍🏭|964
+👩🏻‍🏭|965
+👩🏼‍🏭|966
+👩🏽‍🏭|967
+👩🏾‍🏭|968
+👩🏿‍🏭|969
 🧑‍💼|970
 🧑🏻‍💼|971
 🧑🏼‍💼|972
 🧑🏽‍💼|973
 🧑🏾‍💼|974
 🧑🏿‍💼|975
-👩‍💼|982
-👩🏻‍💼|983
-👩🏼‍💼|984
-👩🏽‍💼|985
-👩🏾‍💼|986
-👩🏿‍💼|987
 👨‍💼|976
 👨🏻‍💼|977
 👨🏼‍💼|978
 👨🏽‍💼|979
 👨🏾‍💼|980
 👨🏿‍💼|981
-🧑‍🔧|934
-🧑🏻‍🔧|935
-🧑🏼‍🔧|936
-🧑🏽‍🔧|937
-🧑🏾‍🔧|938
-🧑🏿‍🔧|939
-👩‍🔧|946
-👩🏻‍🔧|947
-👩🏼‍🔧|948
-👩🏽‍🔧|949
-👩🏾‍🔧|950
-👩🏿‍🔧|951
-👨‍🔧|940
-👨🏻‍🔧|941
-👨🏼‍🔧|942
-👨🏽‍🔧|943
-👨🏾‍🔧|944
-👨🏿‍🔧|945
+👩‍💼|982
+👩🏻‍💼|983
+👩🏼‍💼|984
+👩🏽‍💼|985
+👩🏾‍💼|986
+👩🏿‍💼|987
 🧑‍🔬|988
 🧑🏻‍🔬|989
 🧑🏼‍🔬|990
 🧑🏽‍🔬|991
 🧑🏾‍🔬|992
 🧑🏿‍🔬|993
-👩‍🔬|1000
-👩🏻‍🔬|1001
-👩🏼‍🔬|1002
-👩🏽‍🔬|1003
-👩🏾‍🔬|1004
-👩🏿‍🔬|1005
 👨‍🔬|994
 👨🏻‍🔬|995
 👨🏼‍🔬|996
 👨🏽‍🔬|997
 👨🏾‍🔬|998
 👨🏿‍🔬|999
+👩‍🔬|1000
+👩🏻‍🔬|1001
+👩🏼‍🔬|1002
+👩🏽‍🔬|1003
+👩🏾‍🔬|1004
+👩🏿‍🔬|1005
+🧑‍💻|1006
+🧑🏻‍💻|1007
+🧑🏼‍💻|1008
+🧑🏽‍💻|1009
+🧑🏾‍💻|1010
+🧑🏿‍💻|1011
+👨‍💻|1012
+👨🏻‍💻|1013
+👨🏼‍💻|1014
+👨🏽‍💻|1015
+👨🏾‍💻|1016
+👨🏿‍💻|1017
+👩‍💻|1018
+👩🏻‍💻|1019
+👩🏼‍💻|1020
+👩🏽‍💻|1021
+👩🏾‍💻|1022
+👩🏿‍💻|1023
+🧑‍🎤|1024
+🧑🏻‍🎤|1025
+🧑🏼‍🎤|1026
+🧑🏽‍🎤|1027
+🧑🏾‍🎤|1028
+🧑🏿‍🎤|1029
+👨‍🎤|1030
+👨🏻‍🎤|1031
+👨🏼‍🎤|1032
+👨🏽‍🎤|1033
+👨🏾‍🎤|1034
+👨🏿‍🎤|1035
+👩‍🎤|1036
+👩🏻‍🎤|1037
+👩🏼‍🎤|1038
+👩🏽‍🎤|1039
+👩🏾‍🎤|1040
+👩🏿‍🎤|1041
 🧑‍🎨|1042
 🧑🏻‍🎨|1043
 🧑🏼‍🎨|1044
 🧑🏽‍🎨|1045
 🧑🏾‍🎨|1046
 🧑🏿‍🎨|1047
-👩‍🎨|1054
-👩🏻‍🎨|1055
-👩🏼‍🎨|1056
-👩🏽‍🎨|1057
-👩🏾‍🎨|1058
-👩🏿‍🎨|1059
 👨‍🎨|1048
 👨🏻‍🎨|1049
 👨🏼‍🎨|1050
 👨🏽‍🎨|1051
 👨🏾‍🎨|1052
 👨🏿‍🎨|1053
-🧑‍🚒|1096
-🧑🏻‍🚒|1097
-🧑🏼‍🚒|1098
-🧑🏽‍🚒|1099
-🧑🏾‍🚒|1100
-🧑🏿‍🚒|1101
-👩‍🚒|1108
-👩🏻‍🚒|1109
-👩🏼‍🚒|1110
-👩🏽‍🚒|1111
-👩🏾‍🚒|1112
-👩🏿‍🚒|1113
-👨‍🚒|1102
-👨🏻‍🚒|1103
-👨🏼‍🚒|1104
-👨🏽‍🚒|1105
-👨🏾‍🚒|1106
-👨🏿‍🚒|1107
+👩‍🎨|1054
+👩🏻‍🎨|1055
+👩🏼‍🎨|1056
+👩🏽‍🎨|1057
+👩🏾‍🎨|1058
+👩🏿‍🎨|1059
 🧑‍✈️|1060
 🧑🏻‍✈️|1061
 🧑🏼‍✈️|1062
 🧑🏽‍✈️|1063
 🧑🏾‍✈️|1064
 🧑🏿‍✈️|1065
-👩‍✈️|1072
-👩🏻‍✈️|1073
-👩🏼‍✈️|1074
-👩🏽‍✈️|1075
-👩🏾‍✈️|1076
-👩🏿‍✈️|1077
 👨‍✈️|1066
 👨🏻‍✈️|1067
 👨🏼‍✈️|1068
 👨🏽‍✈️|1069
 👨🏾‍✈️|1070
 👨🏿‍✈️|1071
+👩‍✈️|1072
+👩🏻‍✈️|1073
+👩🏼‍✈️|1074
+👩🏽‍✈️|1075
+👩🏾‍✈️|1076
+👩🏿‍✈️|1077
 🧑‍🚀|1078
 🧑🏻‍🚀|1079
 🧑🏼‍🚀|1080
 🧑🏽‍🚀|1081
 🧑🏾‍🚀|1082
 🧑🏿‍🚀|1083
-👩‍🚀|1090
-👩🏻‍🚀|1091
-👩🏼‍🚀|1092
-👩🏽‍🚀|1093
-👩🏾‍🚀|1094
-👩🏿‍🚀|1095
 👨‍🚀|1084
 👨🏻‍🚀|1085
 👨🏼‍🚀|1086
 👨🏽‍🚀|1087
 👨🏾‍🚀|1088
 👨🏿‍🚀|1089
-🧑‍⚖️|880
-🧑🏻‍⚖️|881
-🧑🏼‍⚖️|882
-🧑🏽‍⚖️|883
-🧑🏾‍⚖️|884
-🧑🏿‍⚖️|885
-👩‍⚖️|892
-👩🏻‍⚖️|893
-👩🏼‍⚖️|894
-👩🏽‍⚖️|895
-👩🏾‍⚖️|896
-👩🏿‍⚖️|897
-👨‍⚖️|886
-👨🏻‍⚖️|887
-👨🏼‍⚖️|888
-👨🏽‍⚖️|889
-👨🏾‍⚖️|890
-👨🏿‍⚖️|891
-👰|1258
-👰🏻|1259
-👰🏼|1260
-👰🏽|1261
-👰🏾|1262
-👰🏿|1263
-👰‍♀️|1270
-👰🏻‍♀️|1271
-👰🏼‍♀️|1272
-👰🏽‍♀️|1273
-👰🏾‍♀️|1274
-👰🏿‍♀️|1275
-👰‍♂️|1264
-👰🏻‍♂️|1265
-👰🏼‍♂️|1266
-👰🏽‍♂️|1267
-👰🏾‍♂️|1268
-👰🏿‍♂️|1269
+👩‍🚀|1090
+👩🏻‍🚀|1091
+👩🏼‍🚀|1092
+👩🏽‍🚀|1093
+👩🏾‍🚀|1094
+👩🏿‍🚀|1095
+🧑‍🚒|1096
+🧑🏻‍🚒|1097
+🧑🏼‍🚒|1098
+🧑🏽‍🚒|1099
+🧑🏾‍🚒|1100
+🧑🏿‍🚒|1101
+👨‍🚒|1102
+👨🏻‍🚒|1103
+👨🏼‍🚒|1104
+👨🏽‍🚒|1105
+👨🏾‍🚒|1106
+👨🏿‍🚒|1107
+👩‍🚒|1108
+👩🏻‍🚒|1109
+👩🏼‍🚒|1110
+👩🏽‍🚒|1111
+👩🏾‍🚒|1112
+👩🏿‍🚒|1113
+👮|1114
+👮🏻|1115
+👮🏼|1116
+👮🏽|1117
+👮🏾|1118
+👮🏿|1119
+👮‍♂️|1120
+👮🏻‍♂️|1121
+👮🏼‍♂️|1122
+👮🏽‍♂️|1123
+👮🏾‍♂️|1124
+👮🏿‍♂️|1125
+👮‍♀️|1126
+👮🏻‍♀️|1127
+👮🏼‍♀️|1128
+👮🏽‍♀️|1129
+👮🏾‍♀️|1130
+👮🏿‍♀️|1131
+🕵️|1132
+🕵🏻|1133
+🕵🏼|1134
+🕵🏽|1135
+🕵🏾|1136
+🕵🏿|1137
+🕵️‍♂️|1138
+🕵🏻‍♂️|1139
+🕵🏼‍♂️|1140
+🕵🏽‍♂️|1141
+🕵🏾‍♂️|1142
+🕵🏿‍♂️|1143
+🕵️‍♀️|1144
+🕵🏻‍♀️|1145
+🕵🏼‍♀️|1146
+🕵🏽‍♀️|1147
+🕵🏾‍♀️|1148
+🕵🏿‍♀️|1149
+💂|1150
+💂🏻|1151
+💂🏼|1152
+💂🏽|1153
+💂🏾|1154
+💂🏿|1155
+💂‍♂️|1156
+💂🏻‍♂️|1157
+💂🏼‍♂️|1158
+💂🏽‍♂️|1159
+💂🏾‍♂️|1160
+💂🏿‍♂️|1161
+💂‍♀️|1162
+💂🏻‍♀️|1163
+💂🏼‍♀️|1164
+💂🏽‍♀️|1165
+💂🏾‍♀️|1166
+💂🏿‍♀️|1167
+🥷|1168
+🥷🏻|1169
+🥷🏼|1170
+🥷🏽|1171
+🥷🏾|1172
+🥷🏿|1173
+👷|1174
+👷🏻|1175
+👷🏼|1176
+👷🏽|1177
+👷🏾|1178
+👷🏿|1179
+👷‍♂️|1180
+👷🏻‍♂️|1181
+👷🏼‍♂️|1182
+👷🏽‍♂️|1183
+👷🏾‍♂️|1184
+👷🏿‍♂️|1185
+👷‍♀️|1186
+👷🏻‍♀️|1187
+👷🏼‍♀️|1188
+👷🏽‍♀️|1189
+👷🏾‍♀️|1190
+👷🏿‍♀️|1191
+🫅|1192
+🫅🏻|1193
+🫅🏼|1194
+🫅🏽|1195
+🫅🏾|1196
+🫅🏿|1197
+🤴|1198
+🤴🏻|1199
+🤴🏼|1200
+🤴🏽|1201
+🤴🏾|1202
+🤴🏿|1203
+👸|1204
+👸🏻|1205
+👸🏼|1206
+👸🏽|1207
+👸🏾|1208
+👸🏿|1209
+👳|1210
+👳🏻|1211
+👳🏼|1212
+👳🏽|1213
+👳🏾|1214
+👳🏿|1215
+👳‍♂️|1216
+👳🏻‍♂️|1217
+👳🏼‍♂️|1218
+👳🏽‍♂️|1219
+👳🏾‍♂️|1220
+👳🏿‍♂️|1221
+👳‍♀️|1222
+👳🏻‍♀️|1223
+👳🏼‍♀️|1224
+👳🏽‍♀️|1225
+👳🏾‍♀️|1226
+👳🏿‍♀️|1227
+👲|1228
+👲🏻|1229
+👲🏼|1230
+👲🏽|1231
+👲🏾|1232
+👲🏿|1233
+🧕|1234
+🧕🏻|1235
+🧕🏼|1236
+🧕🏽|1237
+🧕🏾|1238
+🧕🏿|1239
 🤵|1240
 🤵🏻|1241
 🤵🏼|1242
 🤵🏽|1243
 🤵🏾|1244
 🤵🏿|1245
-🤵‍♀️|1252
-🤵🏻‍♀️|1253
-🤵🏼‍♀️|1254
-🤵🏽‍♀️|1255
-🤵🏾‍♀️|1256
-🤵🏿‍♀️|1257
 🤵‍♂️|1246
 🤵🏻‍♂️|1247
 🤵🏼‍♂️|1248
 🤵🏽‍♂️|1249
 🤵🏾‍♂️|1250
 🤵🏿‍♂️|1251
-👸|1204
-👸🏻|1205
-👸🏼|1206
-👸🏽|1207
-👸🏾|1208
-👸🏿|1209
-🤴|1198
-🤴🏻|1199
-🤴🏼|1200
-🤴🏽|1201
-🤴🏾|1202
-🤴🏿|1203
-🦸|1342
-🦸🏻|1343
-🦸🏼|1344
-🦸🏽|1345
-🦸🏾|1346
-🦸🏿|1347
-🦸‍♀️|1354
-🦸🏻‍♀️|1355
-🦸🏼‍♀️|1356
-🦸🏽‍♀️|1357
-🦸🏾‍♀️|1358
-🦸🏿‍♀️|1359
-🦸‍♂️|1348
-🦸🏻‍♂️|1349
-🦸🏼‍♂️|1350
-🦸🏽‍♂️|1351
-🦸🏾‍♂️|1352
-🦸🏿‍♂️|1353
-🦹|1360
-🦹🏻|1361
-🦹🏼|1362
-🦹🏽|1363
-🦹🏾|1364
-🦹🏿|1365
-🦹‍♀️|1372
-🦹🏻‍♀️|1373
-🦹🏼‍♀️|1374
-🦹🏽‍♀️|1375
-🦹🏾‍♀️|1376
-🦹🏿‍♀️|1377
-🦹‍♂️|1366
-🦹🏻‍♂️|1367
-🦹🏼‍♂️|1368
-🦹🏽‍♂️|1369
-🦹🏾‍♂️|1370
-🦹🏿‍♂️|1371
-🥷|1168
-🥷🏻|1169
-🥷🏼|1170
-🥷🏽|1171
-🥷🏾|1172
-🥷🏿|1173
-🧑‍🎄|1336
-🧑🏻‍🎄|1337
-🧑🏼‍🎄|1338
-🧑🏽‍🎄|1339
-🧑🏾‍🎄|1340
-🧑🏿‍🎄|1341
-🤶|1330
-🤶🏻|1331
-🤶🏼|1332
-🤶🏽|1333
-🤶🏾|1334
-🤶🏿|1335
-🎅|1324
-🎅🏻|1325
-🎅🏼|1326
-🎅🏽|1327
-🎅🏾|1328
-🎅🏿|1329
-🧙|1378
-🧙🏻|1379
-🧙🏼|1380
-🧙🏽|1381
-🧙🏾|1382
-🧙🏿|1383
-🧙‍♀️|1390
-🧙🏻‍♀️|1391
-🧙🏼‍♀️|1392
-🧙🏽‍♀️|1393
-🧙🏾‍♀️|1394
-🧙🏿‍♀️|1395
-🧙‍♂️|1384
-🧙🏻‍♂️|1385
-🧙🏼‍♂️|1386
+🤵‍♀️|1252
+🤵🏻‍♀️|1253
+🤵🏼‍♀️|1254
+🤵🏽‍♀️|1255
+🤵🏾‍♀️|1256
+🤵🏿‍♀️|1257
+👰|1258
+👰🏻|1259
+👰🏼|1260
+👰🏽|1261
+👰🏾|1262
+👰🏿|1263
+👰‍♂️|1264
+👰🏻‍♂️|1265
+👰🏼‍♂️|1266
+👰🏽‍♂️|1267
+👰🏾‍♂️|1268
+👰🏿‍♂️|1269
+👰‍♀️|1270
+👰🏻‍♀️|1271
+👰🏼‍♀️|1272
+👰🏽‍♀️|1273
+👰🏾‍♀️|1274
+👰🏿‍♀️|1275
+🤰|1276
+🤰🏻|1277
+🤰🏼|1278
+🤰🏽|1279
+🤰🏾|1280
+🤰🏿|1281
+🫃|1282
+🫃🏻|1283
+🫃🏼|1284
+🫃🏽|1285
+🫃🏾|1286
+🫃🏿|1287
+🫄|1288
+🫄🏻|1289
+🫄🏼|1290
+🫄🏽|1291
+🫄🏾|1292
+🫄🏿|1293
+🤱|1294
+🤱🏻|1295
+🤱🏼|1296
+🤱🏽|1297
+🤱🏾|1298
+🤱🏿|1299
+👩‍🍼|1300
+👩🏻‍🍼|1301
+👩🏼‍🍼|1302
+👩🏽‍🍼|1303
+👩🏾‍🍼|1304
+👩🏿‍🍼|1305
+👨‍🍼|1306
+👨🏻‍🍼|1307
+👨🏼‍🍼|1308
+👨🏽‍🍼|1309
+👨🏾‍🍼|1310
+👨🏿‍🍼|1311
+🧑‍🍼|1312
+🧑🏻‍🍼|1313
+🧑🏼‍🍼|1314
+🧑🏽‍🍼|1315
+🧑🏾‍🍼|1316
+🧑🏿‍🍼|1317
+👼|1318
+👼🏻|1319
+👼🏼|1320
+👼🏽|1321
+👼🏾|1322
+👼🏿|1323
+🎅|1324
+🎅🏻|1325
+🎅🏼|1326
+🎅🏽|1327
+🎅🏾|1328
+🎅🏿|1329
+🤶|1330
+🤶🏻|1331
+🤶🏼|1332
+🤶🏽|1333
+🤶🏾|1334
+🤶🏿|1335
+🧑‍🎄|1336
+🧑🏻‍🎄|1337
+🧑🏼‍🎄|1338
+🧑🏽‍🎄|1339
+🧑🏾‍🎄|1340
+🧑🏿‍🎄|1341
+🦸|1342
+🦸🏻|1343
+🦸🏼|1344
+🦸🏽|1345
+🦸🏾|1346
+🦸🏿|1347
+🦸‍♂️|1348
+🦸🏻‍♂️|1349
+🦸🏼‍♂️|1350
+🦸🏽‍♂️|1351
+🦸🏾‍♂️|1352
+🦸🏿‍♂️|1353
+🦸‍♀️|1354
+🦸🏻‍♀️|1355
+🦸🏼‍♀️|1356
+🦸🏽‍♀️|1357
+🦸🏾‍♀️|1358
+🦸🏿‍♀️|1359
+🦹|1360
+🦹🏻|1361
+🦹🏼|1362
+🦹🏽|1363
+🦹🏾|1364
+🦹🏿|1365
+🦹‍♂️|1366
+🦹🏻‍♂️|1367
+🦹🏼‍♂️|1368
+🦹🏽‍♂️|1369
+🦹🏾‍♂️|1370
+🦹🏿‍♂️|1371
+🦹‍♀️|1372
+🦹🏻‍♀️|1373
+🦹🏼‍♀️|1374
+🦹🏽‍♀️|1375
+🦹🏾‍♀️|1376
+🦹🏿‍♀️|1377
+🧙|1378
+🧙🏻|1379
+🧙🏼|1380
+🧙🏽|1381
+🧙🏾|1382
+🧙🏿|1383
+🧙‍♂️|1384
+🧙🏻‍♂️|1385
+🧙🏼‍♂️|1386
 🧙🏽‍♂️|1387
 🧙🏾‍♂️|1388
 🧙🏿‍♂️|1389
-🧝|1450
-🧝🏻|1451
-🧝🏼|1452
-🧝🏽|1453
-🧝🏾|1454
-🧝🏿|1455
-🧝‍♀️|1462
-🧝🏻‍♀️|1463
-🧝🏼‍♀️|1464
-🧝🏽‍♀️|1465
-🧝🏾‍♀️|1466
-🧝🏿‍♀️|1467
-🧝‍♂️|1456
-🧝🏻‍♂️|1457
-🧝🏼‍♂️|1458
-🧝🏽‍♂️|1459
-🧝🏾‍♂️|1460
-🧝🏿‍♂️|1461
+🧙‍♀️|1390
+🧙🏻‍♀️|1391
+🧙🏼‍♀️|1392
+🧙🏽‍♀️|1393
+🧙🏾‍♀️|1394
+🧙🏿‍♀️|1395
+🧚|1396
+🧚🏻|1397
+🧚🏼|1398
+🧚🏽|1399
+🧚🏾|1400
+🧚🏿|1401
+🧚‍♂️|1402
+🧚🏻‍♂️|1403
+🧚🏼‍♂️|1404
+🧚🏽‍♂️|1405
+🧚🏾‍♂️|1406
+🧚🏿‍♂️|1407
+🧚‍♀️|1408
+🧚🏻‍♀️|1409
+🧚🏼‍♀️|1410
+🧚🏽‍♀️|1411
+🧚🏾‍♀️|1412
+🧚🏿‍♀️|1413
 🧛|1414
 🧛🏻|1415
 🧛🏼|1416
 🧛🏽|1417
 🧛🏾|1418
 🧛🏿|1419
-🧛‍♀️|1426
-🧛🏻‍♀️|1427
-🧛🏼‍♀️|1428
-🧛🏽‍♀️|1429
-🧛🏾‍♀️|1430
-🧛🏿‍♀️|1431
 🧛‍♂️|1420
 🧛🏻‍♂️|1421
 🧛🏼‍♂️|1422
 🧛🏽‍♂️|1423
 🧛🏾‍♂️|1424
 🧛🏿‍♂️|1425
-🧟|1471
-🧟‍♀️|1473
-🧟‍♂️|1472
-🧞|1468
-🧞‍♀️|1470
-🧞‍♂️|1469
+🧛‍♀️|1426
+🧛🏻‍♀️|1427
+🧛🏼‍♀️|1428
+🧛🏽‍♀️|1429
+🧛🏾‍♀️|1430
+🧛🏿‍♀️|1431
 🧜|1432
 🧜🏻|1433
 🧜🏼|1434
 🧜🏽|1435
 🧜🏾|1436
 🧜🏿|1437
-🧜‍♀️|1444
-🧜🏻‍♀️|1445
-🧜🏼‍♀️|1446
-🧜🏽‍♀️|1447
-🧜🏾‍♀️|1448
-🧜🏿‍♀️|1449
 🧜‍♂️|1438
 🧜🏻‍♂️|1439
 🧜🏼‍♂️|1440
 🧜🏽‍♂️|1441
 🧜🏾‍♂️|1442
 🧜🏿‍♂️|1443
-🧚|1396
-🧚🏻|1397
-🧚🏼|1398
-🧚🏽|1399
-🧚🏾|1400
-🧚🏿|1401
-🧚‍♀️|1408
-🧚🏻‍♀️|1409
-🧚🏼‍♀️|1410
-🧚🏽‍♀️|1411
-🧚🏾‍♀️|1412
-🧚🏿‍♀️|1413
-🧚‍♂️|1402
-🧚🏻‍♂️|1403
-🧚🏼‍♂️|1404
-🧚🏽‍♂️|1405
-🧚🏾‍♂️|1406
-🧚🏿‍♂️|1407
-👼|1318
-👼🏻|1319
-👼🏼|1320
-👼🏽|1321
-👼🏾|1322
-👼🏿|1323
-🤰|1276
-🤰🏻|1277
-🤰🏼|1278
-🤰🏽|1279
-🤰🏾|1280
-🤰🏿|1281
-🤱|1294
-🤱🏻|1295
-🤱🏼|1296
-🤱🏽|1297
-🤱🏾|1298
-🤱🏿|1299
-🧑‍🍼|1312
-🧑🏻‍🍼|1313
-🧑🏼‍🍼|1314
-🧑🏽‍🍼|1315
-🧑🏾‍🍼|1316
-🧑🏿‍🍼|1317
-👩‍🍼|1300
-👩🏻‍🍼|1301
-👩🏼‍🍼|1302
-👩🏽‍🍼|1303
-👩🏾‍🍼|1304
-👩🏿‍🍼|1305
-👨‍🍼|1306
-👨🏻‍🍼|1307
-👨🏼‍🍼|1308
-👨🏽‍🍼|1309
-👨🏾‍🍼|1310
-👨🏿‍🍼|1311
-🙇|772
-🙇🏻|773
-🙇🏼|774
-🙇🏽|775
-🙇🏾|776
-🙇🏿|777
-🙇‍♀️|784
-🙇🏻‍♀️|785
-🙇🏼‍♀️|786
-🙇🏽‍♀️|787
-🙇🏾‍♀️|788
-🙇🏿‍♀️|789
-🙇‍♂️|778
-🙇🏻‍♂️|779
-🙇🏼‍♂️|780
-🙇🏽‍♂️|781
-🙇🏾‍♂️|782
-🙇🏿‍♂️|783
-💁|718
-💁🏻|719
-💁🏼|720
-💁🏽|721
-💁🏾|722
-💁🏿|723
-💁‍♀️|730
-💁🏻‍♀️|731
-💁🏼‍♀️|732
-💁🏽‍♀️|733
-💁🏾‍♀️|734
-💁🏿‍♀️|735
-💁‍♂️|724
-💁🏻‍♂️|725
-💁🏼‍♂️|726
-💁🏽‍♂️|727
-💁🏾‍♂️|728
-💁🏿‍♂️|729
-🙅|682
-🙅🏻|683
-🙅🏼|684
-🙅🏽|685
-🙅🏾|686
-🙅🏿|687
-🙅‍♀️|694
-🙅🏻‍♀️|695
-🙅🏼‍♀️|696
-🙅🏽‍♀️|697
-🙅🏾‍♀️|698
-🙅🏿‍♀️|699
-🙅‍♂️|688
-🙅🏻‍♂️|689
-🙅🏼‍♂️|690
-🙅🏽‍♂️|691
-🙅🏾‍♂️|692
-🙅🏿‍♂️|693
-🙆|700
-🙆🏻|701
-🙆🏼|702
-🙆🏽|703
-🙆🏾|704
-🙆🏿|705
-🙆‍♀️|712
-🙆🏻‍♀️|713
-🙆🏼‍♀️|714
-🙆🏽‍♀️|715
-🙆🏾‍♀️|716
-🙆🏿‍♀️|717
-🙆‍♂️|706
-🙆🏻‍♂️|707
-🙆🏼‍♂️|708
-🙆🏽‍♂️|709
-🙆🏾‍♂️|710
-🙆🏿‍♂️|711
-🙋|736
-🙋🏻|737
-🙋🏼|738
-🙋🏽|739
-🙋🏾|740
-🙋🏿|741
-🙋‍♀️|748
-🙋🏻‍♀️|749
-🙋🏼‍♀️|750
-🙋🏽‍♀️|751
-🙋🏾‍♀️|752
-🙋🏿‍♀️|753
-🙋‍♂️|742
-🙋🏻‍♂️|743
-🙋🏼‍♂️|744
-🙋🏽‍♂️|745
-🙋🏾‍♂️|746
-🙋🏿‍♂️|747
-🧏|754
-🧏🏻|755
-🧏🏼|756
-🧏🏽|757
-🧏🏾|758
-🧏🏿|759
-🧏‍♀️|766
-🧏🏻‍♀️|767
-🧏🏼‍♀️|768
-🧏🏽‍♀️|769
-🧏🏾‍♀️|770
-🧏🏿‍♀️|771
-🧏‍♂️|760
-🧏🏻‍♂️|761
-🧏🏼‍♂️|762
-🧏🏽‍♂️|763
-🧏🏾‍♂️|764
-🧏🏿‍♂️|765
-🤦|790
-🤦🏻|791
-🤦🏼|792
-🤦🏽|793
-🤦🏾|794
-🤦🏿|795
-🤦‍♀️|802
-🤦🏻‍♀️|803
-🤦🏼‍♀️|804
-🤦🏽‍♀️|805
-🤦🏾‍♀️|806
-🤦🏿‍♀️|807
-🤦‍♂️|796
-🤦🏻‍♂️|797
-🤦🏼‍♂️|798
-🤦🏽‍♂️|799
-🤦🏾‍♂️|800
-🤦🏿‍♂️|801
-🤷|808
-🤷🏻|809
-🤷🏼|810
-🤷🏽|811
-🤷🏾|812
-🤷🏿|813
-🤷‍♀️|820
-🤷🏻‍♀️|821
-🤷🏼‍♀️|822
-🤷🏽‍♀️|823
-🤷🏾‍♀️|824
-🤷🏿‍♀️|825
-🤷‍♂️|814
-🤷🏻‍♂️|815
-🤷🏼‍♂️|816
-🤷🏽‍♂️|817
-🤷🏾‍♂️|818
-🤷🏿‍♂️|819
-🙎|664
-🙎🏻|665
-🙎🏼|666
-🙎🏽|667
-🙎🏾|668
-🙎🏿|669
-🙎‍♀️|676
-🙎🏻‍♀️|677
-🙎🏼‍♀️|678
-🙎🏽‍♀️|679
-🙎🏾‍♀️|680
-🙎🏿‍♀️|681
-🙎‍♂️|670
-🙎🏻‍♂️|671
-🙎🏼‍♂️|672
-🙎🏽‍♂️|673
-🙎🏾‍♂️|674
-🙎🏿‍♂️|675
-🙍|646
-🙍🏻|647
-🙍🏼|648
-🙍🏽|649
-🙍🏾|650
-🙍🏿|651
-🙍‍♀️|658
-🙍🏻‍♀️|659
-🙍🏼‍♀️|660
-🙍🏽‍♀️|661
-🙍🏾‍♀️|662
-🙍🏿‍♀️|663
-🙍‍♂️|652
-🙍🏻‍♂️|653
-🙍🏼‍♂️|654
-🙍🏽‍♂️|655
-🙍🏾‍♂️|656
-🙍🏿‍♂️|657
-💇|1493
-💇🏻|1494
-💇🏼|1495
-💇🏽|1496
-💇🏾|1497
-💇🏿|1498
-💇‍♀️|1505
-💇🏻‍♀️|1506
-💇🏼‍♀️|1507
-💇🏽‍♀️|1508
-💇🏾‍♀️|1509
-💇🏿‍♀️|1510
-💇‍♂️|1499
-💇🏻‍♂️|1500
-💇🏼‍♂️|1501
-💇🏽‍♂️|1502
-💇🏾‍♂️|1503
-💇🏿‍♂️|1504
+🧜‍♀️|1444
+🧜🏻‍♀️|1445
+🧜🏼‍♀️|1446
+🧜🏽‍♀️|1447
+🧜🏾‍♀️|1448
+🧜🏿‍♀️|1449
+🧝|1450
+🧝🏻|1451
+🧝🏼|1452
+🧝🏽|1453
+🧝🏾|1454
+🧝🏿|1455
+🧝‍♂️|1456
+🧝🏻‍♂️|1457
+🧝🏼‍♂️|1458
+🧝🏽‍♂️|1459
+🧝🏾‍♂️|1460
+🧝🏿‍♂️|1461
+🧝‍♀️|1462
+🧝🏻‍♀️|1463
+🧝🏼‍♀️|1464
+🧝🏽‍♀️|1465
+🧝🏾‍♀️|1466
+🧝🏿‍♀️|1467
+🧞|1468
+🧞‍♂️|1469
+🧞‍♀️|1470
+🧟|1471
+🧟‍♂️|1472
+🧟‍♀️|1473
+🧌|1474
 💆|1475
 💆🏻|1476
 💆🏼|1477
 💆🏽|1478
 💆🏾|1479
 💆🏿|1480
-💆‍♀️|1487
-💆🏻‍♀️|1488
-💆🏼‍♀️|1489
-💆🏽‍♀️|1490
-💆🏾‍♀️|1491
-💆🏿‍♀️|1492
 💆‍♂️|1481
 💆🏻‍♂️|1482
 💆🏼‍♂️|1483
 💆🏽‍♂️|1484
 💆🏾‍♂️|1485
 💆🏿‍♂️|1486
-🧖|1658
-🧖🏻|1659
-🧖🏼|1660
-🧖🏽|1661
-🧖🏾|1662
-🧖🏿|1663
-🧖‍♀️|1670
-🧖🏻‍♀️|1671
-🧖🏼‍♀️|1672
-🧖🏽‍♀️|1673
-🧖🏾‍♀️|1674
-🧖🏿‍♀️|1675
-🧖‍♂️|1664
-🧖🏻‍♂️|1665
-🧖🏼‍♂️|1666
-🧖🏽‍♂️|1667
-🧖🏾‍♂️|1668
-🧖🏿‍♂️|1669
-💅|418
-💅🏻|419
-💅🏼|420
-💅🏽|421
-💅🏾|422
-💅🏿|423
-🤳|424
-🤳🏻|425
-🤳🏼|426
-🤳🏽|427
-🤳🏾|428
-🤳🏿|429
-💃|1637
-💃🏻|1638
-💃🏼|1639
-💃🏽|1640
-💃🏾|1641
-💃🏿|1642
-🕺|1643
-🕺🏻|1644
-🕺🏼|1645
-🕺🏽|1646
-🕺🏿|1648
-🕺🏾|1647
-👯|1655
-👯‍♀️|1657
-👯‍♂️|1656
-🕴️|1649
-🕴🏻|1650
-🕴🏼|1651
-🕴🏽|1652
-🕴🏾|1653
-🕴🏿|1654
-🧑‍🦽|1601
-🧑🏻‍🦽|1602
-🧑🏼‍🦽|1603
-🧑🏽‍🦽|1604
-🧑🏾‍🦽|1605
-🧑🏿‍🦽|1606
-👩‍🦽|1613
-👩🏻‍🦽|1614
-👩🏼‍🦽|1615
-👩🏽‍🦽|1616
-👩🏾‍🦽|1617
-👩🏿‍🦽|1618
-👨‍🦽|1607
-👨🏻‍🦽|1608
-👨🏼‍🦽|1609
-👨🏽‍🦽|1610
-👨🏾‍🦽|1611
-👨🏿‍🦽|1612
-🧑‍🦼|1583
-🧑🏻‍🦼|1584
-🧑🏼‍🦼|1585
-🧑🏽‍🦼|1586
-🧑🏾‍🦼|1587
-🧑🏿‍🦼|1588
-👩‍🦼|1595
-👩🏻‍🦼|1596
-👩🏼‍🦼|1597
-👩🏽‍🦼|1598
-👩🏾‍🦼|1599
-👩🏿‍🦼|1600
-👨‍🦼|1589
-👨🏻‍🦼|1590
-👨🏼‍🦼|1591
-👨🏽‍🦼|1592
-👨🏾‍🦼|1593
-👨🏿‍🦼|1594
+💆‍♀️|1487
+💆🏻‍♀️|1488
+💆🏼‍♀️|1489
+💆🏽‍♀️|1490
+💆🏾‍♀️|1491
+💆🏿‍♀️|1492
+💇|1493
+💇🏻|1494
+💇🏼|1495
+💇🏽|1496
+💇🏾|1497
+💇🏿|1498
+💇‍♂️|1499
+💇🏻‍♂️|1500
+💇🏼‍♂️|1501
+💇🏽‍♂️|1502
+💇🏾‍♂️|1503
+💇🏿‍♂️|1504
+💇‍♀️|1505
+💇🏻‍♀️|1506
+💇🏼‍♀️|1507
+💇🏽‍♀️|1508
+💇🏾‍♀️|1509
+💇🏿‍♀️|1510
 🚶|1511
 🚶🏻|1512
 🚶🏼|1513
 🚶🏽|1514
 🚶🏾|1515
 🚶🏿|1516
-🚶‍♀️|1523
-🚶🏻‍♀️|1524
-🚶🏼‍♀️|1525
-🚶🏽‍♀️|1526
-🚶🏾‍♀️|1527
-🚶🏿‍♀️|1528
 🚶‍♂️|1517
 🚶🏻‍♂️|1518
 🚶🏼‍♂️|1519
 🚶🏽‍♂️|1520
 🚶🏾‍♂️|1521
 🚶🏿‍♂️|1522
-🧑‍🦯|1565
-🧑🏻‍🦯|1566
-🧑🏼‍🦯|1567
-🧑🏽‍🦯|1568
-🧑🏾‍🦯|1569
-🧑🏿‍🦯|1570
-👩‍🦯|1577
-👩🏻‍🦯|1578
-👩🏼‍🦯|1579
-👩🏽‍🦯|1580
-👩🏾‍🦯|1581
-👩🏿‍🦯|1582
-👨‍🦯|1571
-👨🏻‍🦯|1572
-👨🏽‍🦯|1574
-👨🏼‍🦯|1573
-👨🏾‍🦯|1575
-👨🏿‍🦯|1576
+🚶‍♀️|1523
+🚶🏻‍♀️|1524
+🚶🏼‍♀️|1525
+🚶🏽‍♀️|1526
+🚶🏾‍♀️|1527
+🚶🏿‍♀️|1528
+🧍|1529
+🧍🏻|1530
+🧍🏼|1531
+🧍🏽|1532
+🧍🏾|1533
+🧍🏿|1534
+🧍‍♂️|1535
+🧍🏻‍♂️|1536
+🧍🏼‍♂️|1537
+🧍🏽‍♂️|1538
+🧍🏾‍♂️|1539
+🧍🏿‍♂️|1540
+🧍‍♀️|1541
+🧍🏻‍♀️|1542
+🧍🏼‍♀️|1543
+🧍🏽‍♀️|1544
+🧍🏾‍♀️|1545
+🧍🏿‍♀️|1546
 🧎|1547
 🧎🏻|1548
 🧎🏼|1549
 🧎🏽|1550
 🧎🏾|1551
 🧎🏿|1552
-🧎‍♀️|1559
-🧎🏻‍♀️|1560
-🧎🏼‍♀️|1561
-🧎🏽‍♀️|1562
-🧎🏾‍♀️|1563
-🧎🏿‍♀️|1564
 🧎‍♂️|1553
 🧎🏻‍♂️|1554
 🧎🏼‍♂️|1555
 🧎🏽‍♂️|1556
 🧎🏾‍♂️|1557
 🧎🏿‍♂️|1558
+🧎‍♀️|1559
+🧎🏻‍♀️|1560
+🧎🏼‍♀️|1561
+🧎🏽‍♀️|1562
+🧎🏾‍♀️|1563
+🧎🏿‍♀️|1564
+🧑‍🦯|1565
+🧑🏻‍🦯|1566
+🧑🏼‍🦯|1567
+🧑🏽‍🦯|1568
+🧑🏾‍🦯|1569
+🧑🏿‍🦯|1570
+👨‍🦯|1571
+👨🏻‍🦯|1572
+👨🏼‍🦯|1573
+👨🏽‍🦯|1574
+👨🏾‍🦯|1575
+👨🏿‍🦯|1576
+👩‍🦯|1577
+👩🏻‍🦯|1578
+👩🏼‍🦯|1579
+👩🏽‍🦯|1580
+👩🏾‍🦯|1581
+👩🏿‍🦯|1582
+🧑‍🦼|1583
+🧑🏻‍🦼|1584
+🧑🏼‍🦼|1585
+🧑🏽‍🦼|1586
+🧑🏾‍🦼|1587
+🧑🏿‍🦼|1588
+👨‍🦼|1589
+👨🏻‍🦼|1590
+👨🏼‍🦼|1591
+👨🏽‍🦼|1592
+👨🏾‍🦼|1593
+👨🏿‍🦼|1594
+👩‍🦼|1595
+👩🏻‍🦼|1596
+👩🏼‍🦼|1597
+👩🏽‍🦼|1598
+👩🏾‍🦼|1599
+👩🏿‍🦼|1600
+🧑‍🦽|1601
+🧑🏻‍🦽|1602
+🧑🏼‍🦽|1603
+🧑🏽‍🦽|1604
+🧑🏾‍🦽|1605
+🧑🏿‍🦽|1606
+👨‍🦽|1607
+👨🏻‍🦽|1608
+👨🏼‍🦽|1609
+👨🏽‍🦽|1610
+👨🏾‍🦽|1611
+👨🏿‍🦽|1612
+👩‍🦽|1613
+👩🏻‍🦽|1614
+👩🏼‍🦽|1615
+👩🏽‍🦽|1616
+👩🏾‍🦽|1617
+👩🏿‍🦽|1618
 🏃|1619
 🏃🏻|1620
 🏃🏼|1621
 🏃🏽|1622
 🏃🏾|1623
 🏃🏿|1624
-🏃‍♀️|1631
-🏃🏻‍♀️|1632
-🏃🏼‍♀️|1633
-🏃🏽‍♀️|1634
-🏃🏾‍♀️|1635
-🏃🏿‍♀️|1636
 🏃‍♂️|1625
 🏃🏻‍♂️|1626
 🏃🏼‍♂️|1627
 🏃🏽‍♂️|1628
 🏃🏾‍♂️|1629
 🏃🏿‍♂️|1630
-🧍|1529
-🧍🏻|1530
-🧍🏼|1531
-🧍🏽|1532
-🧍🏾|1533
-🧍🏿|1534
-🧍‍♀️|1541
-🧍🏻‍♀️|1542
-🧍🏼‍♀️|1543
-🧍🏽‍♀️|1544
-🧍🏾‍♀️|1545
-🧍🏿‍♀️|1546
-🧍‍♂️|1535
-🧍🏻‍♂️|1536
-🧍🏼‍♂️|1537
-🧍🏽‍♂️|1538
-🧍🏾‍♂️|1539
-🧍🏿‍♂️|1540
-🧑‍🤝‍🧑|1957
-🧑🏻‍🤝‍🧑🏻|1958
-🧑🏼‍🤝‍🧑🏼|1964
-🧑🏽‍🤝‍🧑🏽|1970
-🧑🏾‍🤝‍🧑🏾|1976
-🧑🏿‍🤝‍🧑🏿|1982
-👫|2009
-👫🏻|2010
-👫🏼|2016
-👫🏽|2022
-👫🏾|2028
-👫🏿|2034
-👭|1983
-👭🏻|1984
-👭🏼|1990
-👭🏽|1996
-👭🏾|2002
-👭🏿|2008
-👬|2035
-👬🏻|2036
-👬🏼|2042
-👬🏽|2048
-👬🏾|2054
-👬🏿|2060
-💑|2165
-💑🏻|2166
-💑🏼|2167
-💑🏽|2168
-💑🏾|2169
-💑🏿|2170
-👩‍❤️‍👨|2191
-👩🏻‍❤️‍👨🏻|2192
-👩🏼‍❤️‍👨🏼|2198
-👩🏽‍❤️‍👨🏽|2204
-👩🏾‍❤️‍👨🏾|2210
-👩🏿‍❤️‍👨🏿|2216
-👩‍❤️‍👩|2243
-👩🏻‍❤️‍👩🏻|2244
-👩🏼‍❤️‍👩🏼|2250
-👩🏽‍❤️‍👩🏽|2256
-👩🏾‍❤️‍👩🏾|2262
-👩🏿‍❤️‍👩🏿|2268
-👨‍❤️‍👨|2217
-👨🏻‍❤️‍👨🏻|2218
-👨🏼‍❤️‍👨🏼|2224
-👨🏽‍❤️‍👨🏽|2230
-👨🏾‍❤️‍👨🏾|2236
-👨🏿‍❤️‍👨🏿|2242
-💏|2061
-💏🏻|2062
-💏🏼|2063
-💏🏽|2064
-💏🏾|2065
-💏🏿|2066
-👩‍❤️‍💋‍👨|2087
-👩🏻‍❤️‍💋‍👨🏻|2088
-👩🏼‍❤️‍💋‍👨🏼|2094
-👩🏽‍❤️‍💋‍👨🏽|2100
-👩🏾‍❤️‍💋‍👨🏾|2106
-👩🏿‍❤️‍💋‍👨🏿|2112
-👩‍❤️‍💋‍👩|2139
-👩🏻‍❤️‍💋‍👩🏻|2140
-👩🏼‍❤️‍💋‍👩🏼|2146
-👩🏽‍❤️‍💋‍👩🏽|2152
-👩🏾‍❤️‍💋‍👩🏾|2158
-👩🏿‍❤️‍💋‍👩🏿|2164
-👨‍❤️‍💋‍👨|2113
-👨🏻‍❤️‍💋‍👨🏻|2114
-👨🏼‍❤️‍💋‍👨🏼|2120
-👨🏽‍❤️‍💋‍👨🏽|2126
-👨🏾‍❤️‍💋‍👨🏾|2132
-👨🏿‍❤️‍💋‍👨🏿|2138
-👪|2269
-👨‍👩‍👦|2270
-👨‍👩‍👧|2271
-👨‍👩‍👧‍👦|2272
-👨‍👩‍👦‍👦|2273
-👨‍👩‍👧‍👧|2274
-👩‍👩‍👦|2280
-👩‍👩‍👧|2281
-👩‍👩‍👧‍👦|2282
-👩‍👩‍👦‍👦|2283
-👩‍👩‍👧‍👧|2284
-👨‍👨‍👦|2275
-👨‍👨‍👧|2276
-👨‍👨‍👧‍👦|2277
-👨‍👨‍👦‍👦|2278
-👨‍👨‍👧‍👧|2279
-👩‍👦|2290
-👩‍👧|2292
-👩‍👧‍👦|2293
-👩‍👦‍👦|2291
-👩‍👧‍👧|2294
-👨‍👦|2285
-👨‍👧|2287
-👨‍👧‍👦|2288
-👨‍👦‍👦|2286
-👨‍👧‍👧|2289
-🧶|2887
-🧵|2885
-🧥|2899
-🥼|2892
-🦺|2893
-👚|2908
-👕|2895
-👖|2896
-🩲|2905
-🩳|2906
-👔|2894
-👗|2901
-👙|2907
-🩱|2904
-👘|2902
-🥻|2903
-🥿|2918
-👠|2919
-👡|2920
-👢|2922
-👞|2915
-👟|2916
-🥾|2917
-🩴|2914
-🧦|2900
-🧤|2898
-🧣|2897
-🎩|2925
-🧢|2927
-👒|2924
-🎓|2926
-⛑️|2929
-🪖|2928
-👑|2923
-💍|2932
-👝|2911
-👛|2909
-👜|2910
-💼|3044
-🎒|2913
-🧳|2724
-👓|2889
-🕶️|2890
-🥽|2891
-🌂|2791
-🐶|2313
-🐱|2321
-🐭|2353
-🐹|2356
-🐰|2357
-🦊|2319
-🐻|2363
-🐼|2366
-🐻‍❄️|2364
-🐨|2365
-🐯|2325
-🦁|2324
-🐮|2334
-🐷|2338
-🐽|2341
-🐸|2391
-🐵|2309
-🙈|124
-🙉|125
-🙊|126
-🐒|2310
-🐔|2374
-🐧|2380
-🐦|2379
-🐤|2377
-🐣|2376
-🐥|2378
-🦆|2383
-🦤|2386
-🦅|2382
-🦉|2385
-🦇|2362
-🐺|2318
-🐗|2340
-🐴|2328
-🦄|2330
-🐝|2415
-🐛|2413
-🦋|2412
-🐌|2411
-🪱|2425
-🐞|2417
-🐜|2414
-🪰|2424
-🦟|2423
-🪳|2419
-🪲|2416
-🦗|2418
-🕷️|2420
-🕸️|2421
-🦂|2422
-🐢|2393
-🐍|2395
-🦎|2394
-🦖|2399
-🦕|2398
-🐙|2408
-🦑|2542
-🦐|2541
-🦞|2540
-🦀|2539
-🐡|2406
-🐠|2405
-🐟|2404
-🦭|2403
-🐬|2402
-🐳|2400
-🐋|2401
-🦈|2407
-🐊|2392
-🐅|2326
-🐆|2327
-🦓|2331
-🦍|2311
-🦧|2312
-🐘|2349
-🦣|2350
-🦬|2333
-🦛|2352
-🦏|2351
-🐪|2345
-🐫|2346
-🦒|2348
-🦘|2370
-🐃|2336
-🐂|2335
-🐄|2337
-🐎|2329
-🐖|2339
-🐏|2342
-🐑|2343
-🦙|2347
-🐐|2344
-🦌|2332
-🐕|2314
-🐩|2317
-🦮|2315
-🐕‍🦺|2316
-🐈|2322
-🐈‍⬛|2323
-🐓|2375
-🦃|2373
-🦚|2389
-🦜|2390
-🦢|2384
-🦩|2388
-🕊️|2381
-🐇|2358
-🦝|2320
-🦨|2369
-🦡|2371
-🦫|2360
-🦦|2368
-🦥|2367
-🐁|2354
-🐀|2355
-🐿️|2359
-🦔|2361
-🐾|2372
-🐉|2397
-🐲|2396
-🌵|2443
-🎄|2804
-🌲|2440
-🌳|2441
-🌴|2442
-🌱|2438
-🌿|2445
-🍀|2447
-🎍|2813
-🎋|2812
-🍃|2450
-🍂|2449
-🍁|2448
-🪶|2387
-🍄|2484
-🐚|2409
-🪨|2605
-🪵|2606
-🌾|2444
-🪴|2439
-💐|2427
-🌷|2437
-🌹|2432
-🥀|2433
-🌺|2434
-🌸|2428
-🌼|2436
-🌻|2435
-🌞|2771
-🌝|2770
-🌛|2766
-🌜|2767
-🌚|2765
-🌕|2760
-🌖|2761
-🌗|2762
-🌘|2763
-🌑|2756
-🌒|2757
-🌓|2758
-🌔|2759
-🌙|2764
-🌎|2586
-🌍|2585
-🌏|2587
-🪐|2772
-💫|153
-⭐|2773
-🌟|2774
-⚡|2795
-💥|152
-🔥|2800
-🌪️|2786
-🌈|2790
-🌤️|2780
-⛅|2778
-🌥️|2781
-🌦️|2782
-🌧️|2783
-⛈️|2779
-🌩️|2785
-🌨️|2784
-⛄|2798
-🌬️|2788
-💨|155
-💧|2801
-💦|154
-🌊|2802
-🌫️|2787
-🍏|2462
-🍎|2461
-🍐|2463
-🍊|2456
-🍋|2457
-🍌|2458
-🍉|2455
-🍇|2453
-🫐|2467
-🍓|2466
-🍈|2454
-🍒|2465
-🍑|2464
-🥭|2460
-🍍|2459
-🥥|2471
-🥝|2468
-🍅|2469
-🍆|2473
-🥑|2472
-🫒|2470
-🥦|2481
-🥬|2480
-🫑|2478
-🥒|2479
-🌶️|2477
-🌽|2476
-🥕|2475
-🧄|2482
-🧅|2483
-🥔|2474
-🍠|2529
-🥐|2489
-🥯|2493
-🍞|2488
-🥖|2490
-🫓|2491
-🥨|2492
-🧀|2496
-🥚|2511
-🍳|2512
-🧈|2519
-🥞|2494
-🧇|2495
-🥓|2500
-🥩|2499
-🍗|2498
-🍖|2497
-🌭|2504
-🍔|2501
-🍟|2502
-🍕|2503
-🥪|2505
-🥙|2509
-🧆|2510
-🌮|2506
-🌯|2507
-🫔|2508
-🥗|2517
-🥘|2513
-🫕|2515
-🥫|2521
-🍝|2528
-🍜|2527
-🍲|2514
-🍛|2526
-🍣|2531
-🍱|2522
-🥟|2536
-🦪|2543
-🍤|2532
-🍙|2524
-🍚|2525
-🍘|2523
-🍥|2533
-🥠|2537
-🥮|2534
-🍢|2530
-🍡|2535
-🍧|2545
-🍨|2546
-🍦|2544
-🥧|2552
-🧁|2551
-🍰|2550
-🎂|2549
-🍮|2556
-🍭|2555
-🍬|2554
-🍫|2553
-🍿|2518
-🍩|2547
-🍪|2548
-🌰|2487
-🥜|2485
-🍯|2557
-🥛|2559
-🍼|2558
-🍵|2562
-🫖|2561
-🧉|2576
-🧋|2574
-🧃|2575
-🥤|2573
-🍶|2563
-🍺|2568
-🍻|2569
-🥂|2570
-🍷|2565
-🥃|2571
-🍸|2566
-🍹|2567
-🍾|2564
-🧊|2577
-🥄|2581
-🍴|2580
-🍽️|2579
-🥣|2516
-🥡|2538
-🥢|2578
-🧂|2520
-⚽|2830
-🏀|2833
-🏈|2835
-⚾|2831
-🥎|2832
-🎾|2837
-🏐|2834
-🏉|2836
-🥏|2838
-🪃|3081
-🎱|2860
-🪀|2858
-🏓|2844
-🏸|2845
-🏒|2842
-🏑|2841
-🥍|2843
-🏏|2840
-🥅|2848
-⛳|2849
-🪁|2859
-🏹|3082
-🎣|2851
-🤿|2852
-🥊|2846
-🥋|2847
-🎽|2853
-🛹|2688
-🛼|2689
-🛷|2855
-⛸️|2850
-🥌|2856
-🎿|2854
+🏃‍♀️|1631
+🏃🏻‍♀️|1632
+🏃🏼‍♀️|1633
+🏃🏽‍♀️|1634
+🏃🏾‍♀️|1635
+🏃🏿‍♀️|1636
+💃|1637
+💃🏻|1638
+💃🏼|1639
+💃🏽|1640
+💃🏾|1641
+💃🏿|1642
+🕺|1643
+🕺🏻|1644
+🕺🏼|1645
+🕺🏽|1646
+🕺🏾|1647
+🕺🏿|1648
+🕴️|1649
+🕴🏻|1650
+🕴🏼|1651
+🕴🏽|1652
+🕴🏾|1653
+🕴🏿|1654
+👯|1655
+👯‍♂️|1656
+👯‍♀️|1657
+🧖|1658
+🧖🏻|1659
+🧖🏼|1660
+🧖🏽|1661
+🧖🏾|1662
+🧖🏿|1663
+🧖‍♂️|1664
+🧖🏻‍♂️|1665
+🧖🏼‍♂️|1666
+🧖🏽‍♂️|1667
+🧖🏾‍♂️|1668
+🧖🏿‍♂️|1669
+🧖‍♀️|1670
+🧖🏻‍♀️|1671
+🧖🏼‍♀️|1672
+🧖🏽‍♀️|1673
+🧖🏾‍♀️|1674
+🧖🏿‍♀️|1675
+🧗|1676
+🧗🏻|1677
+🧗🏼|1678
+🧗🏽|1679
+🧗🏾|1680
+🧗🏿|1681
+🧗‍♂️|1682
+🧗🏻‍♂️|1683
+🧗🏼‍♂️|1684
+🧗🏽‍♂️|1685
+🧗🏾‍♂️|1686
+🧗🏿‍♂️|1687
+🧗‍♀️|1688
+🧗🏻‍♀️|1689
+🧗🏼‍♀️|1690
+🧗🏽‍♀️|1691
+🧗🏾‍♀️|1692
+🧗🏿‍♀️|1693
+🤺|1694
+🏇|1695
+🏇🏻|1696
+🏇🏼|1697
+🏇🏽|1698
+🏇🏾|1699
+🏇🏿|1700
 ⛷️|1701
 🏂|1702
-🪂|2714
-🏋|1798
+🏌️|1708
+🏌🏻|1709
+🏌🏼|1710
+🏌🏽|1711
+🏌🏾|1712
+🏌🏿|1713
+🏌️‍♂️|1714
+🏌🏻‍♂️|1715
+🏌🏼‍♂️|1716
+🏌🏽‍♂️|1717
+🏌🏾‍♂️|1718
+🏌🏿‍♂️|1719
+🏌️‍♀️|1720
+🏌🏻‍♀️|1721
+🏌🏼‍♀️|1722
+🏌🏽‍♀️|1723
+🏌🏾‍♀️|1724
+🏌🏿‍♀️|1725
+🏄|1726
+🏄🏻|1727
+🏄🏼|1728
+🏄🏽|1729
+🏄🏾|1730
+🏄🏿|1731
+🏄‍♂️|1732
+🏄🏻‍♂️|1733
+🏄🏼‍♂️|1734
+🏄🏽‍♂️|1735
+🏄🏾‍♂️|1736
+🏄🏿‍♂️|1737
+🏄‍♀️|1738
+🏄🏻‍♀️|1739
+🏄🏼‍♀️|1740
+🏄🏽‍♀️|1741
+🏄🏾‍♀️|1742
+🏄🏿‍♀️|1743
+🚣|1744
+🚣🏻|1745
+🚣🏼|1746
+🚣🏽|1747
+🚣🏾|1748
+🚣🏿|1749
+🚣‍♂️|1750
+🚣🏻‍♂️|1751
+🚣🏼‍♂️|1752
+🚣🏽‍♂️|1753
+🚣🏾‍♂️|1754
+🚣🏿‍♂️|1755
+🚣‍♀️|1756
+🚣🏻‍♀️|1757
+🚣🏼‍♀️|1758
+🚣🏽‍♀️|1759
+🚣🏾‍♀️|1760
+🚣🏿‍♀️|1761
+🏊|1762
+🏊🏻|1763
+🏊🏼|1764
+🏊🏽|1765
+🏊🏾|1766
+🏊🏿|1767
+🏊‍♂️|1768
+🏊🏻‍♂️|1769
+🏊🏼‍♂️|1770
+🏊🏽‍♂️|1771
+🏊🏾‍♂️|1772
+🏊🏿‍♂️|1773
+🏊‍♀️|1774
+🏊🏻‍♀️|1775
+🏊🏼‍♀️|1776
+🏊🏽‍♀️|1777
+🏊🏾‍♀️|1778
+🏊🏿‍♀️|1779
+⛹️|1780
+⛹🏻|1781
+⛹🏼|1782
+⛹🏽|1783
+⛹🏾|1784
+⛹🏿|1785
+⛹️‍♂️|1786
+⛹🏻‍♂️|1787
+⛹🏼‍♂️|1788
+⛹🏽‍♂️|1789
+⛹🏾‍♂️|1790
+⛹🏿‍♂️|1791
+⛹️‍♀️|1792
+⛹🏻‍♀️|1793
+⛹🏼‍♀️|1794
+⛹🏽‍♀️|1795
+⛹🏾‍♀️|1796
+⛹🏿‍♀️|1797
+🏋️|1798
 🏋🏻|1799
 🏋🏼|1800
 🏋🏽|1801
 🏋🏾|1802
 🏋🏿|1803
-🏋️‍♀️|1810
-🏋🏻‍♀️|1811
-🏋🏼‍♀️|1812
-🏋🏽‍♀️|1813
-🏋🏾‍♀️|1814
-🏋🏿‍♀️|1815
 🏋️‍♂️|1804
 🏋🏻‍♂️|1805
 🏋🏼‍♂️|1806
 🏋🏽‍♂️|1807
 🏋🏾‍♂️|1808
 🏋🏿‍♂️|1809
-🤼|1870
-🤼‍♀️|1872
-🤼‍♂️|1871
+🏋️‍♀️|1810
+🏋🏻‍♀️|1811
+🏋🏼‍♀️|1812
+🏋🏽‍♀️|1813
+🏋🏾‍♀️|1814
+🏋🏿‍♀️|1815
+🚴|1816
+🚴🏻|1817
+🚴🏼|1818
+🚴🏽|1819
+🚴🏾|1820
+🚴🏿|1821
+🚴‍♂️|1822
+🚴🏻‍♂️|1823
+🚴🏼‍♂️|1824
+🚴🏽‍♂️|1825
+🚴🏾‍♂️|1826
+🚴🏿‍♂️|1827
+🚴‍♀️|1828
+🚴🏻‍♀️|1829
+🚴🏼‍♀️|1830
+🚴🏽‍♀️|1831
+🚴🏾‍♀️|1832
+🚴🏿‍♀️|1833
+🚵|1834
+🚵🏻|1835
+🚵🏼|1836
+🚵🏽|1837
+🚵🏾|1838
+🚵🏿|1839
+🚵‍♂️|1840
+🚵🏻‍♂️|1841
+🚵🏼‍♂️|1842
+🚵🏽‍♂️|1843
+🚵🏾‍♂️|1844
+🚵🏿‍♂️|1845
+🚵‍♀️|1846
+🚵🏻‍♀️|1847
+🚵🏼‍♀️|1848
+🚵🏽‍♀️|1849
+🚵🏾‍♀️|1850
+🚵🏿‍♀️|1851
 🤸|1852
 🤸🏻|1853
 🤸🏼|1854
 🤸🏽|1855
 🤸🏾|1856
 🤸🏿|1857
-🤸‍♀️|1864
-🤸🏻‍♀️|1865
-🤸🏼‍♀️|1866
-🤸🏽‍♀️|1867
-🤸🏾‍♀️|1868
-🤸🏿‍♀️|1869
 🤸‍♂️|1858
 🤸🏻‍♂️|1859
 🤸🏼‍♂️|1860
 🤸🏽‍♂️|1861
 🤸🏾‍♂️|1862
 🤸🏿‍♂️|1863
-⛹|1780
-⛹🏻|1781
-⛹🏼|1782
-⛹🏽|1783
-⛹🏾|1784
-⛹🏿|1785
-⛹️‍♀️|1792
-⛹🏻‍♀️|1793
-⛹🏼‍♀️|1794
-⛹🏽‍♀️|1795
-⛹🏾‍♀️|1796
-⛹🏿‍♀️|1797
-⛹️‍♂️|1786
-⛹🏻‍♂️|1787
-⛹🏼‍♂️|1788
-⛹🏽‍♂️|1789
-⛹🏾‍♂️|1790
-⛹🏿‍♂️|1791
-🤺|1694
+🤸‍♀️|1864
+🤸🏻‍♀️|1865
+🤸🏼‍♀️|1866
+🤸🏽‍♀️|1867
+🤸🏾‍♀️|1868
+🤸🏿‍♀️|1869
+🤼|1870
+🤼‍♂️|1871
+🤼‍♀️|1872
+🤽|1873
+🤽🏻|1874
+🤽🏼|1875
+🤽🏽|1876
+🤽🏾|1877
+🤽🏿|1878
+🤽‍♂️|1879
+🤽🏻‍♂️|1880
+🤽🏼‍♂️|1881
+🤽🏽‍♂️|1882
+🤽🏾‍♂️|1883
+🤽🏿‍♂️|1884
+🤽‍♀️|1885
+🤽🏻‍♀️|1886
+🤽🏼‍♀️|1887
+🤽🏽‍♀️|1888
+🤽🏾‍♀️|1889
+🤽🏿‍♀️|1890
 🤾|1891
 🤾🏻|1892
 🤾🏼|1893
 🤾🏽|1894
 🤾🏾|1895
 🤾🏿|1896
-🤾‍♀️|1903
-🤾🏻‍♀️|1904
-🤾🏼‍♀️|1905
-🤾🏽‍♀️|1906
-🤾🏾‍♀️|1907
-🤾🏿‍♀️|1908
 🤾‍♂️|1897
 🤾🏻‍♂️|1898
 🤾🏼‍♂️|1899
 🤾🏽‍♂️|1900
 🤾🏾‍♂️|1901
 🤾🏿‍♂️|1902
-🏌|1708
-🏌🏻|1709
-🏌🏼|1710
-🏌🏽|1711
-🏌🏾|1712
-🏌🏿|1713
-🏌️‍♀️|1720
-🏌🏻‍♀️|1721
-🏌🏼‍♀️|1722
-🏌🏽‍♀️|1723
-🏌🏾‍♀️|1724
-🏌🏿‍♀️|1725
-🏌️‍♂️|1714
-🏌🏻‍♂️|1715
-🏌🏼‍♂️|1716
-🏌🏽‍♂️|1717
-🏌🏾‍♂️|1718
-🏌🏿‍♂️|1719
-🏇|1695
-🏇🏻|1696
-🏇🏼|1697
-🏇🏽|1698
-🏇🏾|1699
-🏇🏿|1700
+🤾‍♀️|1903
+🤾🏻‍♀️|1904
+🤾🏼‍♀️|1905
+🤾🏽‍♀️|1906
+🤾🏾‍♀️|1907
+🤾🏿‍♀️|1908
+🤹|1909
+🤹🏻|1910
+🤹🏼|1911
+🤹🏽|1912
+🤹🏾|1913
+🤹🏿|1914
+🤹‍♂️|1915
+🤹🏻‍♂️|1916
+🤹🏼‍♂️|1917
+🤹🏽‍♂️|1918
+🤹🏾‍♂️|1919
+🤹🏿‍♂️|1920
+🤹‍♀️|1921
+🤹🏻‍♀️|1922
+🤹🏼‍♀️|1923
+🤹🏽‍♀️|1924
+🤹🏾‍♀️|1925
+🤹🏿‍♀️|1926
 🧘|1927
 🧘🏻|1928
 🧘🏼|1929
 🧘🏽|1930
 🧘🏾|1931
 🧘🏿|1932
-🧘‍♀️|1939
-🧘🏻‍♀️|1940
-🧘🏼‍♀️|1941
-🧘🏽‍♀️|1942
-🧘🏾‍♀️|1943
-🧘🏿‍♀️|1944
 🧘‍♂️|1933
 🧘🏻‍♂️|1934
 🧘🏼‍♂️|1935
 🧘🏽‍♂️|1936
 🧘🏾‍♂️|1937
 🧘🏿‍♂️|1938
-🏄|1726
-🏄🏻|1727
-🏄🏼|1728
-🏄🏽|1729
-🏄🏾|1730
-🏄🏿|1731
-🏄‍♀️|1738
-🏄🏻‍♀️|1739
-🏄🏼‍♀️|1740
-🏄🏽‍♀️|1741
-🏄🏾‍♀️|1742
-🏄🏿‍♀️|1743
-🏄‍♂️|1732
-🏄🏻‍♂️|1733
-🏄🏼‍♂️|1734
-🏄🏽‍♂️|1735
-🏄🏾‍♂️|1736
-🏄🏿‍♂️|1737
-🏊|1762
-🏊🏻|1763
-🏊🏼|1764
-🏊🏽|1765
-🏊🏾|1766
-🏊🏿|1767
-🏊‍♀️|1774
-🏊🏻‍♀️|1775
-🏊🏼‍♀️|1776
-🏊🏽‍♀️|1777
-🏊🏾‍♀️|1778
-🏊🏿‍♀️|1779
-🏊‍♂️|1768
-🏊🏻‍♂️|1769
-🏊🏼‍♂️|1770
-🏊🏽‍♂️|1771
-🏊🏾‍♂️|1772
-🏊🏿‍♂️|1773
-🤽|1873
-🤽🏻|1874
-🤽🏼|1875
-🤽🏽|1876
-🤽🏾|1877
-🤽🏿|1878
-🤽‍♀️|1885
-🤽🏻‍♀️|1886
-🤽🏼‍♀️|1887
-🤽🏽‍♀️|1888
-🤽🏾‍♀️|1889
-🤽🏿‍♀️|1890
-🤽‍♂️|1879
-🤽🏻‍♂️|1880
-🤽🏼‍♂️|1881
-🤽🏽‍♂️|1882
-🤽🏾‍♂️|1883
-🤽🏿‍♂️|1884
-🚣|1744
-🚣🏻|1745
-🚣🏼|1746
-🚣🏽|1747
-🚣🏾|1748
-🚣🏿|1749
-🚣‍♀️|1756
-🚣🏻‍♀️|1757
-🚣🏼‍♀️|1758
-🚣🏽‍♀️|1759
-🚣🏾‍♀️|1760
-🚣🏿‍♀️|1761
-🚣‍♂️|1750
-🚣🏻‍♂️|1751
-🚣🏼‍♂️|1752
-🚣🏽‍♂️|1753
-🚣🏾‍♂️|1754
-🚣🏿‍♂️|1755
-🧗|1676
-🧗🏻|1677
-🧗🏼|1678
-🧗🏽|1679
-🧗🏾|1680
-🧗🏿|1681
-🧗‍♀️|1688
-🧗🏻‍♀️|1689
-🧗🏼‍♀️|1690
-🧗🏽‍♀️|1691
-🧗🏾‍♀️|1692
-🧗🏿‍♀️|1693
-🧗‍♂️|1682
-🧗🏻‍♂️|1683
-🧗🏼‍♂️|1684
-🧗🏽‍♂️|1685
-🧗🏾‍♂️|1686
-🧗🏿‍♂️|1687
-🚵|1834
-🚵🏻|1835
-🚵🏼|1836
-🚵🏽|1837
-🚵🏾|1838
-🚵🏿|1839
-🚵‍♀️|1846
-🚵🏻‍♀️|1847
-🚵🏼‍♀️|1848
-🚵🏽‍♀️|1849
-🚵🏾‍♀️|1850
-🚵🏿‍♀️|1851
-🚵‍♂️|1840
-🚵🏻‍♂️|1841
-🚵🏼‍♂️|1842
-🚵🏽‍♂️|1843
-🚵🏾‍♂️|1844
-🚵🏿‍♂️|1845
-🚴|1816
-🚴🏻|1817
-🚴🏼|1818
-🚴🏽|1819
-🚴🏾|1820
-🚴🏿|1821
-🚴‍♀️|1828
-🚴🏻‍♀️|1829
-🚴🏼‍♀️|1830
-🚴🏽‍♀️|1831
-🚴🏾‍♀️|1832
-🚴🏿‍♀️|1833
-🚴‍♂️|1822
-🚴🏻‍♂️|1823
-🚴🏼‍♂️|1824
-🚴🏽‍♂️|1825
-🚴🏾‍♂️|1826
-🚴🏿‍♂️|1827
-🏆|2825
-🥇|2827
-🥈|2828
-🥉|2829
-🏅|2826
-🎖️|2824
+🧘‍♀️|1939
+🧘🏻‍♀️|1940
+🧘🏼‍♀️|1941
+🧘🏽‍♀️|1942
+🧘🏾‍♀️|1943
+🧘🏿‍♀️|1944
+🛀|1945
+🛀🏻|1946
+🛀🏼|1947
+🛀🏽|1948
+🛀🏾|1949
+🛀🏿|1950
+🛌|1951
+🧑‍🤝‍🧑|1957
+🧑🏻‍🤝‍🧑🏻|1958
+🧑🏼‍🤝‍🧑🏼|1964
+🧑🏽‍🤝‍🧑🏽|1970
+🧑🏾‍🤝‍🧑🏾|1976
+🧑🏿‍🤝‍🧑🏿|1982
+👭|1983
+👭🏻|1984
+👭🏼|1990
+👭🏽|1996
+👭🏾|2002
+👭🏿|2008
+👫|2009
+👫🏻|2010
+👫🏼|2016
+👫🏽|2022
+👫🏾|2028
+👫🏿|2034
+👬|2035
+👬🏻|2036
+👬🏼|2042
+👬🏽|2048
+👬🏾|2054
+👬🏿|2060
+💏|2061
+💏🏻|2062
+💏🏼|2063
+💏🏽|2064
+💏🏾|2065
+💏🏿|2066
+👩‍❤️‍💋‍👨|2087
+👩🏻‍❤️‍💋‍👨🏻|2088
+👩🏼‍❤️‍💋‍👨🏼|2094
+👩🏽‍❤️‍💋‍👨🏽|2100
+👩🏾‍❤️‍💋‍👨🏾|2106
+👩🏿‍❤️‍💋‍👨🏿|2112
+👨‍❤️‍💋‍👨|2113
+👨🏻‍❤️‍💋‍👨🏻|2114
+👨🏼‍❤️‍💋‍👨🏼|2120
+👨🏽‍❤️‍💋‍👨🏽|2126
+👨🏾‍❤️‍💋‍👨🏾|2132
+👨🏿‍❤️‍💋‍👨🏿|2138
+👩‍❤️‍💋‍👩|2139
+👩🏻‍❤️‍💋‍👩🏻|2140
+👩🏼‍❤️‍💋‍👩🏼|2146
+👩🏽‍❤️‍💋‍👩🏽|2152
+👩🏾‍❤️‍💋‍👩🏾|2158
+👩🏿‍❤️‍💋‍👩🏿|2164
+💑|2165
+💑🏻|2166
+💑🏼|2167
+💑🏽|2168
+💑🏾|2169
+💑🏿|2170
+👩‍❤️‍👨|2191
+👩🏻‍❤️‍👨🏻|2192
+👩🏼‍❤️‍👨🏼|2198
+👩🏽‍❤️‍👨🏽|2204
+👩🏾‍❤️‍👨🏾|2210
+👩🏿‍❤️‍👨🏿|2216
+👨‍❤️‍👨|2217
+👨🏻‍❤️‍👨🏻|2218
+👨🏼‍❤️‍👨🏼|2224
+👨🏽‍❤️‍👨🏽|2230
+👨🏾‍❤️‍👨🏾|2236
+👨🏿‍❤️‍👨🏿|2242
+👩‍❤️‍👩|2243
+👩🏻‍❤️‍👩🏻|2244
+👩🏼‍❤️‍👩🏼|2250
+👩🏽‍❤️‍👩🏽|2256
+👩🏾‍❤️‍👩🏾|2262
+👩🏿‍❤️‍👩🏿|2268
+👪|2269
+👨‍👩‍👦|2270
+👨‍👩‍👧|2271
+👨‍👩‍👧‍👦|2272
+👨‍👩‍👦‍👦|2273
+👨‍👩‍👧‍👧|2274
+👨‍👨‍👦|2275
+👨‍👨‍👧|2276
+👨‍👨‍👧‍👦|2277
+👨‍👨‍👦‍👦|2278
+👨‍👨‍👧‍👧|2279
+👩‍👩‍👦|2280
+👩‍👩‍👧|2281
+👩‍👩‍👧‍👦|2282
+👩‍👩‍👦‍👦|2283
+👩‍👩‍👧‍👧|2284
+👨‍👦|2285
+👨‍👦‍👦|2286
+👨‍👧|2287
+👨‍👧‍👦|2288
+👨‍👧‍👧|2289
+👩‍👦|2290
+👩‍👦‍👦|2291
+👩‍👧|2292
+👩‍👧‍👦|2293
+👩‍👧‍👧|2294
+🗣️|2295
+👤|2296
+👥|2297
+🫂|2298
+👣|2299
+🐵|2309
+🐒|2310
+🦍|2311
+🦧|2312
+🐶|2313
+🐕|2314
+🦮|2315
+🐕‍🦺|2316
+🐩|2317
+🐺|2318
+🦊|2319
+🦝|2320
+🐱|2321
+🐈|2322
+🐈‍⬛|2323
+🦁|2324
+🐯|2325
+🐅|2326
+🐆|2327
+🐴|2328
+🐎|2329
+🦄|2330
+🦓|2331
+🦌|2332
+🦬|2333
+🐮|2334
+🐂|2335
+🐃|2336
+🐄|2337
+🐷|2338
+🐖|2339
+🐗|2340
+🐽|2341
+🐏|2342
+🐑|2343
+🐐|2344
+🐪|2345
+🐫|2346
+🦙|2347
+🦒|2348
+🐘|2349
+🦣|2350
+🦏|2351
+🦛|2352
+🐭|2353
+🐁|2354
+🐀|2355
+🐹|2356
+🐰|2357
+🐇|2358
+🐿️|2359
+🦫|2360
+🦔|2361
+🦇|2362
+🐻|2363
+🐻‍❄️|2364
+🐨|2365
+🐼|2366
+🦥|2367
+🦦|2368
+🦨|2369
+🦘|2370
+🦡|2371
+🐾|2372
+🦃|2373
+🐔|2374
+🐓|2375
+🐣|2376
+🐤|2377
+🐥|2378
+🐦|2379
+🐧|2380
+🕊️|2381
+🦅|2382
+🦆|2383
+🦢|2384
+🦉|2385
+🦤|2386
+🪶|2387
+🦩|2388
+🦚|2389
+🦜|2390
+🐸|2391
+🐊|2392
+🐢|2393
+🦎|2394
+🐍|2395
+🐲|2396
+🐉|2397
+🦕|2398
+🦖|2399
+🐳|2400
+🐋|2401
+🐬|2402
+🦭|2403
+🐟|2404
+🐠|2405
+🐡|2406
+🦈|2407
+🐙|2408
+🐚|2409
+🪸|2410
+🐌|2411
+🦋|2412
+🐛|2413
+🐜|2414
+🐝|2415
+🪲|2416
+🐞|2417
+🦗|2418
+🪳|2419
+🕷️|2420
+🕸️|2421
+🦂|2422
+🦟|2423
+🪰|2424
+🪱|2425
+🦠|2426
+💐|2427
+🌸|2428
+💮|2429
+🪷|2430
 🏵️|2431
-🎗️|2821
-🎫|2823
-🎟️|2822
+🌹|2432
+🥀|2433
+🌺|2434
+🌻|2435
+🌼|2436
+🌷|2437
+🌱|2438
+🪴|2439
+🌲|2440
+🌳|2441
+🌴|2442
+🌵|2443
+🌾|2444
+🌿|2445
+🍀|2447
+🍁|2448
+🍂|2449
+🍃|2450
+🪹|2451
+🪺|2452
+🍇|2453
+🍈|2454
+🍉|2455
+🍊|2456
+🍋|2457
+🍌|2458
+🍍|2459
+🥭|2460
+🍎|2461
+🍏|2462
+🍐|2463
+🍑|2464
+🍒|2465
+🍓|2466
+🫐|2467
+🥝|2468
+🍅|2469
+🫒|2470
+🥥|2471
+🥑|2472
+🍆|2473
+🥔|2474
+🥕|2475
+🌽|2476
+🌶️|2477
+🫑|2478
+🥒|2479
+🥬|2480
+🥦|2481
+🧄|2482
+🧅|2483
+🍄|2484
+🥜|2485
+🫘|2486
+🌰|2487
+🍞|2488
+🥐|2489
+🥖|2490
+🫓|2491
+🥨|2492
+🥯|2493
+🥞|2494
+🧇|2495
+🧀|2496
+🍖|2497
+🍗|2498
+🥩|2499
+🥓|2500
+🍔|2501
+🍟|2502
+🍕|2503
+🌭|2504
+🥪|2505
+🌮|2506
+🌯|2507
+🫔|2508
+🥙|2509
+🧆|2510
+🥚|2511
+🍳|2512
+🥘|2513
+🍲|2514
+🫕|2515
+🥣|2516
+🥗|2517
+🍿|2518
+🧈|2519
+🧂|2520
+🥫|2521
+🍱|2522
+🍘|2523
+🍙|2524
+🍚|2525
+🍛|2526
+🍜|2527
+🍝|2528
+🍠|2529
+🍢|2530
+🍣|2531
+🍤|2532
+🍥|2533
+🥮|2534
+🍡|2535
+🥟|2536
+🥠|2537
+🥡|2538
+🦀|2539
+🦞|2540
+🦐|2541
+🦑|2542
+🦪|2543
+🍦|2544
+🍧|2545
+🍨|2546
+🍩|2547
+🍪|2548
+🎂|2549
+🍰|2550
+🧁|2551
+🥧|2552
+🍫|2553
+🍬|2554
+🍭|2555
+🍮|2556
+🍯|2557
+🍼|2558
+🥛|2559
+🫖|2561
+🍵|2562
+🍶|2563
+🍾|2564
+🍷|2565
+🍸|2566
+🍹|2567
+🍺|2568
+🍻|2569
+🥂|2570
+🥃|2571
+🫗|2572
+🥤|2573
+🧋|2574
+🧃|2575
+🧉|2576
+🧊|2577
+🥢|2578
+🍽️|2579
+🍴|2580
+🥄|2581
+🔪|2582
+🫙|2583
+🏺|2584
+🌍|2585
+🌎|2586
+🌏|2587
+🌐|2588
+🗺️|2589
+🗾|2590
+🧭|2591
+🏔️|2592
+⛰️|2593
+🌋|2594
+🗻|2595
+🏕️|2596
+🏖️|2597
+🏜️|2598
+🏝️|2599
+🏞️|2600
+🏟️|2601
+🏛️|2602
+🏗️|2603
+🧱|2604
+🪨|2605
+🪵|2606
+🛖|2607
+🏘️|2608
+🏚️|2609
+🏠|2610
+🏡|2611
+🏢|2612
+🏣|2613
+🏤|2614
+🏥|2615
+🏦|2616
+🏨|2617
+🏩|2618
+🏪|2619
+🏫|2620
+🏬|2621
+🏭|2622
+🏯|2623
+🏰|2624
+💒|2625
+🗼|2626
+🗽|2627
+⛪|2628
+🕌|2629
+🛕|2630
+🕍|2631
+⛩️|2632
+🕋|2633
+⛲|2634
+⛺|2635
+🌁|2636
+🌃|2637
+🏙️|2638
+🌄|2639
+🌅|2640
+🌆|2641
+🌇|2642
+🌉|2643
+🎠|2645
+🛝|2646
+🎡|2647
+🎢|2648
+💈|2649
 🎪|2650
-🤹|1909
-🤹🏻|1910
-🤹🏼|1911
-🤹🏽|1912
-🤹🏾|1913
-🤹🏿|1914
-🤹‍♀️|1921
-🤹🏻‍♀️|1922
-🤹🏼‍♀️|1923
-🤹🏽‍♀️|1924
-🤹🏾‍♀️|1925
-🤹🏿‍♀️|1926
-🤹‍♂️|1915
-🤹🏻‍♂️|1916
-🤹🏼‍♂️|1917
-🤹🏽‍♂️|1918
-🤹🏾‍♂️|1919
-🤹🏿‍♂️|1920
-🎭|2882
-🩰|2921
-🎨|2884
-🎬|2984
-🎤|2949
-🎧|2950
-🎼|2943
-🎹|2955
-🥁|2959
-🪘|2960
-🎷|2952
-🎺|2956
-🎸|2954
-🪕|2958
-🎻|2957
-🪗|2953
-🎲|2868
-♟️|2878
-🎯|2857
-🎳|2839
-🎮|2865
-🎰|2867
-🧩|2869
-🚗|2673
-🚕|2671
-🚙|2675
-🛻|2676
+🚂|2651
+🚃|2652
+🚄|2653
+🚅|2654
+🚆|2655
+🚇|2656
+🚈|2657
+🚉|2658
+🚊|2659
+🚝|2660
+🚞|2661
+🚋|2662
 🚌|2663
+🚍|2664
 🚎|2665
-🏎️|2680
-🚓|2669
+🚐|2666
 🚑|2667
 🚒|2668
-🚐|2666
+🚓|2669
+🚔|2670
+🚕|2671
+🚖|2672
+🚗|2673
+🚘|2674
+🚙|2675
+🛻|2676
 🚚|2677
 🚛|2678
 🚜|2679
-🦯|3091
+🏎️|2680
+🏍️|2681
+🛵|2682
 🦽|2683
 🦼|2684
-🛴|2687
-🚲|2686
-🛵|2682
-🏍️|2681
 🛺|2685
+🚲|2686
+🛴|2687
+🛹|2688
+🛼|2689
+🚏|2690
+🛣️|2691
+🛤️|2692
+🛢️|2693
+⛽|2694
+🛞|2695
 🚨|2696
-🚔|2670
-🚍|2664
-🚘|2674
-🚖|2672
-🚡|2719
-🚠|2718
-🚟|2717
-🚃|2652
-🚋|2662
-🚞|2661
-🚝|2660
-🚄|2653
-🚅|2654
-🚈|2657
-🚂|2651
-🚆|2655
-🚇|2656
-🚊|2659
-🚉|2658
+🚥|2697
+🚦|2698
+🛑|2699
+🚧|2700
+🛟|2702
+⛵|2703
+🛶|2704
+🚤|2705
+🛳️|2706
+⛴️|2707
+🛥️|2708
+🚢|2709
+🛩️|2711
 🛫|2712
 🛬|2713
-🛩️|2711
+🪂|2714
 💺|2715
+🚁|2716
+🚟|2717
+🚠|2718
+🚡|2719
 🛰️|2720
 🚀|2721
 🛸|2722
-🚁|2716
-🛶|2704
-⛵|2703
-🚤|2705
-🛥️|2708
-🛳️|2706
-⛴️|2707
-🚢|2709
-⛽|2694
-🚧|2700
-🚦|2698
-🚥|2697
-🚏|2690
-🗺️|2589
-🗿|3141
-🗽|2627
-🗼|2626
-🏰|2624
-🏯|2623
-🏟️|2601
-🎡|2647
-🎢|2648
-🎠|2645
-⛲|2634
-⛱️|2794
-🏖️|2597
-🏝️|2599
-🏜️|2598
-🌋|2594
-⛰️|2593
-🏔️|2592
-🗻|2595
-🏕️|2596
-⛺|2635
-🏠|2610
-🏡|2611
-🏘️|2608
-🏚️|2609
-🛖|2607
-🏗️|2603
-🏭|2622
-🏢|2612
-🏬|2621
-🏣|2613
-🏤|2614
-🏥|2615
-🏦|2616
-🏨|2617
-🏪|2619
-🏫|2620
-🏩|2618
-💒|2625
-🏛️|2602
-⛪|2628
-🕌|2629
-🕍|2631
-🛕|2630
-🕋|2633
-⛩️|2632
-🛤️|2692
-🛣️|2691
-🗾|2590
-🎑|2817
-🏞️|2600
-🌅|2640
-🌄|2639
+🛎️|2723
+🧳|2724
+⌛|2725
+⏳|2726
+⌚|2727
+⏰|2728
+⏱️|2729
+⏲️|2730
+🕰️|2731
+🕛|2732
+🕧|2733
+🕐|2734
+🕜|2735
+🕑|2736
+🕝|2737
+🕒|2738
+🕞|2739
+🕓|2740
+🕟|2741
+🕔|2742
+🕠|2743
+🕕|2744
+🕡|2745
+🕖|2746
+🕢|2747
+🕗|2748
+🕣|2749
+🕘|2750
+🕤|2751
+🕙|2752
+🕥|2753
+🕚|2754
+🕦|2755
+🌑|2756
+🌒|2757
+🌓|2758
+🌔|2759
+🌕|2760
+🌖|2761
+🌗|2762
+🌘|2763
+🌙|2764
+🌚|2765
+🌛|2766
+🌜|2767
+🌡️|2768
+🌝|2770
+🌞|2771
+🪐|2772
+⭐|2773
+🌟|2774
 🌠|2775
-🎇|2806
-🎆|2805
-🌇|2642
-🌆|2641
-🏙️|2638
-🌃|2637
 🌌|2776
-🌉|2643
-🌁|2636
-⌚|2727
+⛅|2778
+⛈️|2779
+🌤️|2780
+🌥️|2781
+🌦️|2782
+🌧️|2783
+🌨️|2784
+🌩️|2785
+🌪️|2786
+🌫️|2787
+🌬️|2788
+🌀|2789
+🌈|2790
+🌂|2791
+⛱️|2794
+⚡|2795
+⛄|2798
+🔥|2800
+💧|2801
+🌊|2802
+🎃|2803
+🎄|2804
+🎆|2805
+🎇|2806
+🧨|2807
+🎈|2809
+🎉|2810
+🎊|2811
+🎋|2812
+🎍|2813
+🎎|2814
+🎏|2815
+🎐|2816
+🎑|2817
+🧧|2818
+🎀|2819
+🎁|2820
+🎗️|2821
+🎟️|2822
+🎫|2823
+🎖️|2824
+🏆|2825
+🏅|2826
+🥇|2827
+🥈|2828
+🥉|2829
+⚽|2830
+⚾|2831
+🥎|2832
+🏀|2833
+🏐|2834
+🏈|2835
+🏉|2836
+🎾|2837
+🥏|2838
+🎳|2839
+🏏|2840
+🏑|2841
+🏒|2842
+🥍|2843
+🏓|2844
+🏸|2845
+🥊|2846
+🥋|2847
+🥅|2848
+⛳|2849
+⛸️|2850
+🎣|2851
+🤿|2852
+🎽|2853
+🎿|2854
+🛷|2855
+🥌|2856
+🎯|2857
+🪀|2858
+🪁|2859
+🎱|2860
+🔮|2861
+🪄|2862
+🧿|2863
+🪬|2864
+🎮|2865
+🕹️|2866
+🎰|2867
+🎲|2868
+🧩|2869
+🧸|2870
+🪅|2871
+🪩|2872
+🪆|2873
+♟️|2878
+🃏|2879
+🀄|2880
+🎴|2881
+🎭|2882
+🖼️|2883
+🎨|2884
+🧵|2885
+🪡|2886
+🧶|2887
+🪢|2888
+👓|2889
+🕶️|2890
+🥽|2891
+🥼|2892
+🦺|2893
+👔|2894
+👕|2895
+👖|2896
+🧣|2897
+🧤|2898
+🧥|2899
+🧦|2900
+👗|2901
+👘|2902
+🥻|2903
+🩱|2904
+🩲|2905
+🩳|2906
+👙|2907
+👚|2908
+👛|2909
+👜|2910
+👝|2911
+🛍️|2912
+🎒|2913
+🩴|2914
+👞|2915
+👟|2916
+🥾|2917
+🥿|2918
+👠|2919
+👡|2920
+🩰|2921
+👢|2922
+👑|2923
+👒|2924
+🎩|2925
+🎓|2926
+🧢|2927
+🪖|2928
+⛑️|2929
+📿|2930
+💄|2931
+💍|2932
+💎|2933
+🔇|2934
+🔈|2935
+🔉|2936
+🔊|2937
+📢|2938
+📣|2939
+📯|2940
+🔔|2941
+🔕|2942
+🎼|2943
+🎵|2944
+🎶|2945
+🎙️|2946
+🎚️|2947
+🎛️|2948
+🎤|2949
+🎧|2950
+📻|2951
+🎷|2952
+🪗|2953
+🎸|2954
+🎹|2955
+🎺|2956
+🎻|2957
+🪕|2958
+🥁|2959
+🪘|2960
 📱|2961
 📲|2962
+☎️|2963
+📞|2964
+📟|2965
+📠|2966
+🔋|2967
+🪫|2968
+🔌|2969
 💻|2970
 🖥️|2971
 🖨️|2972
 🖱️|2974
 🖲️|2975
-🕹️|2866
-🗜️|3089
 💽|2976
 💾|2977
 💿|2978
 📀|2979
-📼|2989
-📷|2986
-📸|2987
-📹|2988
+🧮|2980
 🎥|2981
-📽️|2983
 🎞️|2982
-📞|2964
-☎️|2963
-📟|2965
-📠|2966
+📽️|2983
+🎬|2984
 📺|2985
-📻|2951
-🎙️|2946
-🎚️|2947
-🎛️|2948
-🧭|2591
-⏱️|2729
-⏲️|2730
-⏰|2728
-🕰️|2731
-⌛|2725
-⏳|2726
-📡|3104
-🔋|2967
-🔌|2969
+📷|2986
+📸|2987
+📹|2988
+📼|2989
+🔍|2990
+🔎|2991
+🕯️|2992
 💡|2993
 🔦|2994
-🕯️|2992
+🏮|2995
 🪔|2996
-🧯|3135
-🛢️|2693
-💸|3020
-💵|3017
+📔|2997
+📕|2998
+📖|2999
+📗|3000
+📘|3001
+📙|3002
+📚|3003
+📓|3004
+📒|3005
+📃|3006
+📜|3007
+📄|3008
+📰|3009
+🗞️|3010
+📑|3011
+🔖|3012
+🏷️|3013
+💰|3014
+🪙|3015
 💴|3016
+💵|3017
 💶|3018
 💷|3019
-🪙|3015
-💰|3014
+💸|3020
 💳|3021
-💎|2933
-🪜|3097
-🧰|3095
-🪛|3086
-🔧|3085
-🔨|3073
-🛠️|3077
-⛏️|3075
-🔩|3087
-🧱|2604
-⛓️|3093
-🪝|3094
-🪢|2888
-🧲|3096
-🔫|3080
-💣|157
-🧨|2807
-🪓|3074
-🪚|3084
-🔪|2582
-🗡️|3078
-🛡️|3083
-🚬|3137
-⚰️|3138
-🪦|3139
-⚱️|3140
-🏺|2584
-🪄|2862
-🔮|2861
-📿|2930
-🧿|2863
-💈|2649
-🔭|3103
-🔬|3102
-🕳️|156
-🪟|3115
-🩹|3108
-🩺|3110
-💊|3107
-💉|3105
-🩸|3106
-🧬|3101
-🦠|2426
-🧫|3100
-🧪|3099
-🌡️|2768
-🪤|3123
-🧹|3127
-🧺|3128
-🪡|2886
-🧻|3129
-🚽|3119
-🪠|3120
-🪣|3130
-🚰|3146
-🚿|3121
-🛁|3122
-🛀|1945
-🛀🏻|1946
-🛀🏼|1947
-🛀🏽|1948
-🛀🏾|1949
-🛀🏿|1950
-🪥|3133
-🧼|3131
-🪒|3124
-🧽|3134
-🧴|3125
-🛎️|2723
-🔑|3071
-🗝️|3072
-🚪|3112
-🪑|3118
-🪞|3114
-🛋️|3117
-🛏️|3116
-🛌|1951
-🧸|2870
-🖼️|2883
-🛍️|2912
-🛒|3136
-🎁|2820
-🎈|2809
-🎏|2815
-🎀|2819
-🎊|2811
-🎉|2810
-🪅|2871
-🪆|2873
-🎎|2814
-🏮|2995
-🎐|2816
-🧧|2818
-📩|3027
-📨|3026
+🧾|3022
+💹|3023
 📧|3025
-💌|128
-📥|3029
+📨|3026
+📩|3027
 📤|3028
+📥|3029
 📦|3030
-🏷️|3013
-📪|3032
 📫|3031
+📪|3032
 📬|3033
 📭|3034
 📮|3035
-📯|2940
-🪧|3142
-📜|3007
-📃|3006
-📄|3008
-📑|3011
-🧾|3022
-📊|3055
-📈|3053
-📉|3054
+🗳️|3036
+✏️|3037
+🖋️|3039
+🖊️|3040
+🖌️|3041
+🖍️|3042
+📝|3043
+💼|3044
+📁|3045
+📂|3046
+🗂️|3047
+📅|3048
+📆|3049
 🗒️|3050
 🗓️|3051
-📆|3049
-📅|3048
-🗑️|3066
 📇|3052
-🗃️|3064
-🗳️|3036
-🗄️|3065
+📈|3053
+📉|3054
+📊|3055
 📋|3056
-📁|3045
-📂|3046
-🗂️|3047
-🗞️|3010
-📰|3009
-📓|3004
-📔|2997
-📒|3005
-📕|2998
-📗|3000
-📘|3001
-📙|3002
-📚|3003
-📖|2999
-🔖|3012
-🧷|3126
-🔗|3092
+📌|3057
+📍|3058
 📎|3059
 🖇️|3060
-📐|3062
 📏|3061
-🧮|2980
-📌|3057
-📍|3058
-🖊️|3040
-🖋️|3039
-🖌️|3041
-🖍️|3042
-📝|3043
-✏️|3037
-🔍|2990
-🔎|2991
-🔏|3069
-🔐|3070
+📐|3062
+🗃️|3064
+🗄️|3065
+🗑️|3066
 🔒|3067
 🔓|3068
-🧡|142
-💛|143
-💚|144
-💙|145
-💜|146
-🖤|148
-🤎|147
-🤍|149
-💔|138
-💕|135
-💞|134
-💓|133
-💗|132
-💖|131
-💘|129
-💝|130
-❤️‍🩹|140
-❤️‍🔥|139
-💟|136
-☮️|3200
-✝️|3197
-☪️|3199
-🕉️|3193
-🔯|3202
-🕎|3201
-☯️|3196
-🛐|3191
-⛎|3215
-♊|3205
-♋|3206
-♌|3207
-♍|3208
-♎|3209
-♏|3210
-🆔|3304
-⚛️|3192
-🉑|3323
-📴|3239
-📳|3238
-🈶|3317
-🈚|3321
-🈸|3324
-🈺|3329
-🈷️|3316
-🆚|3313
-💮|2429
-🉐|3319
-🈴|3325
-🈵|3330
-🈹|3320
-🈲|3322
-🅰️|3297
-🅱️|3299
-🆎|3298
-🆑|3300
-🅾️|3308
-🆘|3311
-❌|3268
-⭕|3264
-🛑|2699
-⛔|3159
-📛|3262
-🚫|3160
-💯|150
-💢|151
-🚷|3165
-🚯|3163
-🚳|3161
-🚱|3164
-🔞|3167
-📵|3166
-🚭|3162
-‼️|3249
-🔅|3235
-🔆|3236
-〽️|3272
-⚠️|3157
-🚸|3158
-🔱|3261
-⚜️|3260
-🔰|3263
-♻️|3259
-🈯|3318
-💹|3023
-❎|3269
-🌐|2588
-💠|3361
-Ⓜ️|3305
-🌀|2789
-💤|163
+🔏|3069
+🔐|3070
+🔑|3071
+🗝️|3072
+🔨|3073
+🪓|3074
+⛏️|3075
+🛠️|3077
+🗡️|3078
+🔫|3080
+🪃|3081
+🏹|3082
+🛡️|3083
+🪚|3084
+🔧|3085
+🪛|3086
+🔩|3087
+🗜️|3089
+🦯|3091
+🔗|3092
+⛓️|3093
+🪝|3094
+🧰|3095
+🧲|3096
+🪜|3097
+🧪|3099
+🧫|3100
+🧬|3101
+🔬|3102
+🔭|3103
+📡|3104
+💉|3105
+🩸|3106
+💊|3107
+🩹|3108
+🩼|3109
+🩺|3110
+🩻|3111
+🚪|3112
+🛗|3113
+🪞|3114
+🪟|3115
+🛏️|3116
+🛋️|3117
+🪑|3118
+🚽|3119
+🪠|3120
+🚿|3121
+🛁|3122
+🪤|3123
+🪒|3124
+🧴|3125
+🧷|3126
+🧹|3127
+🧺|3128
+🧻|3129
+🪣|3130
+🧼|3131
+🫧|3132
+🪥|3133
+🧽|3134
+🧯|3135
+🛒|3136
+🚬|3137
+⚰️|3138
+🪦|3139
+⚱️|3140
+🗿|3141
+🪧|3142
+🪪|3143
 🏧|3144
-🚾|3152
+🚮|3145
+🚰|3146
 ♿|3147
-🅿️|3310
-🈳|3326
-🈂️|3315
-🛂|3153
-🛃|3154
-🛄|3155
-🛅|3156
-🛗|3113
 🚹|3148
 🚺|3149
-🚼|3151
 🚻|3150
-🚮|3145
-🎦|3234
-📶|3237
-🈁|3314
-🔣|3295
-🔤|3296
-🔡|3293
-🔠|3292
-🆖|3307
-🆗|3309
-🆙|3312
-🆒|3301
-🆕|3306
-🆓|3302
-0️⃣|3281
-1️⃣|3282
-2️⃣|3283
-3️⃣|3284
-4️⃣|3285
-5️⃣|3286
-6️⃣|3287
-7️⃣|3288
-8️⃣|3289
-9️⃣|3290
-🔟|3291
-🔢|3294
-#️⃣|3279
-*️⃣|3280
-⏏️|3233
-▶️|3219
-⏸️|3230
-⏯️|3222
-⏹️|3231
-⏺️|3232
-⏭️|3221
-⏮️|3225
-⏩|3220
-⏪|3224
-⏫|3227
-⏬|3229
-◀️|3223
-🔼|3226
-🔽|3228
-➡️|3172
-⬅️|3176
+🚼|3151
+🚾|3152
+🛂|3153
+🛃|3154
+🛄|3155
+🛅|3156
+⚠️|3157
+🚸|3158
+⛔|3159
+🚫|3160
+🚳|3161
+🚭|3162
+🚯|3163
+🚱|3164
+🚷|3165
+📵|3166
+🔞|3167
 ⬆️|3170
+➡️|3172
 ⬇️|3174
-↪️|3181
+⬅️|3176
 ↩️|3180
+↪️|3181
+🔃|3184
+🔄|3185
+🔙|3186
+🔚|3187
+🔛|3188
+🔜|3189
+🔝|3190
+🛐|3191
+⚛️|3192
+🕉️|3193
+☯️|3196
+✝️|3197
+☪️|3199
+☮️|3200
+🕎|3201
+🔯|3202
+♊|3205
+♋|3206
+♌|3207
+♍|3208
+♎|3209
+♏|3210
+⛎|3215
 🔀|3216
 🔁|3217
 🔂|3218
-🔄|3185
-🔃|3184
-🎵|2944
-🎶|2945
+▶️|3219
+⏩|3220
+⏭️|3221
+⏯️|3222
+◀️|3223
+⏪|3224
+⏮️|3225
+🔼|3226
+⏫|3227
+🔽|3228
+⏬|3229
+⏸️|3230
+⏹️|3231
+⏺️|3232
+⏏️|3233
+🎦|3234
+🔅|3235
+🔆|3236
+📶|3237
+📳|3238
+📴|3239
+⚧️|3242
+🟰|3247
 ♾️|3248
-💲|3257
+‼️|3249
 💱|3256
-©️|3276
-®️|3277
+💲|3257
+♻️|3259
+⚜️|3260
+🔱|3261
+📛|3262
+🔰|3263
+⭕|3264
+❌|3268
+❎|3269
 ➰|3270
 ➿|3271
-🔚|3187
-🔙|3186
-🔛|3188
-🔝|3190
-🔜|3189
-🔘|3362
-⚪|3339
-⚫|3338
+〽️|3272
+©️|3276
+®️|3277
+🔟|3291
+🔠|3292
+🔡|3293
+🔢|3294
+🔣|3295
+🔤|3296
+🅰️|3297
+🆎|3298
+🅱️|3299
+🆑|3300
+🆒|3301
+🆓|3302
+🆔|3304
+Ⓜ️|3305
+🆕|3306
+🆖|3307
+🅾️|3308
+🆗|3309
+🅿️|3310
+🆘|3311
+🆙|3312
+🆚|3313
+🈁|3314
+🈂️|3315
+🈷️|3316
+🈶|3317
+🈯|3318
+🉐|3319
+🈹|3320
+🈚|3321
+🈲|3322
+🉑|3323
+🈸|3324
+🈴|3325
+🈳|3326
+🈺|3329
+🈵|3330
 🔴|3331
+🟠|3332
+🟡|3333
+🟢|3334
 🔵|3335
-🟤|3337
 🟣|3336
-🟢|3334
-🟡|3333
-🟠|3332
-🔺|3359
-🔻|3360
-🔸|3357
-🔹|3358
+🟤|3337
+⚫|3338
+⚪|3339
+🟥|3340
+🟧|3341
+🟨|3342
+🟩|3343
+🟦|3344
+🟪|3345
+🟫|3346
+⬛|3347
+⬜|3348
+◼️|3349
+◻️|3350
+◾|3351
+◽|3352
+▪️|3353
+▫️|3354
 🔶|3355
 🔷|3356
+🔸|3357
+🔹|3358
+🔺|3359
+🔻|3360
+💠|3361
+🔘|3362
 🔳|3363
 🔲|3364
-▪️|3353
-▫️|3354
-◾|3351
-◽|3352
-◼️|3349
-◻️|3350
-⬛|3347
-⬜|3348
-🟧|3341
-🟦|3344
-🟥|3340
-🟫|3346
-🟪|3345
-🟩|3343
-🟨|3342
-🔈|2935
-🔇|2934
-🔉|2936
-🔊|2937
-🔔|2941
-🔕|2942
-📣|2939
-📢|2938
-🗨️|160
-👁️‍🗨️|159
-💬|158
-💭|162
-🗯️|161
-🃏|2879
-🎴|2881
-🀄|2880
-🕐|2734
-🕑|2736
-🕒|2738
-🕓|2740
-🕔|2742
-🕕|2744
-🕖|2746
-🕗|2748
-🕘|2750
-🕙|2752
-🕚|2754
-🕛|2732
-🕜|2735
-🕝|2737
-🕞|2739
-🕟|2741
-🕠|2743
-🕡|2745
-🕢|2747
-🕣|2749
-🕤|2751
-🕥|2753
-🕦|2755
-🕧|2733
-⚧|3242
-🏳️|3369
-🏴|3368
 🏁|3365
 🚩|3366
+🎌|3367
+🏴|3368
+🏳️|3369
 🏳️‍🌈|3370
 🏳️‍⚧️|3371
 🏴‍☠️|3372
+🇦🇩|3374
+🇦🇪|3375
 🇦🇫|3376
-🇦🇽|3388
+🇦🇬|3377
+🇦🇮|3378
 🇦🇱|3379
-🇩🇿|3437
-🇦🇸|3384
-🇦🇩|3374
+🇦🇲|3380
 🇦🇴|3381
-🇦🇮|3378
 🇦🇶|3382
-🇦🇬|3377
 🇦🇷|3383
-🇦🇲|3380
-🇦🇼|3387
-🇦🇺|3386
+🇦🇸|3384
 🇦🇹|3385
+🇦🇺|3386
+🇦🇼|3387
+🇦🇽|3388
 🇦🇿|3389
-🇧🇸|3405
-🇧🇭|3396
-🇧🇩|3392
+🇧🇦|3390
 🇧🇧|3391
-🇧🇾|3409
+🇧🇩|3392
 🇧🇪|3393
-🇧🇿|3410
+🇧🇫|3394
+🇧🇬|3395
+🇧🇭|3396
+🇧🇮|3397
 🇧🇯|3398
+🇧🇱|3399
 🇧🇲|3400
+🇧🇳|3401
+🇧🇴|3402
+🇧🇶|3403
+🇧🇷|3404
+🇧🇸|3405
 🇧🇹|3406
-🇧🇴|3402
-🇧🇦|3390
 🇧🇼|3408
-🇧🇷|3404
-🇮🇴|3484
-🇻🇬|3619
-🇧🇳|3401
-🇧🇬|3395
-🇧🇫|3394
-🇧🇮|3397
-🇰🇭|3495
-🇨🇲|3420
+🇧🇾|3409
+🇧🇿|3410
 🇨🇦|3411
-🇮🇨|3478
-🇨🇻|3426
-🇧🇶|3403
-🇰🇾|3502
+🇨🇨|3412
+🇨🇩|3413
 🇨🇫|3414
-🇹🇩|3594
+🇨🇬|3415
+🇨🇭|3416
+🇨🇮|3417
+🇨🇰|3418
 🇨🇱|3419
+🇨🇲|3420
 🇨🇳|3421
-🇨🇽|3428
-🇨🇨|3412
 🇨🇴|3422
-🇰🇲|3497
-🇨🇬|3415
-🇨🇩|3413
-🇨🇰|3418
 🇨🇷|3424
-🇨🇮|3417
-🇭🇷|3475
 🇨🇺|3425
+🇨🇻|3426
 🇨🇼|3427
+🇨🇽|3428
 🇨🇾|3429
 🇨🇿|3430
-🇩🇰|3434
+🇩🇪|3431
 🇩🇯|3433
+🇩🇰|3434
 🇩🇲|3435
 🇩🇴|3436
+🇩🇿|3437
 🇪🇨|3439
+🇪🇪|3440
 🇪🇬|3441
-🇸🇻|3588
-🇬🇶|3465
+🇪🇭|3442
 🇪🇷|3443
-🇪🇪|3440
+🇪🇸|3444
 🇪🇹|3445
 🇪🇺|3446
+🇫🇮|3447
+🇫🇯|3448
 🇫🇰|3449
+🇫🇲|3450
 🇫🇴|3451
-🇫🇯|3448
-🇫🇮|3447
 🇫🇷|3452
-🇬🇫|3457
-🇵🇫|3553
-🇹🇫|3595
 🇬🇦|3453
-🇬🇲|3462
+🇬🇧|3454
+🇬🇩|3455
 🇬🇪|3456
-🇩🇪|3431
+🇬🇫|3457
+🇬🇬|3458
 🇬🇭|3459
 🇬🇮|3460
-🇬🇷|3466
 🇬🇱|3461
-🇬🇩|3455
+🇬🇲|3462
+🇬🇳|3463
 🇬🇵|3464
-🇬🇺|3469
+🇬🇶|3465
+🇬🇷|3466
+🇬🇸|3467
 🇬🇹|3468
-🇬🇬|3458
-🇬🇳|3463
+🇬🇺|3469
 🇬🇼|3470
 🇬🇾|3471
-🇭🇹|3476
-🇭🇳|3474
 🇭🇰|3472
+🇭🇳|3474
+🇭🇷|3475
+🇭🇹|3476
 🇭🇺|3477
-🇮🇸|3487
-🇮🇳|3483
+🇮🇨|3478
 🇮🇩|3479
-🇮🇷|3486
-🇮🇶|3485
 🇮🇪|3480
-🇮🇲|3482
 🇮🇱|3481
+🇮🇲|3482
+🇮🇳|3483
+🇮🇴|3484
+🇮🇶|3485
+🇮🇷|3486
+🇮🇸|3487
 🇮🇹|3488
-🇯🇲|3490
-🇯🇵|3492
-🎌|3367
 🇯🇪|3489
+🇯🇲|3490
 🇯🇴|3491
-🇰🇿|3503
+🇯🇵|3492
 🇰🇪|3493
+🇰🇬|3494
+🇰🇭|3495
 🇰🇮|3496
-🇽🇰|3625
+🇰🇲|3497
+🇰🇳|3498
+🇰🇵|3499
+🇰🇷|3500
 🇰🇼|3501
-🇰🇬|3494
+🇰🇾|3502
+🇰🇿|3503
 🇱🇦|3504
-🇱🇻|3513
 🇱🇧|3505
-🇱🇸|3510
-🇱🇷|3509
-🇱🇾|3514
+🇱🇨|3506
 🇱🇮|3507
+🇱🇰|3508
+🇱🇷|3509
+🇱🇸|3510
 🇱🇹|3511
 🇱🇺|3512
-🇲🇴|3526
-🇲🇰|3522
+🇱🇻|3513
+🇱🇾|3514
+🇲🇦|3515
+🇲🇨|3516
+🇲🇩|3517
+🇲🇪|3518
 🇲🇬|3520
-🇲🇼|3534
-🇲🇾|3536
-🇲🇻|3533
-🇲🇱|3523
-🇲🇹|3531
 🇲🇭|3521
+🇲🇰|3522
+🇲🇱|3523
+🇲🇲|3524
+🇲🇳|3525
+🇲🇴|3526
+🇲🇵|3527
 🇲🇶|3528
 🇲🇷|3529
+🇲🇸|3530
+🇲🇹|3531
 🇲🇺|3532
-🇾🇹|3627
+🇲🇻|3533
+🇲🇼|3534
 🇲🇽|3535
-🇫🇲|3450
-🇲🇩|3517
-🇲🇨|3516
-🇲🇳|3525
-🇲🇪|3518
-🇲🇸|3530
-🇲🇦|3515
+🇲🇾|3536
 🇲🇿|3537
-🇲🇲|3524
 🇳🇦|3538
-🇳🇷|3547
-🇳🇵|3546
-🇳🇱|3544
 🇳🇨|3539
-🇳🇿|3549
-🇳🇮|3543
 🇳🇪|3540
-🇳🇬|3542
-🇳🇺|3548
 🇳🇫|3541
-🇰🇵|3499
-🇲🇵|3527
+🇳🇬|3542
+🇳🇮|3543
+🇳🇱|3544
 🇳🇴|3545
+🇳🇵|3546
+🇳🇷|3547
+🇳🇺|3548
+🇳🇿|3549
 🇴🇲|3550
-🇵🇰|3556
-🇵🇼|3563
-🇵🇸|3561
 🇵🇦|3551
-🇵🇬|3554
-🇵🇾|3564
 🇵🇪|3552
+🇵🇫|3553
+🇵🇬|3554
 🇵🇭|3555
-🇵🇳|3559
+🇵🇰|3556
 🇵🇱|3557
-🇵🇹|3562
+🇵🇲|3558
+🇵🇳|3559
 🇵🇷|3560
+🇵🇸|3561
+🇵🇹|3562
+🇵🇼|3563
+🇵🇾|3564
 🇶🇦|3565
 🇷🇪|3566
 🇷🇴|3567
+🇷🇸|3568
 🇷🇺|3569
 🇷🇼|3570
-🇼🇸|3624
-🇸🇲|3582
-🇸🇹|3587
 🇸🇦|3571
-🇸🇳|3583
-🇷🇸|3568
+🇸🇧|3572
 🇸🇨|3573
-🇸🇱|3581
+🇸🇩|3574
+🇸🇪|3575
 🇸🇬|3576
-🇸🇽|3589
-🇸🇰|3580
+🇸🇭|3577
 🇸🇮|3578
-🇬🇸|3467
-🇸🇧|3572
+🇸🇰|3580
+🇸🇱|3581
+🇸🇲|3582
+🇸🇳|3583
 🇸🇴|3584
-🇿🇦|3628
-🇰🇷|3500
-🇸🇸|3586
-🇪🇸|3444
-🇱🇰|3508
-🇧🇱|3399
-🇸🇭|3577
-🇰🇳|3498
-🇱🇨|3506
-🇵🇲|3558
-🇻🇨|3617
-🇸🇩|3574
 🇸🇷|3585
-🇸🇿|3591
-🇸🇪|3575
-🇨🇭|3416
+🇸🇸|3586
+🇸🇹|3587
+🇸🇻|3588
+🇸🇽|3589
 🇸🇾|3590
-🇹🇼|3607
-🇹🇯|3598
-🇹🇿|3608
-🇹🇭|3597
-🇹🇱|3600
+🇸🇿|3591
+🇹🇨|3593
+🇹🇩|3594
+🇹🇫|3595
 🇹🇬|3596
+🇹🇭|3597
+🇹🇯|3598
 🇹🇰|3599
-🇹🇴|3603
-🇹🇹|3605
+🇹🇱|3600
+🇹🇲|3601
 🇹🇳|3602
+🇹🇴|3603
 🇹🇷|3604
-🇹🇲|3601
-🇹🇨|3593
-🇻🇮|3620
+🇹🇹|3605
 🇹🇻|3606
-🇺🇬|3610
+🇹🇼|3607
+🇹🇿|3608
 🇺🇦|3609
-🇦🇪|3375
-🇬🇧|3454
-🏴󠁧󠁢󠁥󠁮󠁧󠁿|3631
-🏴󠁧󠁢󠁳󠁣󠁴󠁿|3632
-🏴󠁧󠁢󠁷󠁬󠁳󠁿|3633
+🇺🇬|3610
 🇺🇸|3613
 🇺🇾|3614
 🇺🇿|3615
-🇻🇺|3622
 🇻🇦|3616
+🇻🇨|3617
 🇻🇪|3618
+🇻🇬|3619
+🇻🇮|3620
 🇻🇳|3621
+🇻🇺|3622
 🇼🇫|3623
-🇪🇭|3442
+🇼🇸|3624
+🇽🇰|3625
 🇾🇪|3626
+🇾🇹|3627
+🇿🇦|3628
 🇿🇲|3629
-🇿🇼|3630
+🇿🇼|3630
+🏴󠁧󠁢󠁥󠁮󠁧󠁿|3631
+🏴󠁧󠁢󠁳󠁣󠁴󠁿|3632
+🏴󠁧󠁢󠁷󠁬󠁳󠁿|3633

+ 6 - 6
app/assets/emojis/search-index/pl.csv

@@ -257,7 +257,7 @@
 💤|chrapanie|zabawny|żartobliwy
 👋|machająca dłoń
 🤚|powitanie|pozdrowienie|pożegnanie|uniesiona dłoń|wzniesiony grzbiet dłoni
-🖐|otwarta dłoń wszystkie palce|palce|rozpostarte palce|uniesiona otwarta dłoń
+🖐|otwarta dłoń wszystkie palce|palce|rozpostarte palce|uniesiona otwarta dłoń
 ✋|dłoń|stop|uwaga|wzniesiona dłoń
 🖖|dłoń|palce|salut wolkański|wolkański
 👌|dłoń z gestem ok|znak ok
@@ -403,7 +403,7 @@
 👮|oficer|policjant
 👮‍♂️|glina|mężczyzna|oficer|policjant
 👮‍♀️|glina|kobieta policjant|oficer|policjantka
-🕵|detektyw|śledczy|szpieg
+🕵|detektyw|śledczy|szpieg
 🕵️‍♂️|detektyw|mężczyzna|szpieg
 🕵️‍♀️|detektyw|kobieta|szpieg
 💂|gwardzista|strażnik
@@ -505,7 +505,7 @@
 🏇|jeździec|koń|wyścigi konne
 ⛷️|narciarz|narty|śnieg|stok
 🏂|snowboardzista
-🏌|golf|osoba grająca w golfa|piłka
+🏌|golf|osoba grająca w golfa|piłka
 🏌️‍♂️|golf|mężczyzna grający w golfa
 🏌️‍♀️|golf|kobieta grająca w golfa
 🏄|deska|fale|surfing|surfująca osoba
@@ -517,10 +517,10 @@
 🏊|kraulem|pływająca osoba|pływak
 🏊‍♂️|kraul|mężczyzna|pływający mężczyzna|pływanie
 🏊‍♀️|kobieta|kraul|pływająca kobieta|pływanie
-⛹|kozłować|osoba kozłująca piłkę|piłka
+⛹|kozłować|osoba kozłująca piłkę|piłka
 ⛹️‍♂️|kozłowanie|mężczyzna kozłujący piłkę|piłka
 ⛹️‍♀️|kobieta kozłująca piłkę|kozłowanie|piłka
-🏋|atleta|osoba podnosząca ciężary
+🏋|atleta|osoba podnosząca ciężary
 🏋️‍♂️|ciężarowiec|mężczyzna podnoszący ciężary
 🏋️‍♀️|ciężarowiec|kobieta podnosząca ciężary
 🚴|kolarz|osoba na rowerze|peleton|rower
@@ -1430,7 +1430,7 @@
 📶|sieć komórkowa|siła sygnału|zasięg
 📳|komórka|telefon komórkowy|tryb wibracji|wibracje
 📴|komórka|telefon komórkowy|wyłączony telefon komórkowy
-⚧|symbol transpłciowości|transpłciowość
+⚧|symbol transpłciowości|transpłciowość
 ♾️|bezgraniczność|nieskończoność|wieczność
 ‼️|!!|krzyk|okrzyk|podwójny wykrzyknik|wykrzykniki
 💱|kantor|waluta|wymiana walut

+ 6 - 6
app/assets/emojis/search-index/pt.csv

@@ -257,7 +257,7 @@
 💤|dormindo|emoção|engraçado|roncando|zzz|desenho|dormir|sesta|sono
 👋|aceno|mão acenando|acenar|corpo|mão a acenar
 🤚|dorso da mão levantado|levantada|palma|pare|mão com costas para fora
-🖐|cinco dedos|mão aberta com os dedos separados|mão aberta indicando cinco|palma da mão|corpo|dedos abertos|mão erguida com dedos afastados
+🖐|cinco dedos|mão aberta com os dedos separados|mão aberta indicando cinco|palma da mão|corpo|dedos abertos|mão erguida com dedos afastados
 ✋|mão erguida|mão levantada|corpo
 🖖|dedos|jornada nas estrelas|mão|saudação vulcana|spock|star trek|corpo|vulcano
 👌|mão sinalizando ok|corpo|gesto de ok|mão com gesto de ok|ok com a mão|sinal de ok com a mão
@@ -403,7 +403,7 @@
 👮|polícia|policial|agente da polícia
 👮‍♂️|homem|policial|tira|agente|bófia|chui|polícia (homem)
 👮‍♀️|mulher|policial|tira|agente|bófia|chui|polícia (mulher)
-🕵|detetive|espião|investigador
+🕵|detetive|espião|investigador
 🕵️‍♂️|detetive homem|espião|homem|investigador|detetive (homem)
 🕵️‍♀️|detetive mulher|espiã|investigadora|mulher|detetive (mulher)|espia
 💂|guarda|segurança
@@ -505,7 +505,7 @@
 🏇|cavalo de corrida|corrida de cavalos|esporte|jóquei
 ⛷️|esquiador|neve
 🏂|esporte|praticante de snowboard|snowboard|esqui|neve
-🏌|bola|esporte|golfe|golfista|pessoa jogando golfe
+🏌|bola|esporte|golfe|golfista|pessoa jogando golfe
 🏌️‍♂️|golfe|homem golfista|homem jogando golfe|golfista|homem a jogar golfe|taco
 🏌️‍♀️|golfe|mulher golfista|mulher jogando golfe|golfista|mulher a jogar golfe|taco
 🏄|pessoa surfando|surfe|surfista
@@ -517,10 +517,10 @@
 🏊|esporte|nadar|pessoa nadando|pessoa a nadar
 🏊‍♂️|homem nadando|nadar|natação|homem a nadar
 🏊‍♀️|mulher nadando|nadar|natação|mulher a nadar
-⛹|basquete|bola|esporte|pessoa jogando basquete|pessoa com bola
+⛹|basquete|bola|esporte|pessoa jogando basquete|pessoa com bola
 ⛹️‍♂️|basquete|bola|esporte|homem jogando basquete|homem com bola
 ⛹️‍♀️|basquete|bola|esporte|mulher jogando basquete|mulher com bola
-🏋|esporte|força|pessoa levantando peso|halterofilista|levantar|pesos
+🏋|esporte|força|pessoa levantando peso|halterofilista|levantar|pesos
 🏋️‍♂️|esporte|força|homem levantando peso|peso|homem halterofilista|levantamento de pesos
 🏋️‍♀️|esporte|força|mulher levantando peso|peso|levantamento de pesos|mulher halterofilista
 🚴|bicicleta|ciclista|pessoa a andar de bicicleta
@@ -1430,7 +1430,7 @@
 📶|antena com barras|barras de sinal|celular|força do sinal|sinais de telefonia móvel|telefone|barras de antena|rede|sinal|telemóvel
 📳|celular|modo vibratório|telefone|modo de vibração|telemóvel|vibração
 📴|celular|desligado|telefone|telemóvel
-⚧|símbolo transgênero|transgênero|símbolo transgénero|transgénero
+⚧|símbolo transgênero|transgênero|símbolo transgénero|transgénero
 ♾️|eternidade|ilimitado|infinito|universal
 ‼️|!!|dupla exclamação|exclamação|explosão|ponto de exclamação duplo|pontuação
 💱|banco|câmbio de moeda|dinheiro|moeda

+ 6 - 6
app/assets/emojis/search-index/ru.csv

@@ -257,7 +257,7 @@
 💤|сон|храп
 👋|взмах|машет рукой|приветствие|рука
 🤚|ладонь|поднятая рука
-🖐|ладонь|пальцы|раскрытая|рука
+🖐|ладонь|пальцы|раскрытая|рука
 ✋|вверх|пальцы|поднятая ладонь|рука
 🖖|вулканский салют|жест|нимой|приветствие|рука|спок
 👌|жест "все хорошо"|жест «все хорошо»|окей|рука|хорошо
@@ -403,7 +403,7 @@
 👮|лицо|охрана|полицейский|полиция|человек
 👮‍♂️|лицо|мужчина-полицейский|охрана|полицейский|полиция|человек
 👮‍♀️|женщина-полицейский|лицо|охрана|полицейский|полиция|человек
-🕵|детектив|ищейка|расследование|сыщик|шпион
+🕵|детектив|ищейка|расследование|сыщик|шпион
 🕵️‍♂️|детектив|ищейка|мужчина-детектив|расследование|сыщик|шпион
 🕵️‍♀️|детектив|женщина-детектив|ищейка|расследование|сыщик|шпионка
 💂|гвардеец|гвардия|охрана|почетный караул|солдат
@@ -505,7 +505,7 @@
 🏇|бега|жокей|лошади|скачки
 ⛷️|горные лыжи|лыжи|склон|скорость|спортсмен|человек
 🏂|горы|склон|снег|сноубордист|спортсмен
-🏌|гольфист|игрок|клюшка|удар
+🏌|гольфист|игрок|клюшка|удар
 🏌️‍♂️|гольфист|игрок|клюшка|мужчина играет в гольф
 🏌️‍♀️|гольфистка|женщина играет в гольф|игрок|клюшка
 🏄|вода|волны|доска|серфинг|спорт
@@ -517,10 +517,10 @@
 🏊|бассейн|вода|плавание|пловец
 🏊‍♂️|мужчина|плавание|пловец|спорт
 🏊‍♀️|женщина|плавание|пловчиха|спорт
-⛹|баскетболист|ведение|игра|мяч
+⛹|баскетболист|ведение|игра|мяч
 ⛹️‍♂️|баскетбол|мужчина с мячом|мяч|спорт
 ⛹️‍♀️|баскетбол|женщина с мячом|мяч|спорт
-🏋|атлетика|вес|помост|тяжелая|тяжелоатлет|штанга
+🏋|атлетика|вес|помост|тяжелая|тяжелоатлет|штанга
 🏋️‍♂️|мужчина со штангой|пауэрлифтинг|спорт|тяжелая атлетика
 🏋️‍♀️|женщина со штангой|пауэрлифтинг|спорт|тяжелая атлетика
 🚴|велосипедист|педали|спортсмен
@@ -1430,7 +1430,7 @@
 📶|антенна|сигнал сети|телефон|уровень сигнала
 📳|вибрация|мобильный|режим вибрации|смартфон|телефон вибрирует
 📴|выключенный смартфон|выключенный телефон|мобильный отключен|смартфон выключен|телефон выключен
-⚧|символ|трансгендерная|трансгендерное|трансгендерные|трансгендерный
+⚧|символ|трансгендерная|трансгендерное|трансгендерные|трансгендерный
 ♾️|бесконечность|знак бесконечности|навсегда|постоянно
 ‼️|восклицание|восклицательные|восклицательный|два восклицательных знака|знаки|пунктуация
 💱|валюта|деньги|касса|обмен валюты|пункт

+ 6 - 6
app/assets/emojis/search-index/sk.csv

@@ -257,7 +257,7 @@
 💤|chŕ|komiks|spánok
 👋|mávajúca ruka|mávať|ruka
 🤚|ruka|spakruky|zdvihnutá
-🖐|prst|roztiahnutá|ruka
+🖐|prst|roztiahnutá|ruka
 ✋|ruka|stáť|stop|zdvihnutá ruka
 🖖|prst|ruka|spock|vulkánsky pozdrav
 👌|ok|ruka|výborne
@@ -403,7 +403,7 @@
 👮|policajt|polícia|príslušník
 👮‍♂️|muž|policajt|polícia
 👮‍♀️|policajtka|polícia|žena
-🕵|detektív|špión|vyšetrovateľ
+🕵|detektív|špión|vyšetrovateľ
 🕵️‍♂️|agent|detektív|muž|špión
 🕵️‍♀️|agentka|detektívka|špiónka|žena
 💂|stráž
@@ -505,7 +505,7 @@
 🏇|dostihy|džokej|kôň|preteky
 ⛷️|lyže|lyžiar|sneh
 🏂|lyže|sneh|snoubordista|snowboard
-🏌|golf|hráč golfu|loptička
+🏌|golf|hráč golfu|loptička
 🏌️‍♂️|golfista|muž
 🏌️‍♀️|golfistka|žena
 🏄|človek na surfe|surfing|surfovanie
@@ -517,10 +517,10 @@
 🏊|plávajúci človek|plávať
 🏊‍♂️|muž|plávanie|plavec
 🏊‍♀️|plávanie|plavkyňa|žena
-⛹|človek s loptou|lopta
+⛹|človek s loptou|lopta
 ⛹️‍♂️|lopta|muž s loptou
 ⛹️‍♀️|lopta|žena s loptou
-🏋|činky|vzpierajúci človek|vzpierať
+🏋|činky|vzpierajúci človek|vzpierať
 🏋️‍♂️|činky|muž|vzpierač|vzpierať
 🏋️‍♀️|činky|vzpieračka|vzpierať|žena
 🚴|bicykel|bicyklovať|človek na bicykli
@@ -1430,7 +1430,7 @@
 📶|anténa|mobilné|pás|signál|sila signálu|telefón
 📳|mobilný|režim|telefón|vibrácia|vibračný režim
 📴|mobilý|telefón|vypnuté|vypnutý mobil
-⚧|transgenderový symbol
+⚧|transgenderový symbol
 ♾️|navždy|nekonečno|večný
 ‼️|!!|dvojitý výkričník|interpunkcia|výkričník|znak|zvolanie
 💱|banka|mena|peniaze|zmenáreň

+ 6 - 6
app/assets/emojis/search-index/tr.csv

@@ -257,7 +257,7 @@
 💤|çizgi roman|duygu|horlama|uyuma|zzz
 👋|el|sallama
 🤚|elinin tersini kaldırma|kaldırma
-🖐|avuç|beden|el|parmaklar açık el kaldırma
+🖐|avuç|beden|el|parmaklar açık el kaldırma
 ✋|el kaldırma|havada el
 🖖|beden|el|parmak|spock|vulcan selamı|vulkan selamı
 👌|el|işaret|tamam el işareti|tamam işareti
@@ -403,7 +403,7 @@
 👮|görevli|kişiler|polis memuru
 👮‍♂️|adam|aynasız|erkek polis memuru|memur|polis
 👮‍♀️|aynasız|bayan|kadın polis memuru|memur|polis
-🕵|casus|dedektif|hafiye
+🕵|casus|dedektif|hafiye
 🕵️‍♂️|adam|casus|dedektif|erkek|hafiye
 🕵️‍♀️|bayan|casus|dedektif|hafiye|kadın
 💂|bekçi|kişiler|muhafız
@@ -505,7 +505,7 @@
 🏇|at yarışı|jokey|spor|yarış atı
 ⛷️|kar|kayakçı
 🏂|snowboard yapmak|snowbordçu|spor
-🏌|golf oynayan kişi|top
+🏌|golf oynayan kişi|top
 🏌️‍♂️|adam|erkek|golf oynayan erkek
 🏌️‍♀️|bayan|golf oynayan kadın|kadın
 🏄|sörf yapan kişi|sörfçü|spor
@@ -517,10 +517,10 @@
 🏊|spor|yüzen kişi|yüzme
 🏊‍♂️|adam|erkek|yüzen erkek|yüzme
 🏊‍♀️|bayan|kadın|yüzen kadın|yüzme
-⛹|top sektiren kişi
+⛹|top sektiren kişi
 ⛹️‍♂️|adam|erkek|top sektiren erkek
 ⛹️‍♀️|bayan|kadın|top sektiren kadın
-🏋|ağırlık kaldıran kişi|halter
+🏋|ağırlık kaldıran kişi|halter
 🏋️‍♂️|adam|ağırlık kaldıran erkek|erkek
 🏋️‍♀️|ağırlık kaldıran kadın|bayan|kadın
 🚴|bisiklet süren kişi
@@ -1430,7 +1430,7 @@
 📶|anten|çubuk işaretli anten|mobil|sinyal gücü|telefon
 📳|cep telefonu|mobil|mod|telefon|titreşim modu
 📴|cep telefonu kapalı|kapalı|mobil|telefon
-⚧|transgender sembolü|transseksüel
+⚧|transgender sembolü|transseksüel
 ♾️|evrensel|sınırsız|sonsuzluk
 ‼️|!!|çift ünlem|işaret|noktalama|ünlem
 💱|bozdurma|döviz|kambiyo|para

+ 6 - 6
app/assets/emojis/search-index/uk.csv

@@ -257,7 +257,7 @@
 💤|комікси|умовне позначення сну
 👋|долоня|махати|помах|рука махає|тіло
 🤚|долоня|піднята рука тильною стороною|тильна
-🖐|палець|піднята рука з розведеними пальцями|розчепірений|рука|тіло
+🖐|палець|піднята рука з розведеними пальцями|розчепірений|рука|тіло
 ✋|піднята рука|рука|тіло
 🖖|вулканське вітання|зоряний шлях|палець|рука|спок|тіло
 👌|«о’кей»|жест «окей»|рука|тіло
@@ -403,7 +403,7 @@
 👮|коп|поліція|працівник поліції|співробітник
 👮‍♂️|поліцейський|поліція|чоловік
 👮‍♀️|жінка-поліцейський|поліцейський|поліція
-🕵|детектив|сищик|шпигун
+🕵|детектив|сищик|шпигун
 🕵️‍♂️|детектив|сищик|чоловік-детектив|шпигун|шпик
 🕵️‍♀️|детектив|жінка-детектив|жінка-сищик|шпигунка
 💂|варта|караул|охорона|сторожа|чати
@@ -505,7 +505,7 @@
 🏇|біговий кінь|жокей|кінні перегони|кінь|перегони
 ⛷️|лижі|лижник|сніг
 🏂|лижі|сніг|сноубордист
-🏌|гольф|людина, що грає в гольф|м’яч
+🏌|гольф|людина, що грає в гольф|м’яч
 🏌️‍♂️|гольфіст|чоловік
 🏌️‍♀️|гольфістка|жінка
 🏄|людина, що займається серфінгом|серфінг
@@ -517,10 +517,10 @@
 🏊|людина, що пливе|плавати
 🏊‍♂️|плавання|плавець|чоловік
 🏊‍♀️|жінка|плавання|плавчиня
-⛹|людина з м’ячем|людина, що веде мʼяч|м’яч
+⛹|людина з м’ячем|людина, що веде мʼяч|м’яч
 ⛹️‍♂️|м’яч|чоловік із м’ячем|чоловік, що веде мʼяч
 ⛹️‍♀️|жінка з м’ячем|жінка, що веде мʼяч|м’яч
-🏋|важкоатлет|тягар
+🏋|важкоатлет|тягар
 🏋️‍♂️|важка атлетика|чоловік|штангіст
 🏋️‍♀️|важка атлетика|жінка|штангістка
 🚴|велосипед|людина, що їде на велосипеді
@@ -1430,7 +1430,7 @@
 📶|мобільний|рівень сигналу|сигнал|стільниковий|телефон
 📳|вібрація|віброрежим|мобільний|режим вібрації|телефон
 📴|вимкнено|мобільний|стільниковий|телефон
-⚧|трансгендерний символ
+⚧|трансгендерний символ
 ♾️|безкінечність|горизонтальна вісімка|нескінченність|універсальність
 ‼️|!!|бенгбенг|лігатура|оклик|подвійний знак оклику|розділовий знак
 💱|банк|валюта|гроші|обмін валют

+ 89 - 99
app/build.gradle

@@ -13,7 +13,7 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 }
 
 // version codes
-def app_version = "4.84"
+def app_version = "5.0"
 def beta_suffix = "" // with leading dash
 
 /**
@@ -81,18 +81,18 @@ android {
     // NOTE: When adjusting compileSdkVersion, buildToolsVersion or ndkVersion,
     //       make sure to adjust them in `scripts/Dockerfile` and
     //       `.gitlab-ci.yml` as well!
-    compileSdkVersion 31
-    buildToolsVersion '31.0.0'
-    ndkVersion '21.1.6352462'
+    compileSdkVersion 33
+    buildToolsVersion '33.0.0'
+    ndkVersion '25.1.8937393'
 
     defaultConfig {
         minSdkVersion 21
         //noinspection OldTargetApi
-        targetSdkVersion 30
+        targetSdkVersion 31
         vectorDrawables.useSupportLibrary = true
         applicationId "ch.threema.app"
         testApplicationId 'ch.threema.app.test'
-        versionCode 761
+        versionCode 776
         versionName "${app_version}${beta_suffix}"
         resValue "string", "app_name", "Threema"
         // package name used for sync adapter - needs to match mime types below
@@ -108,6 +108,7 @@ android {
         buildConfigField "boolean", "CHAT_SERVER_GROUPS", "true"
         buildConfigField "boolean", "DISABLE_CERT_PINNING", "false"
         buildConfigField "boolean", "VIDEO_CALLS_ENABLED", "true"
+        buildConfigField "boolean", "GROUP_CALLS_ENABLED", "true"
         buildConfigField "byte[]", "SERVER_PUBKEY", "new byte[] {(byte) 0x45, (byte) 0x0b, (byte) 0x97, (byte) 0x57, (byte) 0x35, (byte) 0x27, (byte) 0x9f, (byte) 0xde, (byte) 0xcb, (byte) 0x33, (byte) 0x13, (byte) 0x64, (byte) 0x8f, (byte) 0x5f, (byte) 0xc6, (byte) 0xee, (byte) 0x9f, (byte) 0xf4, (byte) 0x36, (byte) 0x0e, (byte) 0xa9, (byte) 0x2a, (byte) 0x8c, (byte) 0x17, (byte) 0x51, (byte) 0xc6, (byte) 0x61, (byte) 0xe4, (byte) 0xc0, (byte) 0xd8, (byte) 0xc9, (byte) 0x09 }"
         buildConfigField "byte[]", "SERVER_PUBKEY_ALT", "new byte[] {(byte) 0xda, (byte) 0x7c, (byte) 0x73, (byte) 0x79, (byte) 0x8f, (byte) 0x97, (byte) 0xd5, (byte) 0x87, (byte) 0xc3, (byte) 0xa2, (byte) 0x5e, (byte) 0xbe, (byte) 0x0a, (byte) 0x91, (byte) 0x41, (byte) 0x7f, (byte) 0x76, (byte) 0xdb, (byte) 0xcc, (byte) 0xcd, (byte) 0xda, (byte) 0x29, (byte) 0x30, (byte) 0xe6, (byte) 0xa9, (byte) 0x09, (byte) 0x0a, (byte) 0xf6, (byte) 0x2e, (byte) 0xba, (byte) 0x6f, (byte) 0x15 }"
         buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
@@ -129,6 +130,7 @@ android {
 
         buildConfigField "String[]", "ONPREM_CONFIG_TRUSTED_PUBLIC_KEYS", "null"
         buildConfigField "boolean", "SEND_CONSUMED_DELIVERY_RECEIPTS", "false"
+        buildConfigField "boolean", "FORWARD_SECURITY", "true"
 
         // config fields for action URLs / deep links
         buildConfigField "String", "uriScheme", "\"threema\""
@@ -172,6 +174,7 @@ android {
         }
     }
 
+    namespace 'ch.threema.app'
     flavorDimensions "default"
     productFlavors {
         none { }
@@ -534,73 +537,20 @@ android {
         }
     }
 
-
     externalNativeBuild {
         ndkBuild {
             path 'jni/Android.mk'
         }
     }
 
-    lintOptions {
-        // set to true to have all release builds run lint on issues with severity=fatal
-        // and abort the build (controlled by abortOnError above) if fatal issues are found
-        checkReleaseBuilds true
-        // check dependencies
-        checkDependencies true
-        // set to true to turn off analysis progress reporting by lint
-        // quiet true
-        // if true, stop the gradle build if errors are found
-        abortOnError true
-        // if true, only report errors
-        ignoreWarnings false
-        // if true, emit full/absolute paths to files with errors (true by default)
-        //absolutePaths true
-        // if true, check all issues, including those that are off by default
-        checkAllWarnings true
-        // if true, treat all warnings as errors
-        warningsAsErrors false
-        // turn off checking the given issue id's
-        disable 'TypographyFractions', 'TypographyQuotes'
-        // turn on the given issue id's
-        disable 'RtlHardcoded', 'RtlCompat', 'RtlEnabled'
-        // check *only* the given issue id's
-        // check 'NewApi', 'InlinedApi'
-        // if true, don't include source code lines in the error output
-        noLines false
-        // if true, show all locations for an error, do not truncate lists, etc.
-        showAll true
-        // if true, generate an XML report for use by for example Jenkins
-        xmlReport true
-        // file to write report to (if not specified, defaults to lint-results.xml)
-        xmlOutput file("lint-report.xml")
-
-        // Set the severity of the given issues to fatal (which means they will be
-        // checked during release builds (even if the lint target is not included)
-        fatal 'NewApi', 'InlinedApi'
-        // Set the severity of the given issues to error
-        error 'Wakelock', 'TextViewEdits', 'ResourceAsColor'
-        // Set the severity of the given issues to warning
-        warning 'MissingTranslation'
-        // Set the severity of the given issues to ignore (same as disabling the check)
-        ignore 'TypographyQuotes'
-    }
-
     packagingOptions {
-        exclude 'META-INF/DEPENDENCIES.txt'
-        exclude 'META-INF/LICENSE.txt'
-        exclude 'META-INF/NOTICE.txt'
-        exclude 'META-INF/NOTICE'
-        exclude 'META-INF/LICENSE'
-        exclude 'META-INF/DEPENDENCIES'
-        exclude 'META-INF/notice.txt'
-        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'
-        doNotStrip '*/armeabi/*.so'
+        jniLibs {
+            // fix https://stackoverflow.com/questions/42739916/aarch64-linux-android-strip-file-missing
+            keepDebugSymbols += ['*/mips/*.so', '*/mips64/*.so', '*/armeabi/*.so']
+        }
+        resources {
+            excludes += ['META-INF/DEPENDENCIES.txt', 'META-INF/LICENSE.txt', 'META-INF/NOTICE.txt', 'META-INF/NOTICE', 'META-INF/LICENSE', 'META-INF/DEPENDENCIES', 'META-INF/notice.txt', 'META-INF/license.txt', 'META-INF/dependencies.txt', 'META-INF/LGPL2.1', '**/*.proto']
+        }
     }
 
     testOptions {
@@ -629,15 +579,53 @@ android {
         targetCompatibility JavaVersion.VERSION_11
     }
 
+    kotlinOptions {
+        jvmTarget = "11"
+    }
+
     kotlin {
         jvmToolchain {
             languageVersion.set(JavaLanguageVersion.of(11))
         }
     }
 
-    aaptOptions {
+    androidResources {
         noCompress 'png'
     }
+
+    lint {
+        // if true, stop the gradle build if errors are found
+        abortOnError true
+        // if true, check all issues, including those that are off by default
+        checkAllWarnings true
+        // check dependencies
+        checkDependencies true
+        // set to true to have all release builds run lint on issues with severity=fatal
+        // and abort the build (controlled by abortOnError above) if fatal issues are found
+        checkReleaseBuilds true
+        // turn off checking the given issue id's
+        disable 'TypographyFractions', 'TypographyQuotes', 'RtlHardcoded', 'RtlCompat', 'RtlEnabled'
+        // Set the severity of the given issues to error
+        error 'Wakelock', 'TextViewEdits', 'ResourceAsColor'
+        // Set the severity of the given issues to fatal (which means they will be
+        // checked during release builds (even if the lint target is not included)
+        fatal 'NewApi', 'InlinedApi'
+        // Set the severity of the given issues to ignore (same as disabling the check)
+        ignore 'TypographyQuotes'
+        ignoreWarnings false
+        // if true, don't include source code lines in the error output
+        noLines false
+        // if true, show all locations for an error, do not truncate lists, etc.
+        showAll true
+        // Set the severity of the given issues to warning
+        warning 'MissingTranslation'
+        // if true, treat all warnings as errors
+        warningsAsErrors false
+        // file to write report to (if not specified, defaults to lint-results.xml)
+        xmlOutput file('lint-report.xml')
+        // if true, generate an XML report for use by for example Jenkins
+        xmlReport true
+    }
 }
 
 dependencies {
@@ -652,7 +640,7 @@ dependencies {
 
     implementation project(':domain')
 
-    implementation 'net.zetetic:android-database-sqlcipher:4.5.1'
+    implementation 'net.zetetic:android-database-sqlcipher:4.5.2'
 
     implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
     implementation 'net.sf.opencsv:opencsv:2.3'
@@ -661,10 +649,10 @@ dependencies {
     // commons-io >2.6 requires android 8
     implementation 'commons-io:commons-io:2.6'
     implementation "org.slf4j:slf4j-api:$slf4j_version"
-    implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.24'
+    implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.25'
     implementation 'com.github.CanHub:Android-Image-Cropper:4.3.0'
     implementation 'com.datatheorem.android.trustkit:trustkit:1.1.5'
-    implementation 'me.zhanghai.android.fastscroll:library:1.1.7'
+    implementation 'me.zhanghai.android.fastscroll:library:1.1.8'
     implementation 'com.googlecode.ez-vcard:ez-vcard:0.11.3'
 
     // AndroidX / Jetpack support libraries
@@ -672,60 +660,61 @@ dependencies {
     implementation 'androidx.recyclerview:recyclerview:1.2.1'
     implementation 'androidx.palette:palette-ktx:1.0.0'
     implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
-    implementation 'androidx.appcompat:appcompat:1.4.2'
+    implementation 'androidx.appcompat:appcompat:1.5.1'
     implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
     implementation 'androidx.biometric:biometric:1.1.0'
-    implementation "androidx.work:work-runtime:2.7.1"
-    implementation 'androidx.fragment:fragment-ktx:1.4.1'
-    implementation 'androidx.activity:activity-ktx:1.4.0'
+    implementation 'androidx.work:work-runtime-ktx:2.7.1'
+    implementation 'androidx.fragment:fragment-ktx:1.5.4'
+    implementation 'androidx.activity:activity-ktx:1.6.1'
     implementation 'androidx.sqlite:sqlite:2.1.0'
     implementation "androidx.concurrent:concurrent-futures:1.1.0"
     implementation "androidx.camera:camera-camera2:1.1.0"
     implementation "androidx.camera:camera-lifecycle:1.1.0"
     implementation "androidx.camera:camera-view:1.1.0"
     implementation 'androidx.multidex:multidex:2.0.1'
-    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1"
-    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.1"
-    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.1"
-    implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.4.1"
-    implementation "androidx.lifecycle:lifecycle-service:2.4.1"
-    implementation "androidx.lifecycle:lifecycle-process:2.4.1"
-    implementation "androidx.lifecycle:lifecycle-common-java8:2.4.1"
+    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1"
+    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.5.1"
+    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.1"
+    implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:2.5.1"
+    implementation "androidx.lifecycle:lifecycle-service:2.5.1"
+    implementation "androidx.lifecycle:lifecycle-process:2.5.1"
+    implementation "androidx.lifecycle:lifecycle-common-java8:2.5.1"
     implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
     implementation "androidx.paging:paging-runtime:3.1.1"
-    implementation "androidx.sharetarget:sharetarget:1.1.0"
-    implementation 'androidx.room:room-runtime:2.4.2'
-    kapt 'androidx.room:room-compiler:2.4.2'
+    implementation "androidx.sharetarget:sharetarget:1.2.0"
+    implementation 'androidx.room:room-runtime:2.4.3'
+    kapt 'androidx.room:room-compiler:2.4.3'
 
-    implementation 'com.google.android.material:material:1.6.1'
-    implementation 'com.google.android.exoplayer:exoplayer-core:2.18.0'
-    implementation 'com.google.android.exoplayer:exoplayer-ui:2.18.0'
+    implementation 'com.google.android.material:material:1.7.0'
+    implementation 'com.google.android.exoplayer:exoplayer-core:2.18.1'
+    implementation 'com.google.android.exoplayer:exoplayer-ui:2.18.1'
     implementation 'com.google.zxing:core:3.3.3' // zxing 3.4 crashes on API < 24
-    implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.45' // make sure to update this in domain's build.gradle as well
+    implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.57' // make sure to update this in domain's build.gradle as well
 
     // webclient dependencies
     implementation 'org.msgpack:msgpack-core:0.8.24!!'
+    implementation 'com.fasterxml.jackson.core:jackson-core:2.12.5!!'
     implementation 'com.neovisionaries:nv-websocket-client:2.9'
 
     // Backport of Streams and CompletableFuture. Remove once API level 24 is supported.
-    implementation 'net.sourceforge.streamsupport:streamsupport-cfuture:1.7.2'
+    implementation 'net.sourceforge.streamsupport:streamsupport-cfuture:1.7.4'
 
     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:100.0.0'
+    implementation 'ch.threema:webrtc-android:0.108.0-group-call-2'
     implementation('org.saltyrtc:saltyrtc-task-webrtc:0.18.1') {
         exclude module: 'saltyrtc-client'
     }
 
     // Glide components
-    implementation 'com.github.bumptech.glide:glide:4.13.2'
-    kapt 'com.github.bumptech.glide:compiler:4.13.2'
+    implementation 'com.github.bumptech.glide:glide:4.14.2'
+    kapt 'com.github.bumptech.glide:compiler:4.14.2'
 
     // kotlin
-    implementation "androidx.core:core-ktx:1.7.0"
+    implementation 'androidx.core:core-ktx:1.9.0'
     implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
     implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
 
@@ -744,30 +733,31 @@ dependencies {
     testImplementation "org.powermock:powermock-module-junit4:${mockitoVersion}"
 
     // add JSON support to tests without mocking
-    testImplementation 'org.json:json:20190722'
+    testImplementation 'org.json:json:20220924'
 
     testImplementation 'com.tngtech.archunit:archunit-junit4:0.18.0'
 
     androidTestImplementation(testFixtures(project(":domain")))
     androidTestImplementation 'androidx.test:rules:1.4.0'
-    androidTestImplementation 'tools.fastlane:screengrab:2.0.0', {
+    androidTestImplementation 'tools.fastlane:screengrab:2.1.1', {
         exclude group: 'androidx.annotation', module: 'annotation'
     }
-    androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0', {
+    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0', {
         exclude group: 'androidx.annotation', module: 'annotation'
     }
-    androidTestImplementation 'androidx.test:runner:1.1.0', {
+    androidTestImplementation 'androidx.test:runner:1.4.0', {
         exclude group: 'androidx.annotation', module: 'annotation'
     }
     androidTestImplementation 'androidx.test.ext:junit:1.1.3'
-    androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.1.0', {
+    androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0', {
         exclude group: 'androidx.annotation', module: 'annotation'
         exclude group: 'androidx.appcompat', module: 'appcompat'
         exclude group: 'androidx.legacy', module: 'legacy-support-v4'
         exclude group: 'com.google.android.material', module: 'material'
         exclude group: 'androidx.recyclerview', module: 'recyclerview'
+        exclude(group: 'org.checkerframework', module: 'checker')
     }
-    androidTestImplementation 'androidx.test.espresso:espresso-intents:3.1.0', {
+    androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0', {
         exclude group: 'androidx.annotation', module: 'annotation'
     }
     androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'

+ 2 - 0
app/proguard-project.txt

@@ -231,3 +231,5 @@ public static <fields>;
 -keep class * extends androidx.startup.Initializer {
    <init>();
 }
+
+-keep class java8.util.ImmutableCollections { *; }

+ 10 - 2
app/src/androidTest/java/ch/threema/app/processors/MessageProcessorTest.java

@@ -49,6 +49,7 @@ 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.groupcall.GroupCallManager;
 import ch.threema.app.voip.services.VoipStateService;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.crypto.NonceFactory;
@@ -62,6 +63,7 @@ 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.fs.ForwardSecurityMessageProcessor;
 import ch.threema.domain.protocol.csp.messages.DeliveryReceiptMessage;
 import ch.threema.domain.stores.ContactStore;
 import ch.threema.domain.stores.IdentityStoreInterface;
@@ -90,6 +92,7 @@ public class MessageProcessorTest {
 	private NotificationService notificationService;
 	private VoipStateService voipStateService;
 	private NonceFactory nonceFactory;
+	private GroupCallManager groupCallManager;
 
 	// Stores
 	private IdentityStoreInterface identityStore;
@@ -98,6 +101,7 @@ public class MessageProcessorTest {
 
 	// Message processor
 	private MessageProcessor messageProcessor;
+	private ForwardSecurityMessageProcessor forwardSecurityMessageProcessor;
 
 	@Before
 	public void setUp() throws Exception {
@@ -116,6 +120,8 @@ public class MessageProcessorTest {
 		this.notificationService = serviceManager.getNotificationService();
 		this.voipStateService = serviceManager.getVoipStateService();
 		this.nonceFactory = new NonceFactory(new InMemoryNonceStore());
+		this.forwardSecurityMessageProcessor = serviceManager.getForwardSecurityMessageProcessor();
+		this.groupCallManager = serviceManager.getGroupCallManager();
 
 		// Create in-memory stores
 		this.contactStore = new InMemoryContactStore();
@@ -153,7 +159,9 @@ public class MessageProcessorTest {
 			this.ballotService,
 			this.fileService,
 			this.notificationService,
-			this.voipStateService
+			this.voipStateService,
+			this.forwardSecurityMessageProcessor,
+			this.groupCallManager
 		);
 	}
 
@@ -231,7 +239,7 @@ public class MessageProcessorTest {
 		final String logs = this.getLogs();
 		ThreemaAssert.assertContains(
 			logs,
-			"MessageProcessor: Incoming message " + deliveryMessageId.toString()
+			"MessageProcessor: Incoming message " + deliveryMessageId
 				+ " from " + this.identityStore2.getIdentity()
 				+ " to " + this.identityStore.getIdentity()
 				+ " (type " + Utils.byteToHex((byte) ProtocolDefines.MSGTYPE_DELIVERY_RECEIPT, false, true) + ")"

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

@@ -56,7 +56,7 @@ public class GroupInviteServiceTest {
 	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 final GroupInvite.ConfirmationMode TEST_CONFIRMATION_MODE_AUTOMATIC = GroupInvite.ConfirmationMode.AUTOMATIC;
 	static GroupInviteToken TEST_TOKEN_VALID;
 	static GroupInviteModel TEST_INVITE_MODEL;
 	static String TEST_ENCODED_INVITE = "RUNIT0VDSE86MDAwMTAyMDMwNDA1MDYwNzA4MDkwYTBiMGMwZDBlMGY6QSBuaWNlIGxpdHRsZSBncm91cDow";
@@ -79,7 +79,7 @@ public class GroupInviteServiceTest {
 		TEST_IDENTITY,
 		TEST_TOKEN_VALID,
 		TEST_GROUP_NAME,
-		TEST_INVITE_TYPE_AUTOMATIC
+		TEST_CONFIRMATION_MODE_AUTOMATIC
 	);
 
 	@Before
@@ -303,6 +303,6 @@ public class GroupInviteServiceTest {
 		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());
+		Assert.assertEquals(TEST_INVITE_DATA.getConfirmationMode(), inviteDataFromDecodedUri.getConfirmationMode());
 	}
 }

+ 0 - 4
app/src/androidTest/java/ch/threema/app/voip/SdpTest.java

@@ -27,7 +27,6 @@ import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.webrtc.IceCandidate;
 import org.webrtc.PeerConnection;
-import org.webrtc.PeerConnectionFactory;
 import org.webrtc.SessionDescription;
 
 import java.util.ArrayList;
@@ -42,7 +41,6 @@ import androidx.annotation.Nullable;
 import androidx.test.core.app.ApplicationProvider;
 import androidx.test.filters.MediumTest;
 import androidx.test.runner.AndroidJUnit4;
-
 import ch.threema.app.voip.util.SdpPatcher;
 
 import static junit.framework.Assert.assertEquals;
@@ -374,8 +372,6 @@ public class SdpTest {
 		expectedMatchesPart1.add("^a=ssrc:\\d+ cname:[^ ]+$");
 		if (isOffer) {
 			expectedMatchesPart1.add("^a=ssrc:\\d+ msid:3MACALL 3MACALLa0$");
-			expectedMatchesPart1.add("^a=ssrc:\\d+ mslabel:3MACALL$");
-			expectedMatchesPart1.add("^a=ssrc:\\d+ label:3MACALLa0$");
 		}
 		if (videoEnabled) {
 			expectedMatchesPart1.add("^m=video 9 UDP/TLS/RTP/SAVPF( \\d+)+$");

+ 229 - 0
app/src/androidTest/java/ch/threema/storage/SQLDHSessionStoreTest.java

@@ -0,0 +1,229 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2022 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.storage;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import androidx.test.core.app.ApplicationProvider;
+import ch.threema.domain.fs.DHSession;
+import ch.threema.domain.fs.DHSessionId;
+import ch.threema.domain.helpers.DummyUsers;
+import ch.threema.domain.stores.DHSessionStoreException;
+
+public class SQLDHSessionStoreTest {
+
+	private static final byte[] DATABASE_KEY = "dummyKey".getBytes(StandardCharsets.UTF_8);
+	private static final int NUM_RANDOM_RUNS = 20;
+
+	private String tempDbFileName;
+	private SQLDHSessionStore store;
+	private DHSession initiatorDHSession;
+	private DHSession responderDHSession;
+
+	@Before
+	public void setup() {
+		tempDbFileName = "threema-fs-test-" + System.currentTimeMillis() + ".db";
+		store = new SQLDHSessionStore(ApplicationProvider.getApplicationContext(), DATABASE_KEY, tempDbFileName);
+	}
+
+	@After
+	public void tearDown() {
+		store.close();
+		store = null;
+		ApplicationProvider.getApplicationContext().deleteDatabase(tempDbFileName);
+	}
+
+	public void createSessions() {
+		// Alice is the initiator (= us)
+		this.initiatorDHSession = new DHSession(
+			DummyUsers.getContactForUser(DummyUsers.BOB),
+			DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
+		);
+
+		// Bob gets an init message from Alice with her ephemeral public key
+		this.responderDHSession = new DHSession(
+			this.initiatorDHSession.getId(),
+			this.initiatorDHSession.getMyEphemeralPublicKey(),
+			DummyUsers.getContactForUser(DummyUsers.ALICE),
+			DummyUsers.getIdentityStoreForUser(DummyUsers.BOB)
+		);
+	}
+
+	@Test
+	public void testStoreInitiatorSession() throws DHSessionStoreException, DHSession.MissingEphemeralPrivateKeyException {
+		// Assume that we are Alice = the initiator, and Bob is the responder
+		createSessions();
+
+		// Delete any stored initiator session to start with a clean slate
+		store.deleteAllDHSessions(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity());
+		Assert.assertNull(this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity()));
+
+		// Insert an initiator DH session in 2DH mode
+		Assert.assertNotNull(this.initiatorDHSession.getMyRatchet2DH());
+		Assert.assertNull(this.initiatorDHSession.getMyRatchet4DH());
+		store.storeDHSession(this.initiatorDHSession);
+
+		// Retrieve the session again and ensure that the details match
+		Assert.assertEquals(this.initiatorDHSession, this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity()));
+
+		// Turn 2DH ratchets once (need to do this here, as responder sessions are always 4DH)
+		this.initiatorDHSession.getMyRatchet2DH().turn();
+		store.storeDHSession(this.initiatorDHSession);
+		Assert.assertEquals(this.initiatorDHSession, this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity()));
+
+		// Now Bob sends his ephemeral public key back to Alice
+		this.initiatorDHSession.processAccept(
+			this.responderDHSession.getMyEphemeralPublicKey(),
+			DummyUsers.getContactForUser(DummyUsers.BOB),
+			DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
+		);
+
+		// initiatorDHSession has now been upgraded to 4DH - store and retrieve it again
+		Assert.assertNotNull(this.initiatorDHSession.getMyRatchet4DH());
+		store.storeDHSession(this.initiatorDHSession);
+		DHSession bestSession = this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity());
+		Assert.assertNotNull(bestSession);
+		Assert.assertEquals(this.initiatorDHSession, bestSession);
+
+		// Check that the private key has been discarded
+		Assert.assertNull(bestSession.getMyEphemeralPrivateKey());
+
+		// Delete initiator DH session
+		store.deleteDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity(), this.initiatorDHSession.getId());
+		Assert.assertNull(this.store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity()));
+	}
+
+	@Test
+	public void testStoreResponderSession() throws DHSessionStoreException {
+		// Assume that we are Bob = the responder
+		createSessions();
+
+		// Store and retrieve the responder session
+		store.storeDHSession(this.responderDHSession);
+		Assert.assertEquals(this.responderDHSession, this.store.getBestDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity()));
+
+		// Turn the 4DH ratchets once, store, retrieve and compare again
+		Assert.assertNotNull(this.responderDHSession.getMyRatchet4DH());
+		Assert.assertNotNull(this.responderDHSession.getPeerRatchet4DH());
+		this.responderDHSession.getMyRatchet4DH().turn();
+		this.responderDHSession.getPeerRatchet4DH().turn();
+		store.storeDHSession(this.responderDHSession);
+		Assert.assertEquals(this.responderDHSession, this.store.getBestDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity()));
+
+		// Try to retrieve a responder session with a random session ID
+		Assert.assertNull(this.store.getDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), new DHSessionId()));
+
+		// Delete DH session
+		store.deleteDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), this.responderDHSession.getId());
+		Assert.assertNull(this.store.getBestDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity()));
+	}
+
+	@Test
+	public void testDiscardRatchet() throws DHSessionStoreException {
+		// Assume that we are Bob = the responder
+		createSessions();
+
+		Assert.assertNotNull(this.responderDHSession.getPeerRatchet2DH());
+		Assert.assertNotNull(this.responderDHSession.getPeerRatchet4DH());
+
+		// Store the responder session, including the 2DH ratchet
+		store.storeDHSession(this.responderDHSession);
+
+		// There should still be a 2DH ratchet at this point
+		DHSession retrievedSession = store.getDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), this.responderDHSession.getId());
+		Assert.assertNotNull(retrievedSession);
+		Assert.assertNotNull(retrievedSession.getPeerRatchet2DH());
+
+		// Discard the 2DH ratchet (assume Bob has received a 4DH message from Alice)
+		this.responderDHSession.discardPeerRatchet2DH();
+		Assert.assertNull(this.responderDHSession.getPeerRatchet2DH());
+
+		// Store the responder session again without the 2DH ratchet
+		store.storeDHSession(this.responderDHSession);
+
+		// Ensure that the 2DH ratchet is really gone
+		retrievedSession = store.getDHSession(DummyUsers.BOB.getIdentity(), DummyUsers.ALICE.getIdentity(), this.responderDHSession.getId());
+		Assert.assertNotNull(retrievedSession);
+		Assert.assertNull(retrievedSession.getPeerRatchet2DH());
+	}
+
+	@Test
+	public void testRaceCondition() throws DHSession.MissingEphemeralPrivateKeyException, DHSessionStoreException {
+		// Repeat the test several times, as random session IDs are involved
+		for (int i = 0; i < NUM_RANDOM_RUNS; i++) {
+			if (i > 0) {
+				tearDown();
+				setup();
+			}
+			testRaceConditionOnce();
+		}
+	}
+
+	private void testRaceConditionOnce() throws DHSession.MissingEphemeralPrivateKeyException, DHSessionStoreException {
+		createSessions();
+
+		// Alice stores the session that she initiated (still in 2DH mode)
+		store.storeDHSession(this.initiatorDHSession);
+
+		// Pretend Bob has created a (separate) DH session before he has received the Init from Alice
+		DHSession raceInitiatorDHSession = new DHSession(
+			DummyUsers.getContactForUser(DummyUsers.BOB),
+			DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
+		);
+
+		// Alice gets the Init for Bob's new session first and processes it
+		DHSession raceResponderDHSession = new DHSession(
+			raceInitiatorDHSession.getId(),
+			raceInitiatorDHSession.getMyEphemeralPublicKey(),
+			DummyUsers.getContactForUser(DummyUsers.BOB),
+			DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
+		);
+
+		store.storeDHSession(raceResponderDHSession);
+
+		// Alice then processes the Accept from Bob and stores the session
+		this.initiatorDHSession.processAccept(
+			this.responderDHSession.getMyEphemeralPublicKey(),
+			DummyUsers.getContactForUser(DummyUsers.BOB),
+			DummyUsers.getIdentityStoreForUser(DummyUsers.ALICE)
+		);
+
+		store.storeDHSession(this.initiatorDHSession);
+
+		// At this point, there should be only one DH session with Bob from Alice's point of view,
+		// and it should be the one with the lower session ID
+		DHSessionId lowestSessionId;
+		if (raceResponderDHSession.getId().compareTo(this.initiatorDHSession.getId()) < 0) {
+			lowestSessionId = raceResponderDHSession.getId();
+		} else {
+			lowestSessionId = this.initiatorDHSession.getId();
+		}
+		DHSession bestSession = store.getBestDHSession(DummyUsers.ALICE.getIdentity(), DummyUsers.BOB.getIdentity());
+		Assert.assertNotNull(bestSession);
+		Assert.assertEquals(lowestSessionId, bestSession.getId());
+	}
+}

+ 52 - 42
app/src/main/AndroidManifest.xml

@@ -1,10 +1,9 @@
 <?xml version="1.0" encoding="utf-8"?>
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
-          xmlns:tools="http://schemas.android.com/tools"
-          package="ch.threema.app"
-          android:installLocation="internalOnly"
-          android:testOnly="false"
-          tools:ignore="GoogleAppIndexingWarning">
+	xmlns:tools="http://schemas.android.com/tools"
+	android:installLocation="internalOnly"
+	android:testOnly="false"
+	tools:ignore="GoogleAppIndexingWarning">
 
 	<supports-screens
 		android:anyDensity="true"
@@ -61,7 +60,9 @@
 
 	<!-- Access to audio settings and bluetooth for voice calls -->
 	<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
-	<uses-permission android:name="android.permission.BLUETOOTH"/>
+	<uses-permission
+		android:name="android.permission.BLUETOOTH"
+		android:maxSdkVersion="30" />
 	<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
 
 	<!-- Permissions for biometric unlocking  -->
@@ -171,7 +172,8 @@
 		android:allowAudioPlaybackCapture="false"
 		android:appCategory="social"
 		android:hasFragileUserData="true"
-		tools:replace="android:supportsRtl,android:allowBackup">
+		tools:replace="android:supportsRtl,android:allowBackup"
+		android:dataExtractionRules="@xml/data_extraction_rules">
 		<!-- Note: The "replace" entry above should be kept to ensure that a library cannot accidentally
 		override rtl or backup support. Unfortunately the linter warning cannot be silenced. -->
 		<meta-data
@@ -284,14 +286,16 @@
 		</activity>
 		<activity
 			android:name=".preference.SettingsNotificationsDummyActivity"
-			android:label="@string/app_name">
+			android:label="@string/app_name"
+			android:exported="true">
 			<intent-filter>
 				<action android:name="android.intent.action.MAIN"/>
 				<category android:name="android.intent.category.NOTIFICATION_PREFERENCES"/>
 			</intent-filter>
 		</activity>
 		<activity
-			android:name=".preference.SettingsMediaDummyActivity">
+			android:name=".preference.SettingsMediaDummyActivity"
+			android:exported="true">
 			<intent-filter>
 				<action android:name="android.intent.action.MANAGE_NETWORK_USAGE"/>
 				<category android:name="android.intent.category.DEFAULT"/>
@@ -309,7 +313,8 @@
 			android:name=".activities.AddContactActivity"
 			android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
 			android:launchMode="singleTask"
-			android:theme="@style/Theme.Threema.Translucent">
+			android:theme="@style/Theme.Threema.Translucent"
+			android:exported="true">
 			<intent-filter android:label="@string/app_name">
 				<action android:name="android.intent.action.VIEW"/>
 
@@ -363,7 +368,8 @@
 			android:launchMode="singleTop"
 			android:screenOrientation="sensorPortrait"
 			android:theme="@style/Theme.Threema.Wizard"
-			android:windowSoftInputMode="adjustPan">
+			android:windowSoftInputMode="adjustPan"
+			android:exported="true">
 			<intent-filter android:priority="1000">
 				<action android:name="android.intent.action.VIEW"/>
 
@@ -542,7 +548,7 @@
 			android:name=".activities.ImagePaintActivity"
 			android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
 			android:theme="@style/Theme.Threema.LowProfile"
-			android:windowSoftInputMode="stateHidden|adjustPan"/>
+			android:windowSoftInputMode="stateHidden|adjustResize"/>
 
 		<!-- Webclient activities -->
 		<activity
@@ -576,17 +582,26 @@
 			android:excludeFromRecents="true"
 			android:screenOrientation="sensorPortrait"
 			android:theme="@style/Theme.Threema.TransparentStatusbar"
-			tools:ignore="LockedOrientationActivity">
+			tools:ignore="LockedOrientationActivity"
+			android:exported="true">
 			<intent-filter android:label="@string/threema_call">
 				<action android:name="android.intent.action.VIEW"/>
 
 				<category android:name="android.intent.category.DEFAULT"/>
 			</intent-filter>
 		</activity>
+		<activity
+			android:name=".voip.activities.GroupCallActivity"
+			android:exported="false"
+			android:launchMode="singleTask"
+			android:taskAffinity=".voip.activities.GroupCallActivity"
+			android:excludeFromRecents="true"
+			android:theme="@style/Theme.Threema.TransparentStatusbar_Fullscreen" />
 		<activity
 			android:name=".voip.activities.CallActionIntentActivity"
 			android:screenOrientation="sensorPortrait"
-			tools:ignore="LockedOrientationActivity">
+			tools:ignore="LockedOrientationActivity"
+			android:exported="true">
 			<intent-filter tools:ignore="AppLinkUrlError">
 				<!-- Handle calls from phonebook -->
 				<action android:name="android.intent.action.VIEW"/>
@@ -646,7 +661,8 @@
 			android:windowSoftInputMode="stateAlwaysHidden"/>
 		<activity
 			android:name=".activities.SMSVerificationLinkActivity"
-			android:theme="@android:style/Theme.NoDisplay">
+			android:theme="@android:style/Theme.NoDisplay"
+			android:exported="true" >
 			<intent-filter>
 				<action android:name="android.intent.action.VIEW"/>
 
@@ -737,7 +753,8 @@
 			android:name=".activities.AppLinksActivity"
 			android:autoRemoveFromRecents="true"
 			android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
-			android:theme="@style/Theme.Threema.Transparent.Background">
+			android:theme="@style/Theme.Threema.Transparent.Background"
+			android:exported="true">
 			<intent-filter android:autoVerify="true">
 				<action android:name="android.intent.action.VIEW"/>
 
@@ -855,11 +872,6 @@
 			android:name=".services.WidgetService"
 			android:permission="android.permission.BIND_REMOTEVIEWS"
 			android:exported="false"/>
-		<service
-			android:name=".services.ConnectivityChangeService"
-			android:permission="android.permission.BIND_JOB_SERVICE"
-			android:enabled="true"
-			android:exported="false"/>
 		<service
 			android:name=".services.RestrictBackgroundChangedService"
 			android:permission="android.permission.BIND_JOB_SERVICE"
@@ -886,14 +898,6 @@
 			android:name=".webclient.services.SessionAndroidService"
 			android:exported="false"
 			android:label="WebClientService"/>
-		<service
-			android:name=".threemasafe.ThreemaSafeUploadJobService"
-			android:permission="android.permission.BIND_JOB_SERVICE"
-			android:exported="false"/>
-		<service
-			android:name=".threemasafe.ThreemaSafeUploadService"
-			android:permission="android.permission.BIND_JOB_SERVICE"
-			android:exported="false"/>
 		<service
 			android:name=".webclient.services.StopSessionsAndroidService"
 			android:enabled="true"
@@ -903,19 +907,21 @@
 			android:enabled="true"
 			android:exported="false"/>
 		<service
-			android:name=".jobs.WorkSyncJobService"
-			android:enabled="true"
+			android:name=".services.ThreemaPushService"
 			android:exported="false"
-			android:permission="android.permission.BIND_JOB_SERVICE"/>
+			android:label="ThreemaPushService"/>
+
 		<service
-			android:name=".jobs.WorkSyncService"
-			android:enabled="true"
+			android:name=".voip.groupcall.service.GroupCallService"
 			android:exported="false"
-			android:permission="android.permission.BIND_JOB_SERVICE"/>
+			android:foregroundServiceType="phoneCall" />
+
+		<!-- Set the exported attribute for the sharetarget (needed for target api 31) -->
 		<service
-			android:name=".services.ThreemaPushService"
-			android:exported="false"
-			android:label="ThreemaPushService"/>
+			android:name="androidx.sharetarget.ChooserTargetServiceCompat"
+			android:exported="true"
+			tools:node="merge">
+		</service>
 
 		<!-- broadcast receivers -->
 		<receiver
@@ -933,7 +939,8 @@
 		</receiver>
 		<receiver android:name=".receivers.AlarmManagerBroadcastReceiver">
 		</receiver>
-		<receiver android:name=".receivers.WidgetProvider">
+		<receiver android:name=".receivers.WidgetProvider"
+			android:exported="true">
 			<intent-filter>
 				<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
 			</intent-filter>
@@ -942,7 +949,8 @@
 				android:name="android.appwidget.provider"
 				android:resource="@xml/appwidget_info"/>
 		</receiver>
-		<receiver android:name=".receivers.UpdateReceiver">
+		<receiver android:name=".receivers.UpdateReceiver"
+			android:exported="true">
 			<intent-filter>
 				<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
 			</intent-filter>
@@ -952,12 +960,14 @@
 			android:exported="false">
 		</receiver>
 		<receiver android:name=".receivers.FetchMessagesBroadcastReceiver"/>
-		<receiver android:name=".voip.receivers.VoipMediaButtonReceiver">
+		<receiver android:name=".voip.receivers.VoipMediaButtonReceiver"
+			android:exported="true">
 			<intent-filter>
 				<action android:name="android.intent.action.MEDIA_BUTTON"/>
 			</intent-filter>
 		</receiver>
-		<receiver android:name=".receivers.PowerSaveModeReceiver">
+		<receiver android:name=".receivers.PowerSaveModeReceiver"
+			android:exported="true">
 			<intent-filter>
 				<action android:name="android.os.action.POWER_SAVE_MODE_CHANGED"/>
 			</intent-filter>

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

@@ -44,6 +44,7 @@ import ch.threema.base.utils.LoggingUtil;
 import ch.threema.localcrypto.MasterKey;
 
 import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_NOTICE;
+import static ch.threema.app.utils.IntentDataUtil.PENDING_INTENT_FLAG_IMMUTABLE;
 
 public class AutostartService extends FixedJobIntentService {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("AutostartService");
@@ -76,7 +77,7 @@ public class AutostartService extends FixedJobIntentService {
 
 			Intent notificationIntent = IntentDataUtil.createActionIntentHideAfterUnlock(new Intent(this, HomeActivity.class));
 			notificationIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
-			PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, 0);
+			PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PENDING_INTENT_FLAG_IMMUTABLE);
 			notificationCompat.setContentIntent(pendingIntent);
 			NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
 			notificationManager.notify(ThreemaApplication.MASTER_KEY_LOCKED_NOTIFICATION_ID, notificationCompat.build());

+ 91 - 54
app/src/main/java/ch/threema/app/ThreemaApplication.java

@@ -26,11 +26,9 @@ import android.annotation.TargetApi;
 import android.app.Activity;
 import android.app.ActivityManager;
 import android.app.ApplicationExitInfo;
+import android.app.ForegroundServiceStartNotAllowedException;
 import android.app.NotificationManager;
-import android.app.job.JobInfo;
-import android.app.job.JobScheduler;
 import android.content.BroadcastReceiver;
-import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
@@ -60,7 +58,9 @@ import androidx.multidex.MultiDexApplication;
 import androidx.preference.PreferenceManager;
 import androidx.work.Constraints;
 import androidx.work.ExistingPeriodicWorkPolicy;
+import androidx.work.ExistingWorkPolicy;
 import androidx.work.NetworkType;
+import androidx.work.OneTimeWorkRequest;
 import androidx.work.PeriodicWorkRequest;
 import androidx.work.WorkManager;
 
@@ -92,8 +92,6 @@ 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;
 import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactSettingsListener;
@@ -142,6 +140,7 @@ import ch.threema.app.utils.LinuxSecureRandom;
 import ch.threema.app.utils.LoggingUEH;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.PushUtil;
+import ch.threema.app.utils.RogueDeviceMonitorImpl;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.ShortcutUtil;
 import ch.threema.app.utils.StateBitmapUtil;
@@ -160,6 +159,7 @@ import ch.threema.app.webclient.services.instance.DisconnectContext;
 import ch.threema.app.webclient.state.WebClientSessionState;
 import ch.threema.app.workers.IdentityStatesWorker;
 import ch.threema.app.workers.ShareTargetUpdateWorker;
+import ch.threema.app.workers.WorkSyncWorker;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.crypto.NonceFactory;
 import ch.threema.base.utils.LoggingUtil;
@@ -221,8 +221,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	public static final String EXTRA_OUTPUT_FILE = "output";
 	public static final String EXTRA_ORIENTATION = "rotate";
 	public static final String EXTRA_FLIP = "flip";
-	public static final String EXTRA_EXIF_ORIENTATION = "rotateExif";
-	public static final String EXTRA_EXIF_FLIP = "flipExif";
 	public static final String INTENT_DATA_CHECK_ONLY = "check";
 	public static final String INTENT_DATA_ANIM_CENTER = "itemPos";
 	public static final String INTENT_DATA_PICK_FROM_CAMERA = "useCam";
@@ -251,6 +249,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	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;
+	public static final int INCOMING_GROUP_CALL_NOTIFICATION_ID = 803;
 
 	private static final String THREEMA_APPLICATION_LISTENER_TAG = "al";
 	public static final String AES_KEY_FILE = "key.dat";
@@ -270,10 +269,14 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 	public static final int MAX_PW_LENGTH_BACKUP = 256;
 	public static final int MIN_PW_LENGTH_ID_EXPORT_LEGACY = 4; // extremely ancient versions of the app on some platform accepted four-letter passwords when generating ID exports
 
-	private static final int WORK_SYNC_JOB_ID = 63339;
-
 	private static final String WORKER_IDENTITY_STATES_PERIODIC_NAME = "IdentityStates";
 	public static final String WORKER_SHARE_TARGET_UPDATE = "ShareTargetUpdate";
+	public static final String WORKER_WORK_SYNC = "WorkSync";
+	public static final String WORKER_PERIODIC_WORK_SYNC = "PeriodicWorkSync";
+	public static final String WORKER_THREEMA_SAFE_UPLOAD = "SafeUpload";
+	public static final String WORKER_PERIODIC_THREEMA_SAFE_UPLOAD = "PeriodicSafeUpload";
+	public static final String WORKER_CONNECTIVITY_CHANGE = "ConnectivityChange";
+
 	private static final String EXIT_REASON_LOGGING_TIMESTAMP = "exit_reason_timestamp";
 
 	private static Context context;
@@ -533,12 +536,19 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 								}
 							} else {
 								logger.info("*** Device waking up");
-								try {
-									serviceManager.getLifetimeService().unpause();
-								} catch (Exception e) {
-									logger.error("Exception while unpausing connection", e);
+								if (serviceManager != null) {
+									try {
+										serviceManager.getLifetimeService().unpause();
+									} catch (Exception e) {
+										logger.error("Exception while unpausing connection", e);
+									}
+									isDeviceIdle = false;
+								} else {
+									logger.info("Service manager unavailable");
+									if (masterKey != null && !masterKey.isLocked()) {
+										reset();
+									}
 								}
-								isDeviceIdle = false;
 							}
 						}
 					}, new IntentFilter(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED));
@@ -591,9 +601,12 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 						@Override
 						public void onReceive(Context context, Intent intent) {
 							AppRestrictionService.getInstance().reload();
-							Intent syncIntent = new Intent();
-							syncIntent.putExtra(WorkSyncService.EXTRA_WORK_UPDATE_RESTRICTIONS_ONLY, true);
-							WorkSyncService.enqueueWork(getAppContext(), syncIntent, true);
+							try {
+								OneTimeWorkRequest workRequest = WorkSyncWorker.Companion.buildOneTimeWorkRequest(true, true, null);
+								WorkManager.getInstance(ThreemaApplication.getAppContext()).enqueueUniqueWork(WORKER_WORK_SYNC, ExistingWorkPolicy.REPLACE, workRequest);
+							} catch (IllegalStateException e) {
+								logger.error("Unable to schedule work sync one time work", e);
+							}
 						}
 					}, new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED));
 				}
@@ -902,12 +915,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 				nonceSqlcipherVersion = 3;
 			}
 
-			logger.info(
-				"*** App launched. Device/Android Version/Flavor: {} Version: {} Build: {}",
-				ConfigUtils.getDeviceInfo(getAppContext(), false),
-				BuildConfig.VERSION_NAME,
-				ConfigUtils.getBuildNumber(getAppContext())
-			);
+			logger.info("*** App launched");
+			logVersion();
 
 			if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
 				ActivityManager activityManager = (ActivityManager) getAppContext().getSystemService(Context.ACTIVITY_SERVICE);
@@ -995,6 +1004,8 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 
 			connection.setServerAddressProvider(serviceManager.getServerAddressProviderService().getServerAddressProvider());
 
+			connection.setRogueDeviceMonitor(new RogueDeviceMonitorImpl(serviceManager));
+
 			// get application restrictions
 			if (ConfigUtils.isWorkBuild()) {
 				AppRestrictionService.getInstance()
@@ -1083,6 +1094,19 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 		}
 	}
 
+	public static void logVersion() {
+		String commitHash = BuildConfig.DEBUG
+			? ", Commit: " + BuildConfig.GIT_HASH
+			: "";
+		logger.info(
+			"*** App Version. Device/Android Version/Flavor: {} Version: {} Build: {}{}",
+			ConfigUtils.getDeviceInfo(getAppContext(), false),
+			BuildConfig.VERSION_NAME,
+			ConfigUtils.getBuildNumber(getAppContext()),
+			commitHash
+		);
+	}
+
 	private static void initMapLibre() {
 		if (ConfigUtils.hasNoMapLibreSupport()) {
 			logger.debug("*** MapLibre disabled due to faulty firmware");
@@ -1092,7 +1116,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 		}
 	}
 
-	private static long getSchedulePeriod(PreferenceStore preferenceStore, int key) {
+	private static long getSchedulePeriodMs(PreferenceStore preferenceStore, int key) {
 		Integer schedulePeriod = preferenceStore.getInt(getAppContext().getString(key));
 		if (schedulePeriod == null || schedulePeriod == 0) {
 			schedulePeriod = (int) DateUtils.DAY_IN_MILLIS;
@@ -1104,14 +1128,14 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 
 	@WorkerThread
 	private static boolean scheduleIdentityStatesSync(PreferenceStore preferenceStore) {
-		long schedulePeriod = getSchedulePeriod(preferenceStore, R.string.preferences__identity_states_check_interval);
+		long schedulePeriod = getSchedulePeriodMs(preferenceStore, R.string.preferences__identity_states_check_interval);
 
 		logger.info("Initializing Identity States sync. Requested schedule period: {} ms", schedulePeriod);
 
 		try {
 			WorkManager workManager = WorkManager.getInstance(context);
 
-			if (WorkManagerUtil.cancelExistingWorkManagerInstance(
+			if (WorkManagerUtil.shouldScheduleNewWorkManagerInstance(
 				workManager,
 				WORKER_IDENTITY_STATES_PERIODIC_NAME,
 				schedulePeriod
@@ -1143,22 +1167,23 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			return false;
 		}
 
-		long schedulePeriod = getSchedulePeriod(preferenceStore, R.string.preferences__work_sync_check_interval);
-
-		logger.info("Scheduling Work Sync. Schedule period: {}", schedulePeriod);
+		long schedulePeriodMs = getSchedulePeriodMs(preferenceStore, R.string.preferences__work_sync_check_interval);
+		logger.info("Scheduling periodic work sync. Schedule period: {}", schedulePeriodMs);
 
-		// schedule the start of the service according to schedule period
-		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;
+		try {
+			WorkManager workManager = WorkManager.getInstance(context);
+			PeriodicWorkRequest workRequest = WorkSyncWorker.Companion.buildPeriodicWorkRequest(schedulePeriodMs);
+			workManager.enqueueUniquePeriodicWork(WORKER_PERIODIC_WORK_SYNC,
+				WorkManagerUtil.shouldScheduleNewWorkManagerInstance(workManager, WORKER_PERIODIC_WORK_SYNC, schedulePeriodMs) ?
+					ExistingPeriodicWorkPolicy.REPLACE :
+					ExistingPeriodicWorkPolicy.KEEP,
+				workRequest);
+		} catch (IllegalStateException e) {
+			logger.error("Unable to schedule periodic work sync work", e);
+			return false;
 		}
-		logger.debug("unable to schedule work sync");
-		return false;
+
+		return true;
 	}
 
 	@WorkerThread
@@ -1170,7 +1195,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 		try {
 			WorkManager workManager = WorkManager.getInstance(context);
 
-			if (WorkManagerUtil.cancelExistingWorkManagerInstance(
+			if (WorkManagerUtil.shouldScheduleNewWorkManagerInstance(
 				workManager,
 				WORKER_SHARE_TARGET_UPDATE,
 				schedulePeriod
@@ -1356,8 +1381,9 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 				if (myIdentity != null && myIdentity.equals(identity)) {
 					// my own member status has changed
 					try {
+						serviceManager.getNotificationService().cancelGroupCallNotification(group.getId());
 						serviceManager.getConversationService().refresh(group);
-					} catch (ThreemaException e) {
+					} catch (Exception e) {
 						logger.error("Exception", e);
 					}
 				}
@@ -1378,7 +1404,6 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 							serviceManager.getContext().getString(R.string.status_group_member_kicked, memberName),
 							receiver);
 
-
 					BallotService ballotService = serviceManager.getBallotService();
 					ballotService.removeVotes(receiver, identity);
 				} catch (ThreemaException e) {
@@ -1408,7 +1433,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 
 			@Override
 			public void onGroupStateChanged(GroupModel groupModel, @GroupService.GroupState int oldState, @GroupService.GroupState int newState) {
-				logger.debug("&&& onGroupStateChanged: {} -> {}", oldState, newState);
+				logger.debug("onGroupStateChanged: {} -> {}", oldState, newState);
 
 				showNotesGroupNotice(groupModel, oldState, newState);
 			}
@@ -1454,7 +1479,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 			public void onNew(AbstractMessageModel newMessage) {
 				logger.debug("MessageListener.onNewMessage");
 				if (!newMessage.isStatusMessage()) {
-					showConversationNotification(newMessage, false);
+						showConversationNotification(newMessage, false);
 				}
 			}
 
@@ -1525,14 +1550,15 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 						DeadlineListService hiddenChatsListService = serviceManager.getHiddenChatsListService();
 
 						if (TestUtil.required(notificationService, contactService, groupService)) {
-
-							notificationService.addConversationNotification(ConversationNotificationUtil.convert(
-									getAppContext(),
-									newMessage,
-									contactService,
-									groupService,
-									hiddenChatsListService),
+							if (newMessage.getType() != MessageType.GROUP_CALL_STATUS) {
+								notificationService.addConversationNotification(ConversationNotificationUtil.convert(
+										getAppContext(),
+										newMessage,
+										contactService,
+										groupService,
+										hiddenChatsListService),
 									updateExisting);
+							}
 
 							// update widget on incoming message
 							WidgetUtil.updateWidgets(serviceManager.getContext());
@@ -1618,7 +1644,7 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 					try {
 						serviceManager.getConversationService().removed(removedContactModel);
 						serviceManager.getNotificationService().cancel(new ContactMessageReceiver(
-							removedContactModel, serviceManager.getContactService(), null, null, null, null)
+							removedContactModel, serviceManager.getContactService(), null, null, null, null, null)
 						);
 
 						//remove custom avatar (ANDR-353)
@@ -1913,7 +1939,18 @@ public class ThreemaApplication extends MultiDexApplication implements DefaultLi
 						logger.info( "SessionAndroidService not running...starting");
 						intent.setAction(SessionAndroidService.ACTION_START);
 						logger.info( "sending ACTION_START to SessionAndroidService");
-						ContextCompat.startForegroundService(context, intent);
+						if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+							// Starting on version S, foreground services cannot be started from the background.
+							// When battery optimizations are disabled (recommended for Threema Web), then no
+							// exception is thrown. Otherwise we just log it.
+							try {
+								ContextCompat.startForegroundService(context, intent);
+							} catch (ForegroundServiceStartNotAllowedException exception) {
+								logger.error("Couldn't start foreground service", exception);
+							}
+						} else {
+							ContextCompat.startForegroundService(context, intent);
+						}
 					}
 				});
 			}

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

@@ -206,7 +206,7 @@ public class ComposeMessageActivity extends ThreemaToolbarActivity implements Ge
 
 	@Override
 	public void onActivityResult(int requestCode, int resultCode,
-								 final Intent intent) {
+	                             final Intent intent) {
 		switch (requestCode) {
 			case ID_HIDDEN_CHECK_ON_CREATE:
 				super.onActivityResult(requestCode, resultCode, intent);

+ 24 - 15
app/src/main/java/ch/threema/app/activities/ContactDetailActivity.java

@@ -21,6 +21,8 @@
 
 package ch.threema.app.activities;
 
+import static ch.threema.app.utils.QRScannerUtil.REQUEST_CODE_QR_SCANNER;
+
 import android.Manifest;
 import android.annotation.SuppressLint;
 import android.annotation.TargetApi;
@@ -38,6 +40,19 @@ import android.view.View;
 import android.widget.TextView;
 import android.widget.Toast;
 
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.UiThread;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.view.menu.MenuBuilder;
+import androidx.core.app.ActivityCompat;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
 import com.google.android.material.appbar.AppBarLayout;
 import com.google.android.material.appbar.CollapsingToolbarLayout;
 import com.google.android.material.floatingactionbutton.FloatingActionButton;
@@ -48,15 +63,6 @@ import java.io.File;
 import java.util.Date;
 import java.util.List;
 
-import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.UiThread;
-import androidx.appcompat.app.ActionBar;
-import androidx.appcompat.view.menu.MenuBuilder;
-import androidx.fragment.app.Fragment;
-import androidx.lifecycle.LifecycleOwner;
-import androidx.recyclerview.widget.LinearLayoutManager;
-import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.adapters.ContactDetailAdapter;
@@ -101,8 +107,6 @@ import ch.threema.domain.models.VerificationLevel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 
-import static ch.threema.app.utils.QRScannerUtil.REQUEST_CODE_QR_SCANNER;
-
 public class ContactDetailActivity extends ThreemaToolbarActivity
 		implements LifecycleOwner,
 					GenericAlertDialog.DialogClickListener,
@@ -130,7 +134,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	private IdListService blackListIdentityService, profilePicRecipientsService;
 	private MessageService messageService;
 	private DeadlineListService hiddenChatsListService;
-	private LicenseService licenseService;
 	private VoipStateService voipStateService;
 	private MenuItem blockMenuItem = null, profilePicItem = null, profilePicSendItem = null, callItem = null;
 	private boolean isReadonly;
@@ -143,6 +146,13 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 	private List<GroupModel> groupList;
 	private boolean isDisabledProfilePicReleaseSettings = false;
 	private View workIcon;
+	private final ActivityResultLauncher<String> readPhoneStatePermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
+		if (!isGranted && !ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.READ_PHONE_STATE)) {
+			ConfigUtils.showPermissionRationale(this, findViewById(R.id.main_content), R.string.read_phone_state_short_message);
+		} else {
+			VoipUtil.initiateCall(this, contact, false, null, null);
+		}
+	});
 
 	private final ResumePauseHandler.RunIfActive runIfActiveUpdate = new ResumePauseHandler.RunIfActive() {
 		@Override
@@ -298,7 +308,6 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 			this.groupService = serviceManager.getGroupService();
 			this.messageService = serviceManager.getMessageService();
 			this.hiddenChatsListService = serviceManager.getHiddenChatsListService();
-			this.licenseService = serviceManager.getLicenseService();
 			this.voipStateService = serviceManager.getVoipStateService();
 		} catch (Exception e) {
 			LogUtil.exception(e, this);
@@ -605,7 +614,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 		if (callItem != null) {
 			if (
 				ContactUtil.canReceiveVoipMessages(contact, blackListIdentityService)
-					&& ConfigUtils.isCallsEnabled(ContactDetailActivity.this, preferenceService, licenseService)) {
+					&& ConfigUtils.isCallsEnabled()) {
 				logger.debug("updateVoipMenu newState " + newState);
 
 				callItem.setVisible(newState != null ? newState : voipStateService.getCallState().isIdle());
@@ -637,7 +646,7 @@ public class ContactDetailActivity extends ThreemaToolbarActivity
 				}
 				break;
 			case R.id.menu_threema_call:
-				VoipUtil.initiateCall(this, contact, false, null);
+				VoipUtil.initiateCall(this, contact, false, null, readPhoneStatePermissionLauncher);
 				break;
 			case R.id.action_block_contact:
 				if (this.blackListIdentityService != null && this.blackListIdentityService.has(this.contact.getIdentity())) {

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

@@ -239,17 +239,17 @@ class EditSendContactActivity : ThreemaToolbarActivity() {
         toolbar.animation?.cancel()
         toolbar.alpha = 1f
         toolbar.animate().alpha(0f).setDuration(100).setListener(object : AnimatorListenerAdapter() {
-            override fun onAnimationStart(animation: Animator?) {}
-            override fun onAnimationEnd(animation: Animator?) {
+            override fun onAnimationStart(animation: Animator) {}
+            override fun onAnimationEnd(animation: Animator) {
                 toolbar.visibility = View.INVISIBLE
                 window.statusBarColor = ConfigUtils.getColorFromAttribute(this@EditSendContactActivity, R.attr.attach_status_bar_color_collapsed)
             }
 
-            override fun onAnimationCancel(animation: Animator?) {
+            override fun onAnimationCancel(animation: Animator) {
                 window.statusBarColor = ConfigUtils.getColorFromAttribute(this@EditSendContactActivity, R.attr.attach_status_bar_color_collapsed)
             }
 
-            override fun onAnimationRepeat(animation: Animator?) {}
+            override fun onAnimationRepeat(animation: Animator) {}
         })
     }
 

+ 32 - 14
app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java

@@ -247,25 +247,43 @@ public class EnterSerialActivity extends ThreemaActivity {
 
 	private void parseUrlAndCheck(Uri data) {
 		String query = data.getQuery();
-
 		if (!TestUtil.empty(query)) {
 			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), server);
-					return;
-				}
+				parseWorkLicense(data);
 			} else {
-				final String key = data.getQueryParameter("key");
-				if (!TestUtil.empty(key)) {
-					check(new SerialCredentials(key), null);
-					return;
-				}
+				parseConsumerLicense(data);
+			}
+		}
+	}
+
+	private void parseConsumerLicense(Uri data) {
+		final String key = data.getQueryParameter("key");
+		if (!TestUtil.empty(key)) {
+			check(new SerialCredentials(key), null);
+		}
+	}
+
+	private void parseWorkLicense(Uri data) {
+		final String username = data.getQueryParameter("username");
+		final String password = data.getQueryParameter("password");
+		final String server = data.getQueryParameter("server");
+
+		if (ConfigUtils.isOnPremBuild()) {
+			if (!TestUtil.empty(username) && !TestUtil.empty(password) && !TestUtil.empty(server)) {
+				check(new UserCredentials(username, password), server);
+			} else {
+				licenseKeyOrUsernameText.setText(username);
+				passwordText.setText(password);
+				serverText.setText(server);
+			}
+		} else {
+			if (!TestUtil.empty(username) && !TestUtil.empty(password)) {
+				check(new UserCredentials(username, password), null);
+			} else {
+				licenseKeyOrUsernameText.setText(username);
+				passwordText.setText(password);
 			}
 		}
-		Toast.makeText(this, R.string.invalid_input, Toast.LENGTH_LONG).show();
 	}
 
 	private void doUnlock() {

+ 130 - 29
app/src/main/java/ch/threema/app/activities/GroupDetailActivity.java

@@ -21,6 +21,7 @@
 
 package ch.threema.app.activities;
 
+import android.Manifest;
 import android.annotation.SuppressLint;
 import android.app.Activity;
 import android.content.Intent;
@@ -34,20 +35,12 @@ import android.text.Html;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
+import android.view.WindowManager;
 import android.widget.LinearLayout;
 import android.widget.Toast;
 
-import com.google.android.material.appbar.AppBarLayout;
-import com.google.android.material.appbar.CollapsingToolbarLayout;
-import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton;
-
-import org.slf4j.Logger;
-
-import java.io.File;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
 import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
 import androidx.appcompat.app.ActionBar;
@@ -55,10 +48,24 @@ import androidx.appcompat.view.menu.MenuBuilder;
 import androidx.appcompat.widget.Toolbar;
 import androidx.core.app.ActivityCompat;
 import androidx.core.app.ActivityOptionsCompat;
+import androidx.fragment.app.Fragment;
 import androidx.lifecycle.Observer;
 import androidx.lifecycle.ViewModelProvider;
 import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.appbar.AppBarLayout;
+import com.google.android.material.appbar.CollapsingToolbarLayout;
+import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton;
+
+import org.slf4j.Logger;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -68,12 +75,12 @@ import ch.threema.app.asynctasks.DeleteMyGroupAsyncTask;
 import ch.threema.app.asynctasks.LeaveGroupAsyncTask;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
+import ch.threema.app.dialogs.GroupDescEditDialog;
 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;
@@ -91,22 +98,27 @@ import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.ContactUtil;
 import ch.threema.app.utils.DialogUtil;
+import ch.threema.app.utils.GroupCallUtilKt;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.LogUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.app.voip.groupcall.GroupCallDescription;
+import ch.threema.app.voip.groupcall.GroupCallManager;
 import ch.threema.app.voip.util.VoipUtil;
+import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
-import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
 
+import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.COLLAPSED;
+import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.NONE;
+
 public class GroupDetailActivity extends GroupEditActivity implements SelectorDialog.SelectorDialogClickListener,
 	GenericAlertDialog.DialogClickListener,
 	TextEntryDialog.TextEntryDialogClickListener,
-	GroupDetailAdapter.OnGroupDetailsClickListener
-	{
+	GroupDetailAdapter.OnGroupDetailsClickListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("GroupDetailActivity");
 	// static values
 	private final int MODE_EDIT = 1;
@@ -119,6 +131,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	private static final String DIALOG_TAG_RESYNC_GROUP = "resyncGroup";
 	private static final String DIALOG_TAG_DELETE_GROUP = "delG";
 	private static final String DIALOG_TAG_CLONE_GROUP = "cg";
+	private static final String DIALOG_TAG_CHANGE_GROUP_DESC = "cgDesc";
 	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";
@@ -134,6 +147,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	private GroupInviteService groupInviteService;
 	private DeviceService deviceService;
 	private IdListService blackListIdentityService;
+	private GroupCallManager groupCallManager;
 
 	private GroupModel groupModel;
 	private GroupDetailViewModel groupDetailViewModel;
@@ -145,6 +159,13 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	private AvatarEditView avatarEditView;
 	private ExtendedFloatingActionButton floatingActionButton;
 
+	private final ActivityResultLauncher<String> readPhoneStatePermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
+		if (!isGranted && !ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.READ_PHONE_STATE)) {
+			ConfigUtils.showPermissionRationale(this, findViewById(R.id.main_content), R.string.read_phone_state_short_message);
+		}
+		// Note that the call cannot be started from here if the permission has just been granted.
+	});
+
 	private String myIdentity;
 	private int operationMode;
 	private int groupId;
@@ -304,7 +325,8 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			this.blackListIdentityService = serviceManager.getBlackListService();
 			this.licenseService = serviceManager.getLicenseService();
 			this.groupInviteService = serviceManager.getGroupInviteService();
-		} catch (FileSystemNotPresentException | MasterKeyLockedException e) {
+			this.groupCallManager = serviceManager.getGroupCallManager();
+		} catch (ThreemaException e) {
 			logger.error("Exception, could not get required services", e);
 			finishUp();
 			return;
@@ -325,6 +347,15 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			// new instance
 			this.groupDetailViewModel.setGroupContacts(this.contactService.getByIdentities(groupService.getGroupIdentities(this.groupModel)));
 			this.groupDetailViewModel.setGroupName(this.groupModel.getName());
+			String groupDesc = this.groupModel.getGroupDesc();
+			if (groupDesc == null || groupDesc.isEmpty()) {
+				this.groupDetailViewModel.setGroupDesc(null);
+				this.groupDetailViewModel.setGroupDescState(NONE);
+			} else {
+				this.groupDetailViewModel.setGroupDesc(groupDesc);
+				this.groupDetailViewModel.setGroupDescState(COLLAPSED);
+			}
+			this.groupDetailViewModel.setGroupDescTimestamp(this.groupModel.getGroupDescTimestamp());
 		}
 
 		this.avatarEditView.setHires(true);
@@ -380,9 +411,18 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		}
 
 		groupDetailRecyclerView.setLayoutManager(new LinearLayoutManager(this));
-
 		setupAdapter();
 
+		Fragment dialogFragment = getSupportFragmentManager().findFragmentByTag(DIALOG_TAG_CHANGE_GROUP_DESC);
+		if (dialogFragment instanceof GroupDescEditDialog) {
+			GroupDescEditDialog dialog = (GroupDescEditDialog) dialogFragment;
+			dialog.setCallback(newGroupDesc -> {
+				hideKeyboard(); // is used for older devices
+				onGroupDescChange(newGroupDesc);
+			});
+		}
+
+
 		groupDetailRecyclerView.setAdapter(this.groupDetailAdapter);
 
 		final Observer<List<ContactModel>> groupMemberObserver = new Observer<List<ContactModel>>() {
@@ -413,7 +453,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 	}
 
 	private void setupAdapter() {
-		this.groupDetailAdapter = new GroupDetailAdapter(this, this.groupModel);
+		this.groupDetailAdapter = new GroupDetailAdapter(this, this.groupModel, groupDetailViewModel);
 		this.groupDetailAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
 			@Override
 			public void onChanged() {
@@ -451,7 +491,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		final boolean isSortingFirstName = preferenceService.isContactListSortingFirstName();
 		List<ContactModel> contactModels = groupDetailViewModel.getGroupContacts();
 		Collections.sort(contactModels, (model1, model2) -> ContactUtil.getSafeNameString(model1, isSortingFirstName).compareTo(
-				ContactUtil.getSafeNameString(model2, isSortingFirstName)
+			ContactUtil.getSafeNameString(model2, isSortingFirstName)
 		));
 		groupDetailViewModel.setGroupContacts(contactModels);
 	}
@@ -487,12 +527,15 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		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);
+		MenuItem groupCallMenu = menu.findItem(R.id.menu_group_call);
 
 		if (AppRestrictionUtil.isCreateGroupDisabled(this)) {
 			cloneMenu.setVisible(false);
 		}
 
 		if (groupModel != null) {
+			GroupCallDescription call = groupCallManager.getCurrentChosenCall(groupModel);
+			groupCallMenu.setVisible(GroupCallUtilKt.qualifiesForGroupCalls(groupService, groupModel) && !hasChanges && call == null);
 			leaveGroupMenu.setVisible(true);
 			deleteGroupMenu.setVisible(true);
 			if (groupService.isGroupOwner(this.groupModel)) {
@@ -510,7 +553,6 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			menu.findItem(R.id.action_send_message).setVisible(false);
 		}
 
-
 		return super.onPrepareOptionsMenu(menu);
 	}
 
@@ -586,6 +628,8 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 				mediaGalleryIntent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, groupId);
 				startActivity(mediaGalleryIntent);
 			}
+		} else if (itemId == R.id.menu_group_call) {
+			GroupCallUtilKt.initiateCall(this, groupModel);
 		}
 		return super.onOptionsItemSelected(item);
 	}
@@ -621,6 +665,8 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 							newGroupName,
 							groupService.getGroupIdentities(groupModel),
 							avatar);
+					model.setGroupDesc(groupModel.getGroupDesc());
+					model.setGroupDescTimestamp(groupModel.getGroupDescTimestamp());
 				} catch (Exception e) {
 					logger.error("Exception, cloning group failed", e);
 					return null;
@@ -685,6 +731,10 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			this.groupDetailViewModel.setGroupName("");
 		}
 
+		groupModel.setGroupDesc(groupDetailViewModel.getGroupDesc());
+		groupModel.setGroupDescTimestamp(groupDetailViewModel.getGroupDescTimestamp());
+
+
 		new AsyncTask<Void, Void, GroupModel>() {
 			@Override
 			protected void onPreExecute() {
@@ -703,11 +753,12 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 					Bitmap avatar = groupDetailViewModel.getAvatarFile() != null ? BitmapFactory.decodeFile(groupDetailViewModel.getAvatarFile().getPath()) : null;
 
 					model = groupService.updateGroup(
-							groupModel,
-							groupDetailViewModel.getGroupName(),
-							groupDetailViewModel.getGroupIdentities(),
-							avatar,
-							groupDetailViewModel.getIsAvatarRemoved()
+						groupModel,
+						groupDetailViewModel.getGroupName(),
+						groupDetailViewModel.getGroupDesc(),
+						groupDetailViewModel.getGroupIdentities(),
+						avatar,
+						groupDetailViewModel.getIsAvatarRemoved()
 					);
 				} catch (Exception x) {
 					logger.error("Exception", x);
@@ -788,7 +839,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 					removeMemberFromGroup(selectorInfo.contactModel);
 					break;
 				case SELECTOR_OPTION_CALL:
-					VoipUtil.initiateCall(this, selectorInfo.contactModel, false, null);
+					VoipUtil.initiateCall(this, selectorInfo.contactModel, false, null, readPhoneStatePermissionLauncher);
 					break;
 				default:
 					break;
@@ -811,8 +862,33 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		}
 	}
 
+	public void onGroupDescChange(String newGroupDesc) {
+		if (newGroupDesc.equals(groupModel.getGroupDesc())) {
+			return;
+		}
+		groupDetailViewModel.setGroupDescTimestamp(new Date());
+
+
+		// delete group description
+		if (newGroupDesc.isEmpty()) {
+			removeGroupDescription();
+			return;
+		}
+
+		// create or update description
+		groupDetailViewModel.setGroupDesc(newGroupDesc);
+		if (groupDetailViewModel.getGroupDescState() == NONE) {
+			groupDetailViewModel.setGroupDescState(COLLAPSED);
+		}
+		groupDetailAdapter.updateGroupDescriptionLayout();
+	}
+
+
 	@Override
-	public void onNo(String tag) {}
+	public void onNo(String tag) {
+		// do nothing
+	}
+
 
 	@Override
 	public void onNeutral(String tag) {}
@@ -934,7 +1010,7 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 			optionsMap.add(SELECTOR_OPTION_CHAT);
 
 			if (ContactUtil.canReceiveVoipMessages(contactModel, blackListIdentityService)
-				&& ConfigUtils.isCallsEnabled(GroupDetailActivity.this, preferenceService, licenseService)
+				&& ConfigUtils.isCallsEnabled()
 			) {
 				items.add(new SelectorDialogItem(String.format(getString(R.string.call_with), shortName), R.drawable.ic_phone_locked_outline));
 				optionsMap.add(SELECTOR_OPTION_CALL);
@@ -965,9 +1041,34 @@ public class GroupDetailActivity extends GroupEditActivity implements SelectorDi
 		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
+	@Override
 	public void onShareLinkClick() {
 		// option only enabled if there is a default link
 		groupInviteService.shareGroupLink(this, groupInviteService.getDefaultGroupInvite(groupModel).get());
 	}
+
+	@Override
+	public void onGroupDescriptionEditClick() {
+		showGroupDescEditDialog();
+	}
+
+	// hide keyboard on older devices after ok clicked when group description changed
+	public void hideKeyboard() {
+		getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
+	}
+
+	public void showGroupDescEditDialog() {
+		String groupDescText = groupDetailViewModel.getGroupDesc();
+		GroupDescEditDialog descriptionDialog = GroupDescEditDialog.newGroupDescriptionInstance(R.string.change_group_description, groupDescText, newGroupDesc -> {
+			hideKeyboard(); // is used for older devices
+			onGroupDescChange(newGroupDesc);
+		});
+		descriptionDialog.show(getSupportFragmentManager(), DIALOG_TAG_CHANGE_GROUP_DESC);
+	}
+
+	private void removeGroupDescription() {
+		groupDetailViewModel.setGroupDesc(null);
+		groupDetailViewModel.setGroupDescState(NONE);
+		groupDetailAdapter.updateGroupDescriptionLayout();
+	}
 }

+ 56 - 47
app/src/main/java/ch/threema/app/activities/HomeActivity.java

@@ -41,11 +41,22 @@ import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
-import android.widget.Chronometer;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
 import android.widget.Toast;
 
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.widget.AppCompatImageView;
+import androidx.appcompat.widget.Toolbar;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentTransaction;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
 import com.google.android.material.badge.BadgeDrawable;
 import com.google.android.material.bottomnavigation.BottomNavigationView;
 
@@ -61,17 +72,6 @@ import java.util.List;
 import java.util.Objects;
 import java.util.concurrent.RejectedExecutionException;
 
-import androidx.annotation.AnyThread;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.UiThread;
-import androidx.appcompat.app.ActionBar;
-import androidx.appcompat.widget.AppCompatImageView;
-import androidx.appcompat.widget.Toolbar;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentTransaction;
-import androidx.lifecycle.LifecycleOwner;
-import androidx.localbroadcastmanager.content.LocalBroadcastManager;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.BuildFlavor;
 import ch.threema.app.R;
@@ -89,6 +89,7 @@ import ch.threema.app.exceptions.FileSystemNotPresentException;
 import ch.threema.app.fragments.ContactsSectionFragment;
 import ch.threema.app.fragments.MessageSectionFragment;
 import ch.threema.app.fragments.MyIDFragment;
+import ch.threema.app.glide.AvatarOptions;
 import ch.threema.app.globalsearch.GlobalSearchActivity;
 import ch.threema.app.grouplinks.OutgoingGroupRequestActivity;
 import ch.threema.app.listeners.AppIconListener;
@@ -122,6 +123,8 @@ import ch.threema.app.services.license.LicenseService;
 import ch.threema.app.threemasafe.ThreemaSafeMDMConfig;
 import ch.threema.app.threemasafe.ThreemaSafeService;
 import ch.threema.app.ui.IdentityPopup;
+import ch.threema.app.ui.OngoingCallNoticeModes;
+import ch.threema.app.ui.OngoingCallNoticeView;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.BitmapUtil;
@@ -188,7 +191,8 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	private boolean isLicenseCheckStarted = false, isInitialized = false, isWhatsNewShown = false, isUpdating = false;
 	private Toolbar toolbar;
 	private View connectionIndicator;
-	private LinearLayout noticeLayout, ongoingCallNoticeLayout;
+	private LinearLayout noticeLayout;
+	OngoingCallNoticeView ongoingCallNoticeLayout;
 
 	private ServiceManager serviceManager;
 	private NotificationService notificationService;
@@ -425,6 +429,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 					switch (modifiedMessageModel.getState()) {
 						case SENDFAILED:
+						case FS_KEY_MISMATCH:
 							updateUnsentMessagesList(modifiedMessageModel, true);
 							break;
 						default:
@@ -484,7 +489,9 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		@Override
 		public void onStart(String contact, long elpasedTimeMs) {
 			RuntimeUtil.runOnUiThread(() -> {
-				initOngoingCallNotice();
+				if (ongoingCallNoticeLayout != null) {
+					ongoingCallNoticeLayout.show(VoipCallService.getStartTime(), OngoingCallNoticeModes.MODE_VOIP, 0);
+				}
 			});
 		}
 
@@ -492,9 +499,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 		public void onEnd() {
 			RuntimeUtil.runOnUiThread(() -> {
 				if (ongoingCallNoticeLayout != null) {
-					Chronometer chronometer = ongoingCallNoticeLayout.findViewById(R.id.call_duration);
-					chronometer.stop();
-					ongoingCallNoticeLayout.setVisibility(View.GONE);
+					ongoingCallNoticeLayout.hide();
 				}
 			});
 		}
@@ -618,7 +623,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	}
 
 	private void showWhatsNew() {
-		final boolean skipWhatsNew = true; // set this to false if you want to show a What's New screen
+		final boolean skipWhatsNew = false; // set this to false if you want to show a What's New screen
 
 		if (preferenceService != null) {
 			if (!preferenceService.isLatestVersion(this)) {
@@ -630,14 +635,16 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 
 				if (!ConfigUtils.isWorkBuild() && !RuntimeUtil.isInTest() && !isFinishing()) {
 					if (skipWhatsNew) {
-						isWhatsNewShown = false;
+						isWhatsNewShown = false; // make sure isWhatsNewShown is set to false here if whatsnew is skipped - otherwise pin unlock will not be shown once
 					} else {
-						isWhatsNewShown = true; // make sure this is set to false if whatsnew is skipped - otherwise pin unlock will not be shown once
+						isWhatsNewShown = true;
 
-						// Do not show whatsnew for users of the previous 4.5x version
 						int previous = preferenceService.getLatestVersion() % 1000;
 
-						if (previous < BuildConfig.VERSION_CODE) {
+						// To not show the same dialog twice, it is only shown if the previous version
+						// is prior to the first version that used this dialog.
+						// Use the version code of the first version where this dialog should be shown.
+						if (previous < 776) { // 776 => Threema v5.0
 							Intent intent = new Intent(this, WhatsNewActivity.class);
 							startActivityForResult(intent, REQUEST_CODE_WHATSNEW);
 							overridePendingTransition(R.anim.abc_fade_in, R.anim.abc_fade_out);
@@ -1024,22 +1031,28 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 				userService.getMobileLinkingState() == UserService.LinkingState_PENDING ?
 						View.VISIBLE : View.GONE);
 
-		this.ongoingCallNoticeLayout = findViewById(R.id.ongoing_call_layout);
-		findViewById(R.id.call_container).setOnClickListener(v -> {
+		this.ongoingCallNoticeLayout = findViewById(R.id.ongoing_call_notice);
+		if (ongoingCallNoticeLayout != null) {
+			ongoingCallNoticeLayout.setContainerAction(() -> {
+				if (VoipCallService.isRunning()) {
+					final Intent openIntent = new Intent(HomeActivity.this, CallActivity.class);
+					openIntent.putExtra(EXTRA_ACTIVITY_MODE, CallActivity.MODE_ACTIVE_CALL);
+					openIntent.putExtra(EXTRA_CONTACT_IDENTITY, VoipCallService.getOtherPartysIdentity());
+					openIntent.putExtra(EXTRA_START_TIME, VoipCallService.getStartTime());
+					startActivity(openIntent);
+				}
+			});
+			ongoingCallNoticeLayout.setButtonAction(() -> {
+				final Intent hangupIntent = new Intent(HomeActivity.this, VoipCallService.class);
+				hangupIntent.setAction(ACTION_HANGUP);
+				startService(hangupIntent);
+			});
 			if (VoipCallService.isRunning()) {
-				final Intent openIntent = new Intent(HomeActivity.this, CallActivity.class);
-				openIntent.putExtra(EXTRA_ACTIVITY_MODE, CallActivity.MODE_ACTIVE_CALL);
-				openIntent.putExtra(EXTRA_CONTACT_IDENTITY, VoipCallService.getOtherPartysIdentity());
-				openIntent.putExtra(EXTRA_START_TIME, VoipCallService.getStartTime());
-				startActivity(openIntent);
+				ongoingCallNoticeLayout.show(VoipCallService.getStartTime(), OngoingCallNoticeModes.MODE_VOIP, 0);
+			} else {
+				ongoingCallNoticeLayout.hide();
 			}
-		});
-		findViewById(R.id.call_hangup).setOnClickListener(v -> {
-			final Intent hangupIntent = new Intent(HomeActivity.this, VoipCallService.class);
-			hangupIntent.setAction(ACTION_HANGUP);
-			startService(hangupIntent);
-		});
-		initOngoingCallNotice();
+		}
 
 		/*
 		 * setup fragments
@@ -1178,16 +1191,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 	}
 
 	private void initOngoingCallNotice() {
-		if (ongoingCallNoticeLayout != null) {
-			if (VoipCallService.isRunning()) {
-				Chronometer chronometer = ongoingCallNoticeLayout.findViewById(R.id.call_duration);
-				chronometer.setBase(VoipCallService.getStartTime());
-				chronometer.start();
-				ongoingCallNoticeLayout.setVisibility(View.VISIBLE);
-			} else {
-				ongoingCallNoticeLayout.setVisibility(View.GONE);
-			}
-		}
+
 	}
 
 	/**
@@ -1206,7 +1210,12 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			new AsyncTask<Void, Void, Drawable>() {
 				@Override
 				protected Drawable doInBackground(Void... params) {
-					Bitmap bitmap = contactService.getAvatar(new ContactModel(userService.getIdentity(), null), false);
+					Bitmap bitmap = contactService.getAvatar(
+						new ContactModel(userService.getIdentity(), null),
+						new AvatarOptions.Builder()
+							.setReturnPolicy(AvatarOptions.DefaultAvatarPolicy.DEFAULT_FALLBACK)
+							.toOptions()
+					);
 					if (bitmap != null) {
 						int size = getResources().getDimensionPixelSize(R.dimen.navigation_icon_size);
 						return new BitmapDrawable(getResources(), Bitmap.createScaledBitmap(bitmap, size, size, true));

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

@@ -22,6 +22,7 @@
 package ch.threema.app.activities;
 
 import android.annotation.SuppressLint;
+import android.content.Context;
 import android.content.Intent;
 import android.content.res.Configuration;
 import android.graphics.Bitmap;
@@ -35,10 +36,12 @@ import android.media.FaceDetector;
 import android.net.Uri;
 import android.os.AsyncTask;
 import android.os.Bundle;
+import android.view.KeyEvent;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
+import android.view.ViewStub;
 import android.widget.FrameLayout;
 import android.widget.ImageView;
 import android.widget.ProgressBar;
@@ -48,6 +51,11 @@ import com.android.colorpicker.ColorPickerDialog;
 import com.android.colorpicker.ColorPickerSwatch;
 import com.getkeepsafe.taptargetview.TapTarget;
 import com.getkeepsafe.taptargetview.TapTargetView;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
 
 import org.slf4j.Logger;
 
@@ -58,15 +66,25 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
 
 import androidx.annotation.ColorInt;
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
 import androidx.appcompat.app.ActionBar;
+import androidx.core.content.ContextCompat;
+import androidx.core.view.ViewCompat;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.GenericProgressDialog;
+import ch.threema.app.emojis.EmojiButton;
+import ch.threema.app.emojis.EmojiPicker;
+import ch.threema.app.exceptions.FileSystemNotPresentException;
+import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.motionviews.FaceItem;
 import ch.threema.app.motionviews.viewmodel.Font;
 import ch.threema.app.motionviews.viewmodel.Layer;
@@ -79,24 +97,58 @@ import ch.threema.app.motionviews.widget.MotionEntity;
 import ch.threema.app.motionviews.widget.MotionView;
 import ch.threema.app.motionviews.widget.PathEntity;
 import ch.threema.app.motionviews.widget.TextEntity;
+import ch.threema.app.services.ContactService;
+import ch.threema.app.services.GroupService;
+import ch.threema.app.services.PreferenceService;
+import ch.threema.app.services.UserService;
+import ch.threema.app.ui.ComposeEditText;
+import ch.threema.app.ui.LockableScrollView;
 import ch.threema.app.ui.MediaItem;
 import ch.threema.app.ui.PaintSelectionPopup;
 import ch.threema.app.ui.PaintView;
+import ch.threema.app.ui.SendButton;
 import ch.threema.app.utils.BitmapUtil;
 import ch.threema.app.utils.BitmapWorkerTask;
 import ch.threema.app.utils.BitmapWorkerTaskParams;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
+import ch.threema.app.utils.EditTextUtil;
+import ch.threema.app.utils.IntentDataUtil;
+import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.localcrypto.MasterKeyLockedException;
+import ch.threema.storage.models.GroupModel;
 
 import static ch.threema.app.utils.BitmapUtil.FLIP_NONE;
 
 public class ImagePaintActivity extends ThreemaToolbarActivity implements GenericAlertDialog.DialogClickListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ImagePaintActivity");
 
+	private  enum ActivityMode {
+		/**
+		 * This is the mode where an image is taken as background and the user can draw on it.
+		 */
+		EDIT_IMAGE,
+		/**
+		 * In this mode, an image and a receiver is given and the user can directly send the image
+		 * after drawing on it.
+		 */
+		IMAGE_REPLY,
+		/**
+		 * In this mode, only a receiver is given and the user can directly send the drawing without
+		 * a background image.
+		 */
+		DRAWING
+	}
+
+	private static final String EXTRA_IMAGE_REPLY = "imageReply";
+	private static final String EXTRA_GROUP_ID = "groupId";
+	private static final String EXTRA_ACTIVITY_MODE = "activityMode";
+
 	private static final String DIALOG_TAG_COLOR_PICKER = "colp";
 	private static final String KEY_PEN_COLOR = "pc";
+	private static final String KEY_BACKGROUND_COLOR = "bc";
 	private static final int REQUEST_CODE_STICKER_SELECTOR = 44;
 	private static final int REQUEST_CODE_ENTER_TEXT = 45;
 	private static final String DIALOG_TAG_QUIT_CONFIRM = "qq";
@@ -113,15 +165,97 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 	private PaintView paintView;
 	private MotionView motionView;
 	private FrameLayout imageFrame;
+	private LockableScrollView scrollView;
+	private ComposeEditText captionEditText;
+	private ProgressBar progressBar;
+	private EmojiPicker emojiPicker;
+
 	private int orientation, exifOrientation, flip, exifFlip, clipWidth, clipHeight;
+
+	private File inputFile;
 	private Uri imageUri, outputUri;
-	private ProgressBar progressBar;
-	@ColorInt private int penColor;
+
+	@ColorInt private int penColor, backgroundColor;
+
 	private MenuItem undoItem, paletteItem, paintItem, pencilItem, blurFacesItem;
 	private PaintSelectionPopup paintSelectionPopup;
-	private ArrayList<MotionEntity> undoHistory = new ArrayList<>();
+	private final ArrayList<MotionEntity> undoHistory = new ArrayList<>();
 	private boolean saveSemaphore = false;
 	private int strokeMode = STROKE_MODE_BRUSH;
+	private ActivityMode activityMode = ActivityMode.EDIT_IMAGE;
+	private int groupId = -1;
+	private final ExecutorService threadPoolExecutor = Executors.newSingleThreadExecutor();
+
+	/**
+	 * Returns an intent to start the activity for editing a picture. The edited picture is stored
+	 * in the output file. On success, the activity finishes with {@code RESULT_OK}. If the activity
+	 * finishes with {@code RESULT_CANCELED}, no changes were made or an error occurred.
+	 *
+	 * @param context    the context
+	 * @param mediaItem  the media item containing the image uri and the orientation/flip information
+	 * @param outputFile the file where the edited image is stored in
+	 * @return the intent to start the {@code ImagePaintActivity}
+	 */
+	public static Intent getImageEditIntent(
+		@NonNull Context context,
+		@NonNull MediaItem mediaItem,
+		@NonNull File outputFile
+	) {
+		Intent intent = new Intent(context, ImagePaintActivity.class);
+		intent.putExtra(EXTRA_ACTIVITY_MODE, ActivityMode.EDIT_IMAGE.name());
+		intent.putExtra(Intent.EXTRA_STREAM, mediaItem);
+		intent.putExtra(ThreemaApplication.EXTRA_OUTPUT_FILE, Uri.fromFile(outputFile));
+		return intent;
+	}
+
+	/**
+	 * Returns an intent to start the activity for creating a fast reply. The edited picture is
+	 * stored in the output file. The message receiver and the updated media item will be part of
+	 * the activity result data.
+	 *
+	 * @param context         the context
+	 * @param mediaItem       the media item containing the image uri
+	 * @param outputFile      the output file where the edited image is stored in
+	 * @param messageReceiver the message receiver
+	 * @param groupModel      the group model (if sent to a group) for mentions
+	 * @return the intent to start the {@code ImagePaintActivity}
+	 */
+	public static Intent getImageReplyIntent(
+		@NonNull Context context,
+		@NonNull MediaItem mediaItem,
+		@NonNull File outputFile,
+		@SuppressWarnings("rawtypes") @NonNull MessageReceiver messageReceiver,
+		@Nullable GroupModel groupModel
+	) {
+		Intent intent = new Intent(context, ImagePaintActivity.class);
+		intent.putExtra(EXTRA_ACTIVITY_MODE, ActivityMode.IMAGE_REPLY.name());
+		intent.putExtra(Intent.EXTRA_STREAM, mediaItem);
+		intent.putExtra(ThreemaApplication.EXTRA_OUTPUT_FILE, Uri.fromFile(outputFile));
+		intent.putExtra(ImagePaintActivity.EXTRA_IMAGE_REPLY, true);
+		if (groupModel != null) {
+			intent.putExtra(EXTRA_GROUP_ID, groupModel.getId());
+		}
+		IntentDataUtil.addMessageReceiverToIntent(intent, messageReceiver);
+		return intent;
+	}
+
+	/**
+	 * Returns an intent to start the activity for creating a drawing. The edited picture is stored
+	 * in a file. The message receiver and the media item will be part of the activity result data.
+	 *
+	 * @param context         the context
+	 * @param messageReceiver the message receiver
+	 * @return the intent to start the {@code ImagePaintActivity}
+	 */
+	public static Intent getDrawingIntent(
+		@NonNull Context context,
+		@SuppressWarnings("rawtypes") @NonNull MessageReceiver messageReceiver
+	) {
+		Intent intent = new Intent(context, ImagePaintActivity.class);
+		intent.putExtra(EXTRA_ACTIVITY_MODE, ActivityMode.DRAWING.name());
+		IntentDataUtil.addMessageReceiverToIntent(intent, messageReceiver);
+		return intent;
+	}
 
 	@Override
 	public int getLayoutResource() {
@@ -138,7 +272,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 					R.string.cancel);
 			dialogFragment.show(getSupportFragmentManager(), DIALOG_TAG_QUIT_CONFIRM);
 		} else {
-			finish();
+			finishWithoutChanges();
 		}
 	}
 
@@ -221,22 +355,26 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
 
 		Intent intent = getIntent();
+
+		groupId = intent.getIntExtra(EXTRA_GROUP_ID, -1);
+
 		MediaItem mediaItem = intent.getParcelableExtra(Intent.EXTRA_STREAM);
-		if (mediaItem == null) {
-			finish();
-			return;
-		}
 
-		this.imageUri = mediaItem.getUri();
-		if (this.imageUri == null) {
-			finish();
+		try {
+			String activityModeOrdinal = intent.getStringExtra(EXTRA_ACTIVITY_MODE);
+			activityMode = ActivityMode.valueOf(activityModeOrdinal);
+		} catch (IllegalArgumentException e) {
+			logger.error("Invalid activity mode", e);
+			finishWithoutChanges();
 			return;
 		}
 
-		this.orientation = intent.getIntExtra(ThreemaApplication.EXTRA_ORIENTATION, 0);
-		this.flip = intent.getIntExtra(ThreemaApplication.EXTRA_FLIP, BitmapUtil.FLIP_NONE);
-		this.exifOrientation = intent.getIntExtra(ThreemaApplication.EXTRA_EXIF_ORIENTATION, 0);
-		this.exifFlip = intent.getIntExtra(ThreemaApplication.EXTRA_EXIF_FLIP, BitmapUtil.FLIP_NONE);
+		if (mediaItem != null) {
+			this.orientation = mediaItem.getRotation();
+			this.flip = mediaItem.getFlip();
+			this.exifOrientation = mediaItem.getExifRotation();
+			this.exifFlip = mediaItem.getExifFlip();
+		}
 
 		this.outputUri = intent.getParcelableExtra(ThreemaApplication.EXTRA_OUTPUT_FILE);
 
@@ -244,11 +382,11 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		ActionBar actionBar = getSupportActionBar();
 
 		if (actionBar == null) {
-			finish();
+			finishWithoutChanges();
 			return;
 		}
 
-		actionBar.setDisplayHomeAsUpEnabled(true);
+		actionBar.setDisplayHomeAsUpEnabled(activityMode == ActivityMode.EDIT_IMAGE);
 		actionBar.setTitle("");
 
 		this.paintView = findViewById(R.id.paint_view);
@@ -257,10 +395,21 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		this.motionView = findViewById(R.id.motion_view);
 
 		this.penColor = getResources().getColor(R.color.material_red);
+		this.backgroundColor = Color.WHITE;
 		if (savedInstanceState != null) {
 			this.penColor = savedInstanceState.getInt(KEY_PEN_COLOR, penColor);
+			this.backgroundColor = savedInstanceState.getInt(KEY_BACKGROUND_COLOR, backgroundColor);
 		}
 
+		initializeCaptionEditText();
+
+		// Lock the scroll view (the scroll view is needed so that the keyboard does not resize the drawing)
+		scrollView = findViewById(R.id.content_scroll_view);
+		scrollView.setScrollingEnabled(false);
+
+		// Set the height of the image to the size of the scrollview
+		this.imageFrame = findViewById(R.id.content_frame);
+
 		this.paintView.setColor(penColor);
 		this.paintView.setStrokeWidth(getResources().getDimensionPixelSize(R.dimen.imagepaint_brush_stroke_width));
 		this.paintView.setTouchListener(new PaintView.TouchListener() {
@@ -352,33 +501,128 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 			}
 		});
 
-		this.imageFrame = findViewById(R.id.content_frame);
-		this.imageFrame.post(() -> loadImage());
+		if (activityMode == ActivityMode.DRAWING) {
+			inputFile = createDrawingInputFile();
+			File outputFile = createDrawingOutputFile();
+
+			if (inputFile == null || outputFile == null) {
+				logger.error("Input file '{}' or output file '{}' is null", inputFile, outputFile);
+				finishWithoutChanges();
+				return;
+			}
+
+			imageUri = Uri.fromFile(inputFile);
+			outputUri = Uri.fromFile(outputFile);
+
+			createBackground(inputFile, Color.WHITE);
+		} else {
+			if (mediaItem == null || mediaItem.getUri() == null) {
+				logger.error("No media uri given");
+				finishWithoutChanges();
+				return;
+			}
+			this.imageUri = mediaItem.getUri();
+			loadImageOnLayout();
+		}
 
-		showTooltip();
+		// Don't show tooltip when creating a drawing or for image replies
+		if (activityMode == ActivityMode.EDIT_IMAGE) {
+			showTooltip();
+		}
+	}
+
+	/**
+	 * Create a file that is used for the drawing input (the background)
+	 */
+	private File createDrawingInputFile() {
+		try {
+			return serviceManager.getFileService().createTempFile(".blank", ".png");
+		} catch (IOException | FileSystemNotPresentException e) {
+			logger.error("Error while creating temporary drawing input file");
+			return null;
+		}
+	}
+
+	/**
+	 * Create a file that is used for the resulting output image (background + drawings)
+	 */
+	private File createDrawingOutputFile() {
+		try {
+			return serviceManager.getFileService().createTempFile(".drawing", ".png");
+		} catch (IOException | FileSystemNotPresentException e) {
+			logger.error("Error while creating temporary drawing output file", e);
+			return null;
+		}
+	}
+
+	/**
+	 * Create a background with the given color and store it into the given file. Afterwards display
+	 * the background.
+	 *
+	 * @param inputFile the file where the background is stored
+	 * @param color     the color of the background
+	 */
+	private void createBackground(File inputFile, int color) {
+		Futures.addCallback(
+			getDrawingImageFuture(inputFile, color),
+			new FutureCallback<>() {
+				@Override
+				public void onSuccess(@Nullable Void result) {
+					loadImageOnLayout();
+				}
+
+				@Override
+				public void onFailure(@NonNull Throwable t) {
+					logger.error("Error while getting the image uri", t);
+					finishWithoutChanges();
+				}
+			},
+			ContextCompat.getMainExecutor(this)
+		);
+	}
+
+	/**
+	 * Get a listenable future that creates a background image of the given color and stores it in
+	 * the given file.
+	 *
+	 * @param file  the file where the image of the given color is stored in
+	 * @param color the color of the background
+	 * @return the listenable future
+	 */
+	private ListenableFuture<Void> getDrawingImageFuture(@NonNull File file, int color) {
+		ListeningExecutorService executorService = MoreExecutors.listeningDecorator(threadPoolExecutor);
+		return executorService.submit(() -> {
+			try {
+				int dimension = ConfigUtils.getPreferredImageDimensions(PreferenceService.ImageScale_MEDIUM);
+				Bitmap bitmap = Bitmap.createBitmap(dimension, dimension, Bitmap.Config.RGB_565);
+				Canvas canvas = new Canvas(bitmap);
+				canvas.drawColor(color);
+				bitmap.compress(Bitmap.CompressFormat.PNG, 0, new FileOutputStream(file));
+			} catch (IOException e) {
+				logger.error("Exception while creating blanc drawing", e);
+			}
+			return null;
+		});
 	}
 
 	private void loadImage() {
 		BitmapWorkerTaskParams bitmapParams = new BitmapWorkerTaskParams();
 		bitmapParams.imageUri = this.imageUri;
 		bitmapParams.width = this.imageFrame.getWidth();
-		bitmapParams.height = this.imageFrame.getHeight();
+		bitmapParams.height = this.scrollView.getHeight();
 		bitmapParams.contentResolver = getContentResolver();
 		bitmapParams.orientation = this.orientation;
 		bitmapParams.flip = this.flip;
 		bitmapParams.exifOrientation = this.exifOrientation;
 		bitmapParams.exifFlip = this.exifFlip;
 
-		logger.debug("screen height: " + bitmapParams.height);
+		logger.debug("screen height: {}", bitmapParams.height);
 
 		// load main image
 		new BitmapWorkerTask(this.imageView) {
 			@Override
 			protected void onPreExecute() {
 				super.onPreExecute();
-				imageView.setVisibility(View.INVISIBLE);
-				paintView.setVisibility(View.INVISIBLE);
-				motionView.setVisibility(View.INVISIBLE);
 				progressBar.setVisibility(View.VISIBLE);
 			}
 
@@ -386,9 +630,6 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 			protected void onPostExecute(Bitmap bitmap) {
 				super.onPostExecute(bitmap);
 				progressBar.setVisibility(View.GONE);
-				imageView.setVisibility(View.VISIBLE);
-				paintView.setVisibility(View.VISIBLE);
-				motionView.setVisibility(View.VISIBLE);
 
 				// clip other views to image size
 				if (bitmap != null) {
@@ -586,7 +827,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 			}
 		}
 		undoItem.setVisible(undoHistory.size() > 0);
-		blurFacesItem.setVisible(motionView.getEntitiesCount() == 0);
+		blurFacesItem.setVisible(activityMode != ActivityMode.DRAWING && motionView.getEntitiesCount() == 0);
 		return true;
 	}
 
@@ -602,6 +843,10 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		pencilItem = menu.findItem(R.id.item_pencil);
 		blurFacesItem = menu.findItem(R.id.item_face);
 
+		if (activityMode == ActivityMode.DRAWING) {
+			menu.findItem(R.id.item_background).setVisible(true);
+		}
+
 		return true;
 	}
 
@@ -609,53 +854,45 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 	public boolean onOptionsItemSelected(MenuItem item) {
 		super.onOptionsItemSelected(item);
 
-		switch (item.getItemId()) {
-			case android.R.id.home:
-				if (undoHistory.size() > 0) {
-					item.setEnabled(false);
-					renderImage();
-				} else {
-					finish();
-				}
-				return true;
-			case R.id.item_undo:
-				undo();
-				break;
-			case R.id.item_stickers:
-				selectSticker();
-				break;
-			case R.id.item_palette:
-				chooseColor();
-				break;
-			case R.id.item_text:
-				enterText();
-				break;
-			case R.id.item_draw:
-				if (strokeMode == STROKE_MODE_BRUSH && this.paintView.getActive()) {
-					// switch to selection mode
-					setDrawMode(false);
-				} else {
-					setStrokeMode(STROKE_MODE_BRUSH);
-					setDrawMode(true);
-				}
-				break;
-			case R.id.item_pencil:
-				if (strokeMode == STROKE_MODE_PENCIL && this.paintView.getActive()) {
-					// switch to selection mode
-					setDrawMode(false);
-				} else {
-					setStrokeMode(STROKE_MODE_PENCIL);
-					setDrawMode(true);
-				}
-				break;
-			case R.id.item_face_blur:
-				blurFaces(false);
-				break;
-			case R.id.item_face_emoji:
-				blurFaces(true);
-				break;
-			default:
-				break;
+		int id = item.getItemId();
+		if (id == android.R.id.home) {
+			if (undoHistory.size() > 0) {
+				item.setEnabled(false);
+				renderImage();
+			} else {
+				finishWithoutChanges();
+			}
+			return true;
+		} else if (id == R.id.item_undo) {
+			undo();
+		} else if (id == R.id.item_stickers) {
+			selectSticker();
+		} else if (id == R.id.item_palette) {
+			choosePenColor();
+		} else if (id == R.id.item_text) {
+			enterText();
+		} else if (id == R.id.item_draw) {
+			if (strokeMode == STROKE_MODE_BRUSH && this.paintView.getActive()) {
+				// switch to selection mode
+				setDrawMode(false);
+			} else {
+				setStrokeMode(STROKE_MODE_BRUSH);
+				setDrawMode(true);
+			}
+		} else if (id == R.id.item_pencil) {
+			if (strokeMode == STROKE_MODE_PENCIL && this.paintView.getActive()) {
+				// switch to selection mode
+				setDrawMode(false);
+			} else {
+				setStrokeMode(STROKE_MODE_PENCIL);
+				setDrawMode(true);
+			}
+		} else if (id == R.id.item_face_blur) {
+			blurFaces(false);
+		} else if (id == R.id.item_face_emoji) {
+			blurFaces(true);
+		} else if (id == R.id.item_background) {
+			chooseBackgroundColor();
 		}
 		return false;
 	}
@@ -748,18 +985,63 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		// hack to adjust toolbar height after rotate
 		ConfigUtils.adjustToolbar(this, getToolbar());
 
-		this.imageFrame = findViewById(R.id.content_frame);
-		if (this.imageFrame != null) {
-			this.imageFrame.post(new Runnable() {
-				@Override
-				public void run() {
-					loadImage();
-				}
-			});
+		loadImageOnLayout();
+	}
+
+	/**
+	 * Updates the image frame height on next layout of the scroll view
+	 */
+	private void loadImageOnLayout() {
+		if (scrollView == null || imageFrame == null) {
+			logger.warn("scrollView or imageFrame is null");
+			return;
 		}
+		scrollView.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
+			@Override
+			public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
+				scrollView.removeOnLayoutChangeListener(this);
+				imageFrame.setMinimumHeight(bottom - top);
+				loadImage();
+			}
+		});
+		scrollView.requestLayout();
+	}
+
+	/**
+	 * Show a color picker and set the selected color as pen color
+	 */
+	private void choosePenColor() {
+		chooseColor(color -> {
+			paintView.setColor(color);
+			penColor = color;
+
+			ConfigUtils.themeMenuItem(paletteItem, penColor);
+			if (motionView.getSelectedEntity() != null) {
+				if (motionView.getSelectedEntity() instanceof TextEntity) {
+					TextEntity textEntity = (TextEntity) motionView.getSelectedEntity();
+					textEntity.getLayer().getFont().setColor(penColor);
+					textEntity.updateEntity();
+					motionView.invalidate();
+				} else {
+					// ignore color selection for stickers
+				}
+			} else {
+				setDrawMode(true);
+			}
+		}, penColor);
 	}
 
-	private void chooseColor() {
+	/**
+	 * Show a color picker and writes the selected color to the input file.
+	 */
+	private void chooseBackgroundColor() {
+		chooseColor(color -> {
+			backgroundColor = color;
+			createBackground(inputFile, color);
+		}, backgroundColor);
+	}
+
+	private void chooseColor(@NonNull ColorPickerSwatch.OnColorSelectedListener colorSelectedListener, int selectedColor) {
 		int[] colors = {
 				getResources().getColor(R.color.material_cyan),
 				getResources().getColor(R.color.material_blue),
@@ -784,29 +1066,8 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		};
 
 		ColorPickerDialog colorPickerDialog = new ColorPickerDialog();
-		colorPickerDialog.initialize(R.string.color_picker_default_title, colors, 0, 4, colors.length);
-		colorPickerDialog.setSelectedColor(penColor);
-		colorPickerDialog.setOnColorSelectedListener(new ColorPickerSwatch.OnColorSelectedListener() {
-			@Override
-			public void onColorSelected(int color) {
-				paintView.setColor(color);
-				penColor = color;
-
-				ConfigUtils.themeMenuItem(paletteItem, penColor);
-				if (motionView.getSelectedEntity() != null) {
-					if (motionView.getSelectedEntity() instanceof TextEntity) {
-						TextEntity textEntity = (TextEntity) motionView.getSelectedEntity();
-						textEntity.getLayer().getFont().setColor(penColor);
-						textEntity.updateEntity();
-						motionView.invalidate();
-					} else {
-						// ignore color selection for stickers
-					}
-				} else {
-					setDrawMode(true);
-				}
-			}
-		});
+		colorPickerDialog.initialize(R.string.color_picker_default_title, colors, selectedColor, 4, colors.length);
+		colorPickerDialog.setOnColorSelectedListener(colorSelectedListener);
 		colorPickerDialog.show(getSupportFragmentManager(), DIALOG_TAG_COLOR_PICKER);
 	}
 
@@ -863,8 +1124,7 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 						DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_SAVING_IMAGE, true);
 
 						if (success) {
-							setResult(RESULT_OK);
-							finish();
+							finishWithChanges();
 						} else {
 							Toast.makeText(ImagePaintActivity.this, R.string.error_saving_file, Toast.LENGTH_SHORT).show();
 						}
@@ -874,17 +1134,199 @@ public class ImagePaintActivity extends ThreemaToolbarActivity implements Generi
 		}.execute(bitmapParams);
 	}
 
+	private void initializeCaptionEditText() {
+		if (activityMode == ActivityMode.EDIT_IMAGE) {
+			// Don't show caption edit text when just editing the image
+			return;
+		}
+
+		captionEditText = findViewById(R.id.caption_edittext);
+
+		SendButton sendButton = findViewById(R.id.send_button);
+		sendButton.setEnabled(true);
+		sendButton.setOnClickListener(v -> renderImage());
+
+		View bottomPanel = findViewById(R.id.bottom_panel);
+		bottomPanel.setVisibility(View.VISIBLE);
+
+		if (preferenceService.getEmojiStyle() != PreferenceService.EmojiStyle_ANDROID) {
+			initializeEmojiView();
+		} else {
+			findViewById(R.id.emoji_button).setVisibility(View.GONE);
+			captionEditText.setPadding(getResources().getDimensionPixelSize(R.dimen.no_emoji_button_padding_left), this.captionEditText.getPaddingTop(), this.captionEditText.getPaddingRight(), this.captionEditText.getPaddingBottom());
+		}
+
+		if (groupId != -1) {
+			initializeMentions();
+		}
+
+	}
+
+	private void initializeMentions() {
+		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+		if (serviceManager == null) {
+			logger.error("Cannot enable mention popup: serviceManager is null");
+			return;
+		}
+		try {
+			GroupService groupService = serviceManager.getGroupService();
+			ContactService contactService = serviceManager.getContactService();
+			UserService userService = serviceManager.getUserService();
+			GroupModel groupModel = groupService.getById(groupId);
+
+			if (groupModel == null) {
+				logger.error("Cannot enable mention popup: no group model with id {} found", groupId);
+				return;
+			}
+
+			captionEditText.enableMentionPopup(
+				this,
+				groupService,
+				contactService,
+				userService,
+				preferenceService,
+				groupModel
+			);
+		} catch (MasterKeyLockedException | FileSystemNotPresentException e) {
+			logger.error("Cannot enable mention popup", e);
+		}
+	}
+
+	@SuppressWarnings("deprecation")
+	private void initializeEmojiView() {
+		final EmojiPicker.EmojiKeyListener emojiKeyListener = new EmojiPicker.EmojiKeyListener() {
+			@Override
+			public void onBackspaceClick() {
+				captionEditText.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL));
+			}
+
+			@Override
+			public void onEmojiClick(String emojiCodeString) {
+				RuntimeUtil.runOnUiThread(() -> captionEditText.addEmoji(emojiCodeString));
+			}
+
+			@Override
+			public void onShowPicker() {
+				logger.info("onShowPicker");
+				showEmojiPicker();
+			}
+		};
+
+		EmojiButton emojiButton = findViewById(R.id.emoji_button);
+		emojiButton.setOnClickListener(v -> showEmojiPicker());
+		emojiButton.setColorFilter(getResources().getColor(android.R.color.white));
+
+		emojiPicker = (EmojiPicker) ((ViewStub) findViewById(R.id.emoji_stub)).inflate();
+		emojiPicker.init(ThreemaApplication.requireServiceManager().getEmojiService());
+		emojiButton.attach(this.emojiPicker, preferenceService.isFullscreenIme());
+		emojiPicker.setEmojiKeyListener(emojiKeyListener);
+
+		ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.image_paint_root).getRootView(), (v, insets) -> {
+			if (insets.getSystemWindowInsetBottom() <= insets.getStableInsetBottom()) {
+				onSoftKeyboardClosed();
+			} else {
+				onSoftKeyboardOpened(insets.getSystemWindowInsetBottom() - insets.getStableInsetBottom());
+			}
+			return insets;
+		});
+
+		addOnSoftKeyboardChangedListener(new OnSoftKeyboardChangedListener() {
+			@Override
+			public void onKeyboardHidden() {
+				// Nothing to do
+			}
+
+			@Override
+			public void onKeyboardShown() {
+				if (emojiPicker != null && emojiPicker.isShown()) {
+					emojiPicker.onKeyboardShown();
+				}
+			}
+		});
+	}
+
+	private void showEmojiPicker() {
+		if (isSoftKeyboardOpen() && !isEmojiPickerShown()) {
+			logger.info("Show emoji picker after keyboard close");
+			runOnSoftKeyboardClose(() -> {
+				if (emojiPicker != null) {
+					emojiPicker.show(loadStoredSoftKeyboardHeight());
+				}
+			});
+
+			captionEditText.post(() -> EditTextUtil.hideSoftKeyboard(captionEditText));
+		} else {
+			if (emojiPicker != null) {
+				if (emojiPicker.isShown()) {
+					logger.info("EmojiPicker currently shown. Closing.");
+					if (ConfigUtils.isLandscape(this) &&
+						!ConfigUtils.isTabletLayout() &&
+						preferenceService.isFullscreenIme()) {
+						emojiPicker.hide();
+					} else {
+						openSoftKeyboard(emojiPicker, captionEditText);
+						if (getResources().getConfiguration().keyboard == Configuration.KEYBOARD_QWERTY) {
+							emojiPicker.hide();
+						}
+					}
+				} else {
+					emojiPicker.show(loadStoredSoftKeyboardHeight());
+				}
+			}
+		}
+	}
+
+	private boolean isEmojiPickerShown() {
+		return emojiPicker != null && emojiPicker.isShown();
+	}
+
 	@Override
-	public void onSaveInstanceState(Bundle outState) {
+	public void onSaveInstanceState(@NonNull Bundle outState) {
 		super.onSaveInstanceState(outState);
 		outState.putInt(KEY_PEN_COLOR, penColor);
+		outState.putInt(KEY_BACKGROUND_COLOR, backgroundColor);
 	}
 
 	@Override
 	public void onYes(String tag, Object data) {
-		finish();
+		finishWithoutChanges();
 	}
 
 	@Override
 	public void onNo(String tag, Object data) {}
+
+	/**
+	 * Finish activity with changes (result ok)
+	 */
+	private void finishWithChanges() {
+		if (activityMode == ActivityMode.IMAGE_REPLY || activityMode == ActivityMode.DRAWING) {
+			MediaItem mediaItem = new MediaItem(outputUri, MediaItem.TYPE_IMAGE);
+			if (captionEditText != null && captionEditText.getText() != null) {
+				mediaItem.setCaption(captionEditText.getText().toString());
+			}
+
+			Intent result = new Intent();
+			boolean messageReceiverCopied = IntentDataUtil.copyMessageReceiverFromIntentToIntent(this, getIntent(), result);
+			if (!messageReceiverCopied) {
+				logger.warn("Could not copy message receiver to intent");
+				finishWithoutChanges();
+				return;
+			}
+			result.putExtra(Intent.EXTRA_STREAM, mediaItem);
+
+			setResult(RESULT_OK, result);
+		} else {
+			setResult(RESULT_OK);
+		}
+		finish();
+	}
+
+	/**
+	 * Finish activity without changes (result canceled)
+	 */
+	private void finishWithoutChanges() {
+		setResult(RESULT_CANCELED);
+		finish();
+	}
+
 }

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

@@ -21,6 +21,11 @@
 
 package ch.threema.app.activities;
 
+import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_LAT;
+import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_LNG;
+import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_NAME;
+import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_PROVIDER;
+
 import android.Manifest;
 import android.annotation.SuppressLint;
 import android.content.ActivityNotFoundException;
@@ -36,12 +41,20 @@ import android.os.AsyncTask;
 import android.os.Build;
 import android.os.Bundle;
 import android.os.StrictMode;
+import android.provider.Settings;
 import android.view.View;
 import android.view.WindowManager;
 import android.widget.FrameLayout;
 import android.widget.TextView;
 import android.widget.Toast;
 
+import androidx.annotation.NonNull;
+import androidx.appcompat.content.res.AppCompatResources;
+import androidx.core.content.ContextCompat;
+import androidx.core.view.OnApplyWindowInsetsListener;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowInsetsCompat;
+
 import com.google.android.material.chip.Chip;
 import com.mapbox.mapboxsdk.annotations.IconFactory;
 import com.mapbox.mapboxsdk.annotations.MarkerOptions;
@@ -63,12 +76,6 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
 
-import androidx.annotation.NonNull;
-import androidx.appcompat.content.res.AppCompatResources;
-import androidx.core.content.ContextCompat;
-import androidx.core.view.OnApplyWindowInsetsListener;
-import androidx.core.view.ViewCompat;
-import androidx.core.view.WindowInsetsCompat;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -85,16 +92,10 @@ import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.data.LocationDataModel;
 
-import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_LAT;
-import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_LNG;
-import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_NAME;
-import static ch.threema.app.utils.IntentDataUtil.INTENT_DATA_LOCATION_PROVIDER;
-
 public class MapActivity extends ThreemaActivity implements GenericAlertDialog.DialogClickListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("MapActivity");
 
 	private static final String DIALOG_TAG_ENABLE_LOCATION_SERVICES = "lss";
-	private static final String DIALOG_TAG_PRIVACY_POLICY_40_ACCEPT = "40acc";
 
 	private static final int REQUEST_CODE_LOCATION_SETTINGS = 22229;
 	private static final int PERMISSION_REQUEST_LOCATION = 49;
@@ -201,18 +202,8 @@ public class MapActivity extends ThreemaActivity implements GenericAlertDialog.D
 			}
 		}
 
-		if (preferenceService.getPrivacyPolicyAcceptedVersion() < 4.0f) {
-			GenericAlertDialog alertDialog = GenericAlertDialog.newInstanceHtml(
-				R.string.privacy_policy,
-				getString(R.string.send_location_privacy_policy_v4_0, getString(R.string.app_name), ConfigUtils.getPrivacyPolicyURL(this)),
-				R.string.prefs_title_accept_privacy_policy,
-				R.string.cancel,
-				false);
-			alertDialog.show(getSupportFragmentManager(), DIALOG_TAG_PRIVACY_POLICY_40_ACCEPT);
-		} else {
-			initUi();
-			initMap();
-		}
+		initUi();
+		initMap();
 	}
 
 	private void initUi() {
@@ -460,27 +451,14 @@ public class MapActivity extends ThreemaActivity implements GenericAlertDialog.D
 
 	@Override
 	public void onYes(String tag, Object data) {
-		switch (tag) {
-			case DIALOG_TAG_PRIVACY_POLICY_40_ACCEPT:
-				preferenceService.setPrivacyPolicyAcceptedVersion(ConfigUtils.getAppVersionFloat(this));
-				initUi();
-				initMap();
-				break;
-			case DIALOG_TAG_ENABLE_LOCATION_SERVICES:
-				startActivityForResult(new Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS), REQUEST_CODE_LOCATION_SETTINGS);
-				break;
+		if (DIALOG_TAG_ENABLE_LOCATION_SERVICES.equals(tag)) {
+			startActivityForResult(new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS), REQUEST_CODE_LOCATION_SETTINGS);
 		}
 	}
 
 	@Override
 	public void onNo(String tag, Object data) {
-		switch (tag) {
-			case DIALOG_TAG_PRIVACY_POLICY_40_ACCEPT:
-				finish();
-				break;
-			case DIALOG_TAG_ENABLE_LOCATION_SERVICES:
-				break;
-		}
+		// do nothing
 	}
 
 	@Override

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

@@ -21,6 +21,8 @@
 
 package ch.threema.app.activities;
 
+import static ch.threema.app.fragments.ComposeMessageFragment.SCROLLBUTTON_VIEW_TIMEOUT;
+
 import android.Manifest;
 import android.annotation.SuppressLint;
 import android.annotation.TargetApi;
@@ -43,6 +45,9 @@ import android.widget.FrameLayout;
 import android.widget.ProgressBar;
 import android.widget.TextView;
 
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.ActionBar;
+
 import com.google.android.material.snackbar.Snackbar;
 
 import org.slf4j.Logger;
@@ -55,8 +60,6 @@ import java.util.Iterator;
 import java.util.List;
 import java.util.concurrent.CopyOnWriteArrayList;
 
-import androidx.annotation.NonNull;
-import androidx.appcompat.app.ActionBar;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.adapters.MediaGalleryAdapter;
@@ -92,8 +95,6 @@ import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.MessageType;
 import ch.threema.storage.models.data.MessageContentsType;
 
-import static ch.threema.app.fragments.ComposeMessageFragment.SCROLLBUTTON_VIEW_TIMEOUT;
-
 public class MediaGalleryActivity extends ThreemaToolbarActivity implements AdapterView.OnItemClickListener, ActionBar.OnNavigationListener, GenericAlertDialog.DialogClickListener, FastScrollGridView.ScrollListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("MediaGalleryActivity");
 
@@ -570,7 +571,7 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 
 	@SuppressLint("StaticFieldLeak")
 	private void reallyDiscardMessages(final CopyOnWriteArrayList<AbstractMessageModel> selectedMessages) {
-		new AsyncTask<Void, Integer, Void>() {
+		new AsyncTask<Void, Integer, Integer>() {
 			boolean cancelled = false;
 
 			@Override
@@ -588,8 +589,9 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 			}
 
 			@Override
-			protected Void doInBackground(Void... params) {
+			protected Integer doInBackground(Void... params) {
 				int i = 0;
+				int deleted = 0;
 				Iterator<AbstractMessageModel> checkedItemsIterator = selectedMessages.iterator();
 				while (checkedItemsIterator.hasNext() && !cancelled) {
 					publishProgress(i++);
@@ -598,6 +600,7 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 
 						if (messageModel != null) {
 							messageService.remove(messageModel);
+							deleted++;
 						 	RuntimeUtil.runOnUiThread(new Runnable() {
 								@Override
 								public void run() {
@@ -609,13 +612,14 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 						logger.error("Exception", e);
 					}
 				}
-				return null;
+				return deleted;
 			}
 
 			@Override
-			protected void onPostExecute(Void result) {
+			protected void onPostExecute(Integer deletedMessages) {
 				DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_DELETING_MEDIA, true);
-				Snackbar.make(gridView, R.string.message_deleted, Snackbar.LENGTH_LONG).show();
+				String text = ConfigUtils.getSafeQuantityString(gridView.getContext(), R.plurals.message_deleted, deletedMessages, deletedMessages);
+				Snackbar.make(gridView, text, Snackbar.LENGTH_LONG).show();
 				if (actionMode != null) {
 					actionMode.finish();
 				}
@@ -702,6 +706,7 @@ public class MediaGalleryActivity extends ThreemaToolbarActivity implements Adap
 		IntentDataUtil.append(m, intent);
 		intent.putExtra(MediaViewerActivity.EXTRA_ID_IMMEDIATE_PLAY, true);
 		intent.putExtra(MediaViewerActivity.EXTRA_ID_REVERSE_ORDER, false);
+		intent.putExtra(MediaViewerActivity.EXTRA_FILTER, this.spinnerMessageFilter.contentTypes());
 		AnimationUtil.startActivityForResult(this, v, intent, ACTIVITY_ID_MEDIA_VIEWER);
 	}
 

+ 35 - 25
app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java

@@ -27,9 +27,9 @@ import android.annotation.SuppressLint;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.content.res.Configuration;
-import android.graphics.Bitmap;
 import android.graphics.Color;
 import android.graphics.PorterDuff;
+import android.graphics.drawable.Drawable;
 import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
@@ -44,14 +44,6 @@ import android.widget.FrameLayout;
 import android.widget.TextView;
 import android.widget.Toast;
 
-import org.slf4j.Logger;
-
-import java.io.File;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.CopyOnWriteArrayList;
-
 import androidx.annotation.NonNull;
 import androidx.appcompat.app.ActionBar;
 import androidx.appcompat.view.menu.MenuBuilder;
@@ -62,6 +54,15 @@ import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentStatePagerAdapter;
 import androidx.fragment.app.FragmentTransaction;
 import androidx.viewpager.widget.PagerAdapter;
+
+import org.slf4j.Logger;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.dialogs.ExpandableTextEntryDialog;
@@ -81,7 +82,6 @@ import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.FileUtil;
-import ch.threema.app.utils.IconUtil;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.MessageUtil;
 import ch.threema.app.utils.MimeUtil;
@@ -94,6 +94,7 @@ import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.DistributionListMessageModel;
 import ch.threema.storage.models.GroupMessageModel;
 import ch.threema.storage.models.MessageType;
+import ch.threema.storage.models.data.MessageContentsType;
 
 
 public class MediaViewerActivity extends ThreemaToolbarActivity implements
@@ -107,6 +108,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 
 	public static final String EXTRA_ID_IMMEDIATE_PLAY = "play";
 	public static final String EXTRA_ID_REVERSE_ORDER = "reverse";
+	public static final String EXTRA_FILTER = "filter";
 
 	private LockableViewPager pager;
 
@@ -206,6 +208,10 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 			return false;
 		}
 
+		final @MessageContentsType int[] filter = intent.hasExtra(EXTRA_FILTER)
+			? intent.getIntArrayExtra(EXTRA_FILTER)
+			: null;
+
 		//load all records of receiver to support list pager
 		try {
 			this.messageModels = this.currentReceiver.loadMessages(new MessageService.MessageFilter() {
@@ -245,8 +251,9 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 				}
 
 				@Override
+				@MessageContentsType
 				public int[] contentTypes() {
-					return null;
+					return filter;
 				}
 			});
 		} catch (Exception x) {
@@ -374,7 +381,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 
 	@SuppressLint("RestrictedApi")
 	@Override
-	public boolean onCreateOptionsMenu(Menu menu) {
+	public boolean onCreateOptionsMenu(@NonNull Menu menu) {
 		super.onCreateOptionsMenu(menu);
 
 		getMenuInflater().inflate(R.menu.activity_media_viewer, menu);
@@ -474,14 +481,15 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 			Intent mediaGalleryIntent = new Intent(this, MediaGalleryActivity.class);
 			mediaGalleryIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
 			switch (this.currentReceiver.getType()) {
+				case MessageReceiver.Type_CONTACT:
+					mediaGalleryIntent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, messageModel.getIdentity());
+					break;
 				case MessageReceiver.Type_GROUP:
 					mediaGalleryIntent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, ((GroupMessageModel) messageModel).getGroupId());
 					break;
 				case MessageReceiver.Type_DISTRIBUTION_LIST:
 					mediaGalleryIntent.putExtra(ThreemaApplication.INTENT_DATA_DISTRIBUTION_LIST, ((DistributionListMessageModel) messageModel).getDistributionListId());
 					break;
-				default:
-					mediaGalleryIntent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, messageModel.getIdentity());
 			}
 			IntentDataUtil.append(messageModel, mediaGalleryIntent);
 			startActivity(mediaGalleryIntent);
@@ -571,7 +579,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 	}
 
 	@Override
-	public void onSaveInstanceState(Bundle outState) {
+	public void onSaveInstanceState(@NonNull Bundle outState) {
 		// fixes https://code.google.com/p/android/issues/detail?id=19917
 		super.onSaveInstanceState(outState);
 		if (outState.isEmpty()) {
@@ -637,19 +645,20 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 
 		private final MediaViewerActivity a;
 		private final FragmentManager mFragmentManager;
-		private SparseArray<Fragment> mFragments;
+		private final SparseArray<Fragment> mFragments;
 		private FragmentTransaction mCurTransaction;
 
 		public ScreenSlidePagerAdapter(MediaViewerActivity a, FragmentManager fm) {
-			super(fm);
+			super(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
 			this.a = a;
 			mFragmentManager = fm;
 			mFragments = new SparseArray<>();
 		}
 
+		@NonNull
 		@SuppressLint("CommitTransaction")
 		@Override
-		public Object instantiateItem(ViewGroup container, int position) {
+		public Object instantiateItem(@NonNull ViewGroup container, int position) {
 			Fragment fragment = getItem(position);
 			if (mCurTransaction == null) {
 				mCurTransaction = mFragmentManager.beginTransaction();
@@ -660,10 +669,11 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 		}
 
 		@Override
-		public boolean isViewFromObject(View view, Object fragment) {
+		public boolean isViewFromObject(@NonNull View view, @NonNull Object fragment) {
 			return ((Fragment) fragment).getView() == view;
 		}
 
+		@NonNull
 		@Override
 		public Fragment getItem(final int position) {
 			logger.debug("getItem " + position);
@@ -686,11 +696,11 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 						break;
 					case FILE:
 						String mimeType = messageModel.getFileData().getMimeType();
-						if (MimeUtil.isImageFile(mimeType) && !MimeUtil.isGifFile(mimeType)) {
+						if (MimeUtil.isImageFile(mimeType)) {
 							f = new ImageViewFragment();
 						} else if (MimeUtil.isVideoFile(mimeType)) {
 							f = new VideoViewFragment();
-						} else if (IconUtil.getMimeIcon(mimeType) == R.drawable.ic_doc_audio) {
+						} else if (MimeUtil.isAudioFile(mimeType)) {
 							if (MimeUtil.isMidiFile(mimeType) || MimeUtil.isFlacFile(mimeType)) {
 								f = new MediaPlayerViewFragment();
 							} else {
@@ -739,7 +749,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 					}
 
 					@Override
-					public void thumbnailLoaded(Bitmap bitmap) {
+					public void thumbnailLoaded(Drawable bitmap) {
 						//do nothing!
 					}
 				});
@@ -751,7 +761,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 
 		@SuppressLint("CommitTransaction")
 		@Override
-		public void destroyItem(ViewGroup container, int position, Object object) {
+		public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
 			logger.debug("destroyItem " + position);
 
 			if (mCurTransaction == null) {
@@ -772,7 +782,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 		}
 
 		@Override
-		public void finishUpdate(ViewGroup container) {
+		public void finishUpdate(@NonNull ViewGroup container) {
 			if (mCurTransaction != null) {
 				mCurTransaction.commitAllowingStateLoss();
 				mCurTransaction = null;
@@ -802,7 +812,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 
 	@Override
 	public void onRequestPermissionsResult(int requestCode,
-	                                       @NonNull String permissions[], @NonNull int[] grantResults) {
+	                                       @NonNull String[] permissions, @NonNull int[] grantResults) {
 		super.onRequestPermissionsResult(requestCode, permissions, grantResults);
 		switch (requestCode) {
 			case PERMISSION_REQUEST_SAVE_MESSAGE:

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

@@ -28,7 +28,6 @@ import android.graphics.PorterDuff;
 import android.media.RingtoneManager;
 import android.net.Uri;
 import android.os.Bundle;
-import android.preference.PreferenceActivity;
 import android.text.format.DateUtils;
 import android.view.View;
 import android.view.ViewGroup;
@@ -274,11 +273,11 @@ public abstract class NotificationsActivity extends ThreemaActivity implements V
 					if (mutedIndex >= 5) {
 						radioSilentLimited.setText(getString(R.string.one_week) + deadlineString);
 					} else {
-						radioSilentLimited.setText(String.format(getString(R.string.notifications_for_x_hours), muteValues[mutedIndex]) + deadlineString);
+						radioSilentLimited.setText(ConfigUtils.getSafeQuantityString(this, R.plurals.notifications_for_x_hours, muteValues[mutedIndex], muteValues[mutedIndex], muteValues[mutedIndex]) + deadlineString);
 					}
 				} else {
 					radioSilentUnlimited.setChecked(true);
-					radioSilentLimited.setText(String.format(getString(R.string.notifications_for_x_hours), muteValues[0]) + deadlineString);
+					radioSilentLimited.setText((ConfigUtils.getSafeQuantityString(this, R.plurals.notifications_for_x_hours, muteValues[0], muteValues[0]) + deadlineString));
 				}
 			} else {
 				// mentions only
@@ -372,7 +371,7 @@ public abstract class NotificationsActivity extends ThreemaActivity implements V
 		radioSilentUnlimited = this.findViewById(R.id.radio_silent_unlimited);
 		radioSilentLimited = this.findViewById(R.id.radio_silent_limited);
 		radioSilentLimited = this.findViewById(R.id.radio_silent_limited);
-		radioSilentLimited.setText(String.format(getString(R.string.notifications_for_x_hours), muteValues[0]));
+		radioSilentLimited.setText(ConfigUtils.getSafeQuantityString(this, R.plurals.notifications_for_x_hours, muteValues[0], muteValues[0]));
 		radioSilentExceptMentions = this.findViewById(R.id.radio_silent_except_mentions);
 	}
 

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

@@ -105,6 +105,7 @@ import ch.threema.app.services.GroupService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.UserService;
+import ch.threema.app.ui.ComposeEditText;
 import ch.threema.app.ui.MediaItem;
 import ch.threema.app.ui.SingleToast;
 import ch.threema.app.ui.ThreemaSearchView;
@@ -1007,7 +1008,7 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 					if (!expandable) {
 						alertDialog = TextWithCheckboxDialog.newInstance(getString(R.string.really_forward, recipientName), hasCaptions ? R.string.forward_captions : 0, R.string.send, R.string.cancel);
 					} else {
-						alertDialog = ExpandableTextEntryDialog.newInstance(getString(R.string.really_forward, recipientName), R.string.add_caption_hint, presetCaption, R.string.send, R.string.cancel, expandable);
+						alertDialog = ExpandableTextEntryDialog.newInstance(getString(R.string.really_forward, recipientName), R.string.add_caption_hint, presetCaption, R.string.send, R.string.cancel, true);
 					}
 					alertDialog.setData(recipients);
 					alertDialog.show(getSupportFragmentManager(), null);
@@ -1019,18 +1020,17 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 						CompletableFuture
 							.runAsync(copyFilesRunnable, Executors.newSingleThreadExecutor())
 							.thenRunAsync(() -> {
-								int numEditableMedia = 0;
+								DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_FILECOPY, true);
+
+								boolean containsGeoUri = false;
 								for (MediaItem mediaItem : mediaItems) {
-									String mimeType = mediaItem.getMimeType();
-									if (MimeUtil.isImageFile(mimeType) || MimeUtil.isVideoFile(mimeType)) {
-										numEditableMedia++;
+									if (mediaItem!= null && mediaItem.getUri() != null && "geo".equals(mediaItem.getUri().getScheme())) {
+										containsGeoUri = true;
+										break;
 									}
 								}
 
-								DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_FILECOPY, true);
-
-								if (numEditableMedia == mediaItems.size() && mediaItems.size() <= MAX_EDITABLE_IMAGES) { // all files are images or videos
-									// all files are either images or videos => redirect to SendMediaActivity
+								if (mediaItems.size() <= MAX_EDITABLE_IMAGES && !containsGeoUri) { // use send media activity for a reasonable number of files
 									recipientMessageReceivers.clear();
 									for (Object model : recipients) {
 										MessageReceiver messageReceiver = getMessageReceiver(model);
@@ -1048,11 +1048,10 @@ public class RecipientListBaseActivity extends ThreemaToolbarActivity implements
 								} else {
 									// mixed media
 									ExpandableTextEntryDialog alertDialog;
-									boolean expandable = mediaItems.size() == 1 && mediaItems.get(0).getType() != TYPE_LOCATION;
 									if (hideUi) {
-										alertDialog = ExpandableTextEntryDialog.newInstance(getString(R.string.app_name), getString(R.string.really_send, finalRecipientName), R.string.add_caption_hint, captionText, R.string.send, R.string.cancel, expandable);
+										alertDialog = ExpandableTextEntryDialog.newInstance(getString(R.string.app_name), getString(R.string.really_send, finalRecipientName), R.string.add_caption_hint, captionText, R.string.send, R.string.cancel, false);
 									} else {
-										alertDialog = ExpandableTextEntryDialog.newInstance(getString(R.string.really_send, finalRecipientName), R.string.add_caption_hint, captionText, R.string.send, R.string.cancel, expandable);
+										alertDialog = ExpandableTextEntryDialog.newInstance(getString(R.string.really_send, finalRecipientName), R.string.add_caption_hint, captionText, R.string.send, R.string.cancel, false);
 									}
 									alertDialog.setData(recipients);
 									alertDialog.show(getSupportFragmentManager(), null);

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 270 - 359
app/src/main/java/ch/threema/app/activities/SendMediaActivity.java


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

@@ -326,7 +326,7 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 			protected void onPostExecute(Void result) {
 				DialogUtil.dismissDialog(getSupportFragmentManager(), DELETE_MESSAGES_PROGRESS_TAG, true);
 
-				Snackbar.make(coordinatorLayout, String.valueOf(delCount) + " " + getString(R.string.message_deleted), Snackbar.LENGTH_LONG).show();
+				Snackbar.make(coordinatorLayout, ConfigUtils.getSafeQuantityString(StorageManagementActivity.this, R.plurals.message_deleted, delCount, delCount), Snackbar.LENGTH_LONG).show();
 
 				updateStorageDisplay();
 
@@ -431,7 +431,7 @@ public class StorageManagementActivity extends ThreemaToolbarActivity implements
 			protected void onPostExecute(Void result) {
 				DialogUtil.dismissDialog(getSupportFragmentManager(), DELETE_PROGRESS_TAG, true);
 
-				Snackbar.make(coordinatorLayout, String.format(getString(R.string.media_files_deleted), delCount), Snackbar.LENGTH_LONG).show();
+				Snackbar.make(coordinatorLayout, ConfigUtils.getSafeQuantityString(StorageManagementActivity.this, R.plurals.media_files_deleted, delCount, delCount), Snackbar.LENGTH_LONG).show();
 
 				updateStorageDisplay();
 

+ 11 - 0
app/src/main/java/ch/threema/app/activities/TextChatBubbleActivity.java

@@ -50,6 +50,7 @@ import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.services.LockAppService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.PreferenceService;
+import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.LinkifyUtil;
@@ -214,6 +215,16 @@ public class TextChatBubbleActivity extends ThreemaActivity implements GenericAl
 		// display message status
 		StateBitmapUtil.getInstance().setStateDrawable(messageModel, findViewById(R.id.delivered_indicator), true);
 
+		// mock a composemessageholder
+		ComposeMessageHolder holder = new ComposeMessageHolder();
+		holder.groupAckContainer = footerView.findViewById(R.id.groupack_container);
+		holder.groupAckThumbsUpCount = footerView.findViewById(R.id.groupack_thumbsup_count);
+		holder.groupAckThumbsDownCount = footerView.findViewById(R.id.groupack_thumbsdown_count);
+		holder.groupAckThumbsUpImage = footerView.findViewById(R.id.groupack_thumbsup);
+		holder.groupAckThumbsDownImage = footerView.findViewById(R.id.groupack_thumbsdown);
+		holder.deliveredIndicator = findViewById(R.id.delivered_indicator);
+		StateBitmapUtil.getInstance().setGroupAckCount(messageModel, holder);
+
 		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
 			// do not add on lollipop or lower due to this bug: https://issuetracker.google.com/issues/36937508
 			textView.setCustomSelectionActionModeCallback(textSelectionCallback);

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

@@ -22,6 +22,7 @@
 package ch.threema.app.activities;
 
 import android.content.Intent;
+import android.os.Handler;
 import android.widget.Toast;
 
 import org.slf4j.Logger;
@@ -32,6 +33,7 @@ import ch.threema.app.backuprestore.csv.BackupService;
 import ch.threema.app.backuprestore.csv.RestoreService;
 import ch.threema.app.services.UserService;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.app.voip.activities.CallActivity;
 import ch.threema.base.utils.LoggingUtil;
 
 public abstract class ThreemaActivity extends ThreemaAppCompatActivity {
@@ -64,7 +66,6 @@ public abstract class ThreemaActivity extends ThreemaAppCompatActivity {
 	final static public int ACTIVITY_ID_BACKUP_PICKER = 20042;
 	final static public int ACTIVITY_ID_COPY_BALLOT = 20043;
 	public static final int ACTIVITY_ID_CHECK_LOCK = 20046;
-	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;

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

@@ -44,8 +44,9 @@ public class WhatsNewActivity extends ThreemaAppCompatActivity {
 
 		setContentView(R.layout.activity_whatsnew);
 
-		((TextView) findViewById(R.id.whatsnew_title)).setText(getString(R.string.whatsnew_title, getString(R.string.app_name), BuildConfig.VERSION_NAME));
-		((TextView) findViewById(R.id.whatsnew_body)).setText(Html.fromHtml(getString(R.string.whatsnew_headline, getString(R.string.app_name))));
+		// TODO(ANDR-2065): Replace with correct placeholders `getString(R.string.app_name)` instead of "Threema"
+		((TextView) findViewById(R.id.whatsnew_title)).setText(getString(R.string.whatsnew_title, "Threema"));
+		((TextView) findViewById(R.id.whatsnew_body)).setText(Html.fromHtml(getString(R.string.whatsnew_headline, "Threema")));
 
 		findViewById(R.id.next_text).setOnClickListener(v -> {
 /*			startActivity(new Intent(WhatsNewActivity.this, WhatsNew2Activity.class));

+ 7 - 2
app/src/main/java/ch/threema/app/activities/ballot/BallotOverviewActivity.java

@@ -375,8 +375,13 @@ public class BallotOverviewActivity extends ThreemaToolbarActivity implements Li
 		final SparseBooleanArray checkedItems = listView.getCheckedItemPositions();
 		final int numCheckedItems = listView.getCheckedItemCount();
 
-		GenericAlertDialog dialog = GenericAlertDialog.newInstance(R.string.ballot_really_delete, getString(R.string.ballot_really_delete_text, numCheckedItems), R.string.ok, R.string.cancel);
-		dialog.setData(checkedItems);
+		GenericAlertDialog dialog = GenericAlertDialog.newInstance(
+			ConfigUtils.getSafeQuantityString(this, R.plurals.ballot_really_delete, numCheckedItems, numCheckedItems),
+			ConfigUtils.getSafeQuantityString(this, R.plurals.ballot_really_delete_text, numCheckedItems, numCheckedItems),
+			R.string.ok,
+			R.string.cancel
+		);
+			dialog.setData(checkedItems);
 		dialog.show(getSupportFragmentManager(), DIALOG_TAG_BALLOT_DELETE);
 	}
 

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

@@ -22,11 +22,11 @@
 package ch.threema.app.activities.wizard;
 
 import static ch.threema.app.ThreemaApplication.PHONE_LINKED_PLACEHOLDER;
+import static ch.threema.app.ThreemaApplication.WORKER_WORK_SYNC;
 
 import android.accounts.Account;
 import android.annotation.SuppressLint;
 import android.content.Context;
-import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.os.AsyncTask;
 import android.os.Bundle;
@@ -43,8 +43,11 @@ import androidx.annotation.NonNull;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentStatePagerAdapter;
+import androidx.lifecycle.LifecycleOwner;
 import androidx.viewpager.widget.ViewPager;
+import androidx.work.ExistingWorkPolicy;
 import androidx.work.OneTimeWorkRequest;
+import androidx.work.WorkInfo;
 import androidx.work.WorkManager;
 
 import com.google.i18n.phonenumbers.NumberParseException;
@@ -58,6 +61,7 @@ import java.util.List;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ThreemaAppCompatActivity;
+import ch.threema.app.dialogs.GenericProgressDialog;
 import ch.threema.app.dialogs.WizardDialog;
 import ch.threema.app.exceptions.EntryAlreadyExistsException;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
@@ -68,7 +72,6 @@ import ch.threema.app.fragments.wizard.WizardFragment1;
 import ch.threema.app.fragments.wizard.WizardFragment2;
 import ch.threema.app.fragments.wizard.WizardFragment3;
 import ch.threema.app.fragments.wizard.WizardFragment4;
-import ch.threema.app.jobs.WorkSyncService;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.routines.SynchronizeContactsRoutine;
 import ch.threema.app.services.ConversationService;
@@ -83,18 +86,22 @@ import ch.threema.app.ui.ParallaxViewPager;
 import ch.threema.app.ui.StepPagerStrip;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.ConfigUtils;
+import ch.threema.app.utils.DialogUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.SynchronizeContactsUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TextUtil;
 import ch.threema.app.workers.IdentityStatesWorker;
+import ch.threema.app.workers.WorkSyncWorker;
 import ch.threema.base.utils.LoggingUtil;
 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;
 
-public class WizardBaseActivity extends ThreemaAppCompatActivity implements ViewPager.OnPageChangeListener,
+public class WizardBaseActivity extends ThreemaAppCompatActivity implements
+		LifecycleOwner,
+		ViewPager.OnPageChangeListener,
 		View.OnClickListener,
 		WizardFragment1.OnSettingsChangedListener,
 		WizardFragment2.OnSettingsChangedListener,
@@ -117,6 +124,7 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements View
 	private static final long DIALOG_DELAY = 200;
 
 	public static final boolean DEFAULT_SYNC_CONTACTS = false;
+	private static final String DIALOG_TAG_WORK_SYNC = "workSync";
 
 	private static int lastPage = 0;
 	private ParallaxViewPager viewPager;
@@ -155,7 +163,6 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements View
 	private Runnable showDialogDelayedTask(final int current, final int previous) {
 		return () -> {
 			RuntimeUtil.runOnUiThread(() -> {
-
 				if (current == WizardFragment2.PAGE_ID && previous == WizardFragment1.PAGE_ID && TestUtil.empty(getSafePassword())) {
 					if (safeConfig.isBackupForced()) {
 						setPage(WizardFragment1.PAGE_ID);
@@ -255,12 +262,14 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements View
 		viewPager.addLayer(findViewById(R.id.layer0));
 		viewPager.addLayer(findViewById(R.id.layer1));
 
-		viewPager.setAdapter(new ScreenSlidePagerAdapter(getSupportFragmentManager()));
-		viewPager.addOnPageChangeListener(this);
-
-		presetMobile = this.userService.getLinkedMobile();
-		presetEmail = this.userService.getLinkedEmail();
+		if (ConfigUtils.isWorkBuild()) {
+			performWorkSync();
+		} else {
+			setupConfig();
+		}
+	}
 
+	private void setupConfig() {
 		safeConfig = ThreemaSafeMDMConfig.getInstance();
 
 		if (ConfigUtils.isWorkRestricted()) {
@@ -318,6 +327,48 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements View
 			userCannotChangeContactSync = true;
 			isSyncContacts = false;
 		}
+
+		viewPager.setAdapter(new ScreenSlidePagerAdapter(getSupportFragmentManager()));
+		viewPager.addOnPageChangeListener(this);
+
+		presetMobile = this.userService.getLinkedMobile();
+		presetEmail = this.userService.getLinkedEmail();
+	}
+
+	/**
+	 * Perform an early synchronous fetch2. In case of failure due to rate-limiting, do not allow user to continue
+	 */
+	private void performWorkSync() {
+		final String workerTag = "WorkSyncWorker";
+
+		GenericProgressDialog.newInstance(R.string.work_data_sync_desc,
+			R.string.please_wait).show(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC);
+
+		OneTimeWorkRequest workRequest = WorkSyncWorker.Companion.buildOneTimeWorkRequest(false, true, workerTag);
+		WorkManager workManager = WorkManager.getInstance(ThreemaApplication.getAppContext());
+		workManager.getWorkInfosByTagLiveData(workerTag).observe(this, workInfos -> {
+			if (workInfos != null) {
+				for (WorkInfo workInfo : workInfos) {
+					if (workInfo.getState().isFinished()) {
+						DialogUtil.dismissDialog(getSupportFragmentManager(), DIALOG_TAG_WORK_SYNC, true);
+					}
+
+					if (workInfo.getState() == WorkInfo.State.SUCCEEDED) {
+						setupConfig();
+					} else if (workInfo.getState() == WorkInfo.State.FAILED) {
+						RuntimeUtil.runOnUiThread(() -> Toast.makeText(WizardBaseActivity.this, R.string.unable_to_fetch_configuration, Toast.LENGTH_LONG).show());
+						logger.info("Unable to post work request for fetch2");
+						try {
+							userService.removeIdentity();
+						} catch (Exception e) {
+							logger.error("Unable to remove identity", e);
+						}
+						finishAndRemoveTask();
+					}
+				}
+			}
+		});
+		workManager.enqueueUniqueWork(WORKER_WORK_SYNC, ExistingWorkPolicy.REPLACE, workRequest);
 	}
 
 	private void splitMobile(String phoneNumber) {
@@ -902,7 +953,6 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements View
 
 				@Override
 				protected void onPostExecute(Void result) {
-					startWorkSync();
 					startIdentityStatesSync();
 
 					finishHandler.removeCallbacks(finishTask);
@@ -972,12 +1022,6 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements View
 		}
 	}
 
-	private void startWorkSync() {
-		if (ConfigUtils.isWorkBuild()) {
-			WorkSyncService.enqueueWork(this, new Intent(), true);
-		}
-	}
-
 	private void startIdentityStatesSync() {
 		OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(IdentityStatesWorker.class)
 				.build();
@@ -994,28 +1038,24 @@ public class WizardBaseActivity extends ThreemaAppCompatActivity implements View
 			reallySyncContactsAndFinish();
 		} else {
 			preferenceService.setSyncContacts(false);
-			startWorkSync();
 			startIdentityStatesSync();
 			prepareThreemaSafe();
 		}
 	}
 
 	@Override
-	public void onRequestPermissionsResult(int requestCode,
-										   @NonNull String permissions[], @NonNull int[] grantResults) {
+	public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
 		super.onRequestPermissionsResult(requestCode, permissions, grantResults);
 		if (requestCode == PERMISSION_REQUEST_READ_CONTACTS) {
 			if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
 				this.isSyncContacts = true;
 				linkPhone();
+			} else if (userCannotChangeContactSync) {
+				ConfigUtils.showPermissionRationale(this, (View) viewPager.getParent(), R.string.permission_contacts_sync_required);
+				resetUi();
 			} else {
-				if (userCannotChangeContactSync) {
-					ConfigUtils.showPermissionRationale(this, (View) viewPager.getParent(), R.string.permission_contacts_sync_required);
-					resetUi();
-				} else {
-					this.isSyncContacts = false;
-					linkPhone();
-				}
+				this.isSyncContacts = false;
+				linkPhone();
 			}
 		}
 	}

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

@@ -57,6 +57,8 @@ import ch.threema.app.adapters.decorators.ChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.DateSeparatorChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.FileChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.FirstUnreadChatAdapterDecorator;
+import ch.threema.app.adapters.decorators.ForwardSecurityStatusChatAdapterDecorator;
+import ch.threema.app.adapters.decorators.GroupCallStatusDataChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.ImageChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.LocationChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.StatusChatAdapterDecorator;
@@ -141,7 +143,9 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 		TYPE_DATE_SEPARATOR,
 		TYPE_FILE_MEDIA_SEND,
 		TYPE_FILE_MEDIA_RECV,
-		TYPE_FILE_VIDEO_SEND
+		TYPE_FILE_VIDEO_SEND,
+		TYPE_GROUP_CALL_STATUS,
+		TYPE_FORWARD_SECURITY_STATUS
 	})
 	public @interface ItemType {}
 
@@ -169,15 +173,16 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 	public static final int TYPE_FILE_MEDIA_SEND = 21;
 	public static final int TYPE_FILE_MEDIA_RECV = 22;
 	public static final int TYPE_FILE_VIDEO_SEND = 23;
+	public static final int TYPE_GROUP_CALL_STATUS = 24;
+	public static final int TYPE_FORWARD_SECURITY_STATUS = 25;
 
 	// don't forget to update this after adding new types:
-	private static final int TYPE_MAX_COUNT = TYPE_FILE_VIDEO_SEND + 1;
+	private static final int TYPE_MAX_COUNT = TYPE_FORWARD_SECURITY_STATUS + 1;
 
 	private OnClickListener onClickListener;
 	private Map<String, Integer> identityColors = null;
 
 	public interface OnClickListener {
-		void resend(AbstractMessageModel messageModel);
 		void click(View view, int position, AbstractMessageModel messageModel);
 		void longClick(View view, int position, AbstractMessageModel messageModel);
 		boolean touch(View view, MotionEvent motionEvent, AbstractMessageModel messageModel);
@@ -305,8 +310,17 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 		if(m != null) {
 			if(m.isStatusMessage()) {
 				// Special handling for data status messages
-				return m instanceof FirstUnreadMessageModel ? TYPE_FIRST_UNREAD : m instanceof DateSeparatorMessageModel ? TYPE_DATE_SEPARATOR :
-				TYPE_STATUS;
+				if (m instanceof FirstUnreadMessageModel) {
+					return TYPE_FIRST_UNREAD;
+				} else if (m instanceof DateSeparatorMessageModel) {
+					return TYPE_DATE_SEPARATOR;
+				} else if (m.getType() == MessageType.GROUP_CALL_STATUS) {
+					return TYPE_GROUP_CALL_STATUS;
+				} else if (m.getType() == MessageType.FORWARD_SECURITY_STATUS) {
+					return TYPE_FORWARD_SECURITY_STATUS;
+				} else {
+					return TYPE_STATUS;
+				}
 			}
 			else {
 				boolean o = m.isOutbox();
@@ -338,6 +352,10 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 						return o ? TYPE_BALLOT_SEND : TYPE_BALLOT_RECV;
 					case VOIP_STATUS:
 						return o ? TYPE_STATUS_DATA_SEND : TYPE_STATUS_DATA_RECV;
+					case GROUP_CALL_STATUS:
+						return TYPE_GROUP_CALL_STATUS;
+					case FORWARD_SECURITY_STATUS:
+						return TYPE_FORWARD_SECURITY_STATUS;
 					default:
 						if (QuoteUtil.getQuoteType(m) != QuoteUtil.QUOTE_TYPE_NONE) {
 							return o ? TYPE_TEXT_QUOTE_SEND : TYPE_TEXT_QUOTE_RECV;
@@ -356,6 +374,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 			case TYPE_RECV:
 				return R.layout.conversation_list_item_recv;
 			case TYPE_STATUS:
+			case TYPE_FORWARD_SECURITY_STATUS:
 				return R.layout.conversation_list_item_status;
 			case TYPE_FIRST_UNREAD:
 				return R.layout.conversation_list_item_unread;
@@ -397,6 +416,8 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 				return R.layout.conversation_list_item_voip_status_recv;
 			case TYPE_DATE_SEPARATOR:
 				return R.layout.conversation_list_item_date_separator;
+			case TYPE_GROUP_CALL_STATUS:
+				return R.layout.conversation_list_item_group_call_status;
 		}
 
 		//return default!?
@@ -432,10 +453,12 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 			if (itemView != null) {
 				holder.bodyTextView = itemView.findViewById(R.id.text_view);
 				holder.messageBlockView = itemView.findViewById(R.id.message_block);
+				holder.footerView = itemView.findViewById(R.id.indicator_container);
+				holder.dateView = itemView.findViewById(R.id.date_view);
+
 				if (isUserMessage(itemType)) {
 					holder.senderView = itemView.findViewById(R.id.group_sender_view);
 					holder.senderName = itemView.findViewById(R.id.group_sender_name);
-					holder.dateView = itemView.findViewById(R.id.date_view);
 					holder.deliveredIndicator = itemView.findViewById(R.id.delivered_indicator);
 					holder.attachmentImage = itemView.findViewById(R.id.attachment_image_view);
 					holder.avatarView = itemView.findViewById(R.id.avatar_view);
@@ -452,6 +475,12 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 					holder.readOnContainer = itemView.findViewById(R.id.read_on_container);
 					holder.readOnButton = itemView.findViewById(R.id.read_on_button);
 					holder.messageTypeButton = itemView.findViewById(R.id.message_type_button);
+					holder.groupAckContainer = itemView.findViewById(R.id.groupack_container);
+					holder.groupAckThumbsUpCount = itemView.findViewById(R.id.groupack_thumbsup_count);
+					holder.groupAckThumbsDownCount = itemView.findViewById(R.id.groupack_thumbsdown_count);
+					holder.groupAckThumbsUpImage = itemView.findViewById(R.id.groupack_thumbsup);
+					holder.groupAckThumbsDownImage = itemView.findViewById(R.id.groupack_thumbsdown);
+					holder.tapToResend = itemView.findViewById(R.id.tap_to_resend);
 				}
 				itemView.setTag(holder);
 			}
@@ -523,6 +552,12 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 				case VOIP_STATUS:
 					decorator = new VoipStatusDataChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
 					break;
+				case GROUP_CALL_STATUS:
+					decorator = new GroupCallStatusDataChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+					break;
+				case FORWARD_SECURITY_STATUS:
+					decorator = new ForwardSecurityStatusChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
+					break;
 					// Fallback to text chat adapter
 				default:
 					if (messageModel.isStatusMessage()) {
@@ -542,16 +577,12 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 			}
 
 			if(this.onClickListener != null) {
-				decorator.setOnClickRetry(messageModel1 -> onClickListener.resend(messageModel1));
-
 				final View v = itemView;
 				decorator.setOnClickElement(messageModel12 -> onClickListener.click(v, position, messageModel12));
 
 				decorator.setOnLongClickElement(messageModel13 -> onClickListener.longClick(v, position, messageModel13));
 
-				decorator.setOnTouchElement((motionEvent, messageModel14) -> {
-					return onClickListener.touch(v, motionEvent, messageModel14);
-				});
+				decorator.setOnTouchElement((motionEvent, messageModel14) -> onClickListener.touch(v, motionEvent, messageModel14));
 
 				if (!messageModel.isOutbox() && holder.avatarView != null) {
 					if (groupId > 0) {
@@ -656,7 +687,11 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 	 * @return true if it's a user-generated message, false otherwise
 	 */
 	private boolean isUserMessage(@ItemType int itemType) {
-		return (itemType != TYPE_STATUS && itemType != TYPE_FIRST_UNREAD && itemType != TYPE_DATE_SEPARATOR);
+		return (itemType != TYPE_STATUS &&
+			itemType != TYPE_FIRST_UNREAD &&
+			itemType != TYPE_DATE_SEPARATOR &&
+			itemType != TYPE_GROUP_CALL_STATUS &&
+			itemType != TYPE_FORWARD_SECURITY_STATUS);
 	}
 
 	public class ConversationListFilter extends Filter {

+ 74 - 5
app/src/main/java/ch/threema/app/adapters/ContactDetailAdapter.java

@@ -31,35 +31,50 @@ import android.view.View;
 import android.view.ViewGroup;
 import android.widget.ArrayAdapter;
 import android.widget.CheckBox;
+import android.widget.ImageButton;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
 import android.widget.TextView;
 import android.widget.Toast;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.checkbox.MaterialCheckBox;
+import com.google.android.material.chip.Chip;
 import com.google.android.material.textfield.MaterialAutoCompleteTextView;
 
 import org.slf4j.Logger;
+
+import java.util.Collections;
 import java.util.List;
+import java.util.concurrent.ExecutionException;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.StringRes;
-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.dialogs.SimpleStringAlertDialog;
 import ch.threema.app.glide.AvatarOptions;
 import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.routines.UpdateFeatureLevelRoutine;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.GroupService;
 import ch.threema.app.services.IdListService;
 import ch.threema.app.services.PreferenceService;
+import ch.threema.app.services.UserService;
 import ch.threema.app.ui.VerificationLevelImageView;
 import ch.threema.app.utils.AndroidContactUtil;
 import ch.threema.app.utils.ConfigUtils;
+import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.domain.protocol.ThreemaFeature;
+import ch.threema.domain.protocol.api.APIConnector;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupModel;
+import java8.util.concurrent.CompletableFuture;
 
 public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ContactDetailAdapter");
@@ -70,7 +85,9 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 	private final Context context;
 	private ContactService contactService;
 	private GroupService groupService;
+	private UserService userService;
 	private PreferenceService preferenceService;
+	private APIConnector apiConnector;
 	private IdListService excludeFromSyncListService;
 	private IdListService blackListIdentityService;
 	private final ContactModel contactModel;
@@ -100,6 +117,10 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 		private final TextView publicNickNameView;
 		private final LinearLayout groupMembershipTitle;
 		private final MaterialAutoCompleteTextView readReceiptsSpinner, typingIndicatorsSpinner;
+		private final MaterialCheckBox forwardSecurityCheckbox;
+		private final RelativeLayout forwardSecurityContainer;
+		private final Chip clearForwardSecurityButton;
+		private final ImageButton forwardSecurityInfo;
 
 		public HeaderHolder(View view) {
 			super(view);
@@ -115,6 +136,10 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 			this.syncSourceIcon = itemView.findViewById(R.id.sync_source_icon);
 			this.readReceiptsSpinner = itemView.findViewById(R.id.read_receipts_spinner);
 			this.typingIndicatorsSpinner = itemView.findViewById(R.id.typing_indicators_spinner);
+			this.forwardSecurityCheckbox = itemView.findViewById(R.id.forward_security_enabled);
+			this.clearForwardSecurityButton = itemView.findViewById(R.id.clear_forward_security);
+			this.forwardSecurityContainer = itemView.findViewById(R.id.forward_security_container);
+			this.forwardSecurityInfo = itemView.findViewById(R.id.forward_security_info);
 
 			verificationLevelIconView.setOnClickListener(v -> {
 				if (onClickListener != null) {
@@ -162,9 +187,11 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 		try {
 			this.contactService = serviceManager.getContactService();
 			this.groupService = serviceManager.getGroupService();
+			this.userService = serviceManager.getUserService();
 			this.excludeFromSyncListService = serviceManager.getExcludedSyncIdentitiesService();
 			this.blackListIdentityService = serviceManager.getBlackListService();
 			this.preferenceService = serviceManager.getPreferenceService();
+			this.apiConnector = serviceManager.getAPIConnector();
 		} catch (Exception e) {
 			logger.error("Exception", e);
 		}
@@ -193,7 +220,7 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 			ItemHolder itemHolder = (ItemHolder) holder;
 			final GroupModel groupModel = getItem(position);
 
-			this.groupService.loadAvatarIntoImage(groupModel, itemHolder.avatarView, AvatarOptions.DEFAULT);
+			this.groupService.loadAvatarIntoImage(groupModel, itemHolder.avatarView, AvatarOptions.PRESET_DEFAULT_FALLBACK);
 
 			itemHolder.nameView.setText(groupModel.getName());
 
@@ -286,6 +313,48 @@ public class ContactDetailAdapter extends RecyclerView.Adapter<RecyclerView.View
 				contactModel.setTypingIndicators(position12);
 				contactService.save(contactModel);
 			});
+
+			if (ConfigUtils.isForwardSecurityEnabled()) {
+				headerHolder.forwardSecurityInfo.setOnClickListener(v -> SimpleStringAlertDialog.newInstance(R.string.forward_security_mode, R.string.forward_security_explanation).show(((AppCompatActivity) context).getSupportFragmentManager(), "fsinfo"));
+
+				if (ThreemaFeature.canForwardSecurity(contactModel.getFeatureMask())) {
+					headerHolder.forwardSecurityCheckbox.setEnabled(true);
+				} else {
+					try {
+						UpdateFeatureLevelRoutine.removeTimeCache(contactModel);
+						CompletableFuture
+							.runAsync(new UpdateFeatureLevelRoutine(
+								contactService,
+								apiConnector,
+								Collections.singletonList(this.contactModel)
+							))
+							.thenRun(() -> RuntimeUtil.runOnUiThread(() -> {
+								headerHolder.forwardSecurityCheckbox.setEnabled(ThreemaFeature.canForwardSecurity(contactModel.getFeatureMask()));
+							}))
+							.get();
+					} catch (InterruptedException | ExecutionException e) {
+						logger.warn("Unable to fetch feature mask");
+					}
+				}
+				headerHolder.forwardSecurityCheckbox.setChecked(contactModel.isForwardSecurityEnabled());
+				headerHolder.forwardSecurityCheckbox.setOnCheckedChangeListener((compoundButton, b) -> {
+					contactModel.setForwardSecurityEnabled(b);
+					contactService.save(contactModel);
+				});
+				headerHolder.forwardSecurityContainer.setVisibility(View.VISIBLE);
+
+				if (ConfigUtils.isTestBuild()) {
+					headerHolder.clearForwardSecurityButton.setOnClickListener(view -> {
+						try {
+							ThreemaApplication.getServiceManager().getDHSessionStore().deleteAllDHSessions(userService.getIdentity(), contactModel.getIdentity());
+							Toast.makeText(this.context, R.string.forward_security_cleared, Toast.LENGTH_LONG).show();
+						} catch (Exception e) {
+							Toast.makeText(this.context, e.getMessage(), Toast.LENGTH_LONG).show();
+						}
+					});
+					headerHolder.clearForwardSecurityButton.setVisibility(View.VISIBLE);
+				}
+			}
 		}
 	}
 

+ 386 - 0
app/src/main/java/ch/threema/app/adapters/GroupCallParticipantsAdapter.kt

@@ -0,0 +1,386 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2022 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.adapters
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.UiThread
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.recyclerview.widget.RecyclerView
+import ch.threema.app.R
+import ch.threema.app.glide.AvatarOptions
+import ch.threema.app.services.ContactService
+import ch.threema.app.voip.groupcall.GroupCallThreadUtil
+import ch.threema.app.voip.groupcall.ParticipantSurfaceViewRenderer
+import ch.threema.app.voip.groupcall.sfu.*
+import ch.threema.base.utils.LoggingUtil
+import kotlinx.coroutines.*
+import org.webrtc.EglBase
+import org.webrtc.RendererCommon
+import java.lang.IllegalStateException
+
+private val logger = LoggingUtil.getThreemaLogger("GroupCallParticipantsAdapter")
+
+@UiThread
+class GroupCallParticipantsAdapter(
+	private val contactService: ContactService,
+	private val gutterPx: Int
+) : RecyclerView.Adapter<GroupCallParticipantsAdapter.GroupCallParticipantViewHolder>() {
+	private val participants: MutableList<Participant> = mutableListOf()
+
+	private var localParticipantViewHolder: GroupCallParticipantViewHolder? = null
+	private val activeViewHolders: MutableMap<ParticipantId, GroupCallParticipantViewHolder> = mutableMapOf()
+	private val viewHolders: MutableSet<GroupCallParticipantViewHolder> = mutableSetOf()
+
+	lateinit var eglBase: EglBase
+
+	var isPortrait = true
+		set(value) {
+			if (field != value) {
+				field = value
+				notifyItemRangeChanged(0, participants.size)
+			}
+		}
+	private val orientation: Orientation
+		get() = if (isPortrait) {
+			Orientation.PORTRAIT
+		} else {
+			Orientation.LANDSCAPE
+		}
+
+	private val frozenStateUpdates = CoroutineScope(Dispatchers.Main).launch {
+		delay(UPDATE_FROZEN_INTERVAL_MS * 4)
+
+		while (true) {
+			// Don't update frozen state for local participants or participants without active camera
+			// as it may be confusing if changes happen without user interaction
+			activeViewHolders.values.filter {
+				it.participant !is LocalParticipant && it.participant?.cameraActive ?: false
+			}.forEach {
+				it.videoView.updateFrozenState()
+			}
+			delay(UPDATE_FROZEN_INTERVAL_MS)
+		}
+	}
+
+	@UiThread
+	class GroupCallParticipantViewHolder(
+		eglBase: EglBase,
+		itemView: View,
+		val parent: ViewGroup
+	) : RecyclerView.ViewHolder(itemView) {
+
+		var isAttachedToWindow = false
+
+		val name: TextView = itemView.findViewById(R.id.participant_name)
+		val avatar: ImageView = itemView.findViewById(R.id.participant_avatar)
+		val info: ConstraintLayout = itemView.findViewById(R.id.participant_info)
+		var participant: Participant? = null
+		val videoView: ParticipantSurfaceViewRenderer = itemView.findViewById(R.id.video_view)
+
+		private val microphoneMuted: ImageView = itemView.findViewById(R.id.participant_microphone_muted)
+		private var subscribeCameraJob: Job? = null
+
+		init {
+			// TODO(ANDR-2065): Remove logging of video views initialization and release
+			logger.info("Initialise new video view. VideoViews initialised: {}, VideoViews released: {}", videoViewsInitialized, videoViewsReleased)
+			videoView.init(eglBase.eglBaseContext,null)
+			// TODO(ANDR-2065): Remove logging of video views initialization and release
+			videoViewsInitialized++
+			videoView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_BALANCED)
+			videoView.setMirror(false)
+			videoView.setNumFramesNeeded(ENABLE_FRAMES_THRESHOLD, DISABLE_FRAMES_THRESHOLD)
+			videoView.setAvatarView(avatar)
+		}
+
+		private var detachSinkFn: DetachSinkFn? = null
+
+		@UiThread
+		internal fun subscribeCamera() {
+			cancelCameraSubscription()
+			logger.trace("Subscribe camera for participant={}", participant?.id)
+			participant?.let { participant ->
+				val subscribe = {
+					itemView.let {
+						it.post {
+							if (isAttachedToWindow) {
+								subscribeCamera(participant, it)
+							}
+						}
+					}
+				}
+				subscribeCameraJob = CoroutineScope(GroupCallThreadUtil.DISPATCHER).launch {
+					delay(CAMERA_SUBSCRIPTION_DELAY_MILLIS)
+					subscribe()
+				}
+			}
+		}
+
+		@UiThread
+		private fun subscribeCamera(participant: Participant, view: View) {
+			try {
+				logger.debug("Subscribe camera with resolution {}x{}", view.width, view.height)
+				detachSinkFn = participant.subscribeCamera(videoView, view.width, view.height)
+				updateMirroring()
+				videoView.enableVideo()
+			} catch (e: IllegalStateException) {
+				logger.error("Error subscribing camera", e)
+			}
+		}
+
+		@UiThread
+		private fun unsubscribeCamera() {
+			cancelCameraSubscription()
+			itemView.post {
+				participant?.unsubscribeCamera()
+				videoView.disableVideo()
+				detachSinkFn?.invoke()
+				detachSinkFn = null
+			}
+		}
+
+		@UiThread
+		fun updateMirroring() {
+			participant?.let {
+				itemView.post {
+					videoView.setMirror(it.mirrorRenderer)
+				}
+			}
+		}
+
+		@UiThread
+		fun updateCaptureState() {
+			logger.trace("UpdateCaptureState")
+			participant?.let {
+				itemView.post {
+					microphoneMuted.visibility = if (it.microphoneActive) {
+						View.GONE
+					} else {
+						View.VISIBLE
+					}
+				}
+
+				if (isAttachedToWindow && it.cameraActive) {
+					subscribeCamera()
+				} else {
+					unsubscribeCamera()
+				}
+			}
+		}
+
+		@UiThread
+		internal fun cancelCameraSubscription() {
+			subscribeCameraJob = subscribeCameraJob?.let {
+				if (!it.isCompleted) {
+					val message = "Cancel camera subscription"
+					logger.trace(message)
+					it.cancel(message)
+				}
+				null
+			}
+		}
+	}
+
+	/**
+	 * Teardown the adapter when it will not be used anymore.
+	 *
+	 * This will release all video views and cancel pending camera subscriptions.
+	 */
+	@UiThread
+	fun teardown() {
+		viewHolders.forEach {
+			it.cancelCameraSubscription()
+			it.videoView.release()
+			// TODO(ANDR-2065): Remove logging of video views initialization and release
+			videoViewsReleased++
+		}
+		frozenStateUpdates.cancel("releaseVideoViews")
+	}
+
+	@UiThread
+	fun setParticipants(participants: Set<Participant>) {
+		val remove = this.participants
+			.filter { it !in participants }
+
+		val add = participants.filter { it !in this.participants }
+
+		val previousCount = this.participants.size
+		val newCount = this.participants.size - remove.size + add.size
+
+		// Remove participants
+		remove
+			.forEach {
+				val index = this.participants.indexOf(it)
+				this.participants.removeAt(index)
+				notifyItemRemoved(index)
+			}
+
+		// Add participants
+		if (add.isNotEmpty()) {
+			logger.debug("Add {} new participants", add.size)
+			val firstNewPosition = this.participants.size
+			this.participants.addAll(add)
+			notifyItemRangeInserted(firstNewPosition, add.size)
+		}
+
+		updateViewHoldersDimensions(previousCount, newCount)
+	}
+
+	@UiThread
+	private fun updateViewHoldersDimensions(previousCount: Int, newCount: Int) {
+		logger.trace("### updateViewHoldersDimensions")
+		if (hasHeightChanged(previousCount, newCount)) {
+			logger.trace("Layout changed; update view holder heights.")
+			activeViewHolders.values.forEach {
+				it.itemView.layoutParams.height = getViewHeight(it.parent)
+				it.subscribeCamera()
+			}
+		}
+	}
+
+	@UiThread
+	fun updateMirroringForLocalParticipant() {
+		localParticipantViewHolder?.updateMirroring()
+	}
+
+	@UiThread
+	fun updateCaptureStates() {
+		activeViewHolders
+			.map { it.value }
+			.forEach { it.updateCaptureState() }
+	}
+
+	@UiThread
+	override fun onCreateViewHolder(
+		parent: ViewGroup,
+		viewType: Int
+	): GroupCallParticipantViewHolder {
+		val view = LayoutInflater.from(parent.context).inflate(R.layout.item_group_call_participant_list, parent, false)
+		return GroupCallParticipantViewHolder(eglBase, view, parent).also {
+			viewHolders.add(it)
+		}
+	}
+
+	@UiThread
+	override fun onViewAttachedToWindow(holder: GroupCallParticipantViewHolder) {
+		holder.isAttachedToWindow = true
+		holder.updateCaptureState()
+	}
+
+	@UiThread
+	override fun onViewDetachedFromWindow(holder: GroupCallParticipantViewHolder) {
+		holder.isAttachedToWindow = false
+		holder.updateCaptureState()
+	}
+
+	@UiThread
+	override fun onBindViewHolder(holder: GroupCallParticipantViewHolder, position: Int) {
+		val participant = participants[position]
+
+		holder.itemView.layoutParams = FrameLayout.LayoutParams(
+			ViewGroup.LayoutParams.MATCH_PARENT,
+			getViewHeight(holder.parent)
+		)
+
+		holder.participant = participant
+
+		if (participant is LocalParticipant) {
+			localParticipantViewHolder = holder
+		}
+		activeViewHolders[participant.id] = holder
+
+		holder.name.text = participant.name
+
+		if (participant is NormalParticipant) {
+			contactService.loadAvatarIntoImage(participant.contactModel, holder.avatar, AVATAR_OPTIONS)
+		} else {
+			logger.warn("Unknown group call participant type bound: {}", participant.type)
+			holder.avatar.setImageResource(R.drawable.ic_person_outline)
+		}
+	}
+
+	@UiThread
+	override fun onViewRecycled(holder: GroupCallParticipantViewHolder) {
+		if (holder.participant is LocalParticipant) {
+			localParticipantViewHolder = null
+		}
+		holder.participant?.id?.let { activeViewHolders.remove(it) }
+	}
+
+	@UiThread
+	override fun getItemCount() = participants.size
+
+	@UiThread
+	private fun getViewHeight(parent: ViewGroup): Int {
+		val rows = getRowCount(participants.size, isPortrait)
+		val totalGutterPx = (rows + 1) * gutterPx
+		// `+ 1` to compensate "lost" pixels due to integer arithmetic
+		return (parent.measuredHeight - totalGutterPx) / rows + 1
+	}
+
+	@UiThread
+	private fun hasHeightChanged(previousCount: Int, newCount: Int): Boolean {
+		return STABLE_HEIGHT_RANGES[orientation]?.any { previousCount in it && newCount !in it } ?: true
+	}
+
+	@UiThread
+	private companion object {
+		val AVATAR_OPTIONS: AvatarOptions = AvatarOptions.Builder()
+			.setHighRes(true)
+			.toOptions()
+
+		private const val CAMERA_SUBSCRIPTION_DELAY_MILLIS = 800L
+
+		// TODO(ANDR-2065): Remove logging of video views initialization and release
+		private var videoViewsInitialized = 0
+		private var videoViewsReleased = 0
+
+		/**
+		 * A stable height range is - depending on orientation - the range of participants in a call
+		 * that won't affect the view holders height. If the number of participants changes from within one
+		 * range to another, the height of the view holder will change.
+		 */
+		val STABLE_HEIGHT_RANGES: Map<Orientation, List<IntRange>> = mapOf(
+			Orientation.LANDSCAPE to listOf(0..2, 3..Int.MAX_VALUE),
+			Orientation.PORTRAIT to listOf(0..1, 2..4, 5..Int.MAX_VALUE)
+		)
+
+		fun getRowCount(participants: Int, isPortrait: Boolean) = when {
+			participants in 0..1 -> 1
+			participants == 2 && !isPortrait -> 1
+			participants in 2..4 || !isPortrait -> 2
+			else -> 3
+		}
+
+		private const val ENABLE_FRAMES_THRESHOLD = 15
+		private const val DISABLE_FRAMES_THRESHOLD = 5
+		private const val UPDATE_FROZEN_INTERVAL_MS: Long = 5000
+	}
+}
+
+private enum class Orientation {
+	LANDSCAPE, PORTRAIT
+}

+ 185 - 13
app/src/main/java/ch/threema/app/adapters/GroupDetailAdapter.java

@@ -23,9 +23,11 @@ package ch.threema.app.adapters;
 
 import android.content.Context;
 import android.graphics.Bitmap;
+import android.text.Layout;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
+import android.widget.ImageView;
 import android.widget.LinearLayout;
 import android.widget.TextView;
 
@@ -48,9 +50,11 @@ 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.GroupDetailViewModel;
 import ch.threema.app.ui.SectionHeaderView;
 import ch.threema.app.utils.AdapterUtil;
 import ch.threema.app.utils.ConfigUtils;
+import ch.threema.app.utils.LinkifyUtil;
 import ch.threema.app.utils.LocaleUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.base.utils.LoggingUtil;
@@ -60,19 +64,27 @@ import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.group.GroupInviteModel;
 import java8.util.Optional;
 
+import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.COLLAPSED;
+import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.EXPANDED;
+import static ch.threema.app.adapters.GroupDetailAdapter.GroupDescState.NONE;
+
 public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
+	public enum GroupDescState {NONE, COLLAPSED, EXPANDED}
 	private static final Logger logger = LoggingUtil.getThreemaLogger("GroupDetailAdapter");
 
 	private static final int TYPE_HEADER = 0;
 	private static final int TYPE_ITEM = 1;
 
-	private Context context;
+	private boolean isGroupAdmin = false;
+
+	private final Context context;
 	private ContactService contactService;
 	private GroupInviteService groupInviteService;
-	private GroupModel groupModel;
+	private final GroupModel groupModel;
 	private GroupInviteModel defaultGroupInviteModel;
 	private List<ContactModel> contactModels; // Cached copy of group members
 	private OnGroupDetailsClickListener onClickListener;
+	private final GroupDetailViewModel groupDetailViewModel;
 	HeaderHolder headerHolder;
 	private boolean warningShown = false;
 
@@ -103,6 +115,11 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		private final TextView linkString;
 		private final AppCompatImageButton linkResetButton;
 		private final AppCompatImageButton linkShareButton;
+		public final ImageView changeGroupDescButton;
+		public final SectionHeaderView groupDescTitle;
+		private final TextView expandButton;
+		public final TextView groupDescText;
+		public final SectionHeaderView groupDescChangedDate;
 
 		public HeaderHolder(View view) {
 			super(view);
@@ -120,12 +137,44 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 			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);
+			this.changeGroupDescButton = itemView.findViewById(R.id.change_group_desc_btn);
+			this.groupDescTitle = itemView.findViewById(R.id.group_desc_title);
+			this.groupDescText = itemView.findViewById(R.id.group_desc_text);
+			this.groupDescText.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
+				@Override
+				public void onLayoutChange(View view, int i, int i1, int i2, int i3, int i4, int i5, int i6, int i7) {
+					if (groupDetailViewModel.getGroupDescState() == NONE || !checkIfTextFitsInCollapsedView()) {
+						expandButton.setVisibility(View.VISIBLE);
+					} else {
+						expandButton.setVisibility(View.GONE);
+					}
+				}
+			});
+
+			this.expandButton = itemView.findViewById(R.id.expand_group_desc_text);
+			this.groupDescChangedDate = itemView.findViewById(R.id.group_desc_changed_date);
 		}
+
+
+		private boolean checkIfTextFitsInCollapsedView() {
+			Layout layout = headerHolder.groupDescText.getLayout();
+			if (layout != null) {
+				int lines = layout.getLineCount();
+				if (lines > 0) {
+					int ellipsisCount = layout.getEllipsisCount(lines - 1);
+					return ellipsisCount == 0 && lines <= 3;
+				}
+			}
+			return true;
+		}
+
+
 	}
 
-	public GroupDetailAdapter(Context context, GroupModel groupModel) {
+	public GroupDetailAdapter(Context context, GroupModel groupModel, GroupDetailViewModel groupDetailViewModel) {
 		this.context = context;
 		this.groupModel = groupModel;
+		this.groupDetailViewModel = groupDetailViewModel;
 		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 
 		try {
@@ -141,24 +190,24 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		notifyDataSetChanged();
 	}
 
+	@NonNull
 	@Override
-	public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+	public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
 		if (viewType == TYPE_ITEM) {
 			View v = LayoutInflater.from(parent.getContext())
-					.inflate(R.layout.item_group_detail, parent, false);
+				.inflate(R.layout.item_group_detail, parent, false);
 
 			return new ItemHolder(v);
 		} else if (viewType == TYPE_HEADER) {
 			View v = LayoutInflater.from(parent.getContext())
-					.inflate(R.layout.header_group_detail, parent, false);
-
+				.inflate(R.layout.header_group_detail, parent, false);
 			return new HeaderHolder(v);
 		}
 		throw new RuntimeException("no matching item type");
 	}
 
 	@Override
-	public void onBindViewHolder(RecyclerView.ViewHolder holder, final int position) {
+	public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, final int position) {
 		if (holder instanceof ItemHolder) {
 			ItemHolder itemHolder = (ItemHolder) holder;
 			final ContactModel contactModel = getItem(position);
@@ -185,6 +234,16 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 			});
 
 			ContactModel ownerContactModel = contactService.getByIdentity(groupModel.getCreatorIdentity());
+
+			// check if the ID is the owner of the group
+			isGroupAdmin = groupModel.getCreatorIdentity().equals(contactService.getMe().getIdentity());
+
+			if (ConfigUtils.supportGroupDescription()) {
+				initGroupDescriptionSection();
+			} else {
+				disableGroupDescription();
+			}
+
 			if (ownerContactModel != null) {
 				Bitmap bitmap = contactService.getAvatar(ownerContactModel, false);
 
@@ -200,7 +259,7 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 				}
 			} else {
 				// creator is no longer around / has been revoked
-				headerHolder.ownerAvatarView.setImageBitmap(contactService.getDefaultAvatar(null, false));
+				headerHolder.ownerAvatarView.setImageBitmap(contactService.getDefaultAvatar(null, false, false));
 				headerHolder.ownerThreemaId.setText(groupModel.getCreatorIdentity());
 				headerHolder.ownerName.setText(R.string.invalid_threema_id);
 			}
@@ -225,8 +284,7 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		setGroupLinkViewsEnabled(enableGroupLinkSwitch);
 		if (defaultGroupInviteModel != null) {
 			encodeAndDisplayDefaultLink();
-		}
-		else {
+		} else {
 			headerHolder.linkString.setText(R.string.group_link_none);
 			headerHolder.linkResetButton.setVisibility(View.INVISIBLE);
 			headerHolder.linkShareButton.setVisibility(View.INVISIBLE);
@@ -243,8 +301,7 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 				} catch (GroupInviteToken.InvalidGroupInviteTokenException | IOException | GroupInviteModel.MissingRequiredArgumentsException e) {
 					logger.error("Exception, failed to create or get default group link", e);
 				}
-			}
-			else {
+			} else {
 				groupInviteService.deleteDefaultLink(groupModel);
 				GroupDetailAdapter.this.defaultGroupInviteModel = null;
 			}
@@ -269,6 +326,30 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 			" (" + groupInviteService.getCustomLinksCount(groupModel.getApiGroupId()) + " " + context.getString(R.string.custom) + ")" );
 	}
 
+
+	private void initGroupDescriptionSection() {
+		updateGroupDescriptionLayout();
+
+		headerHolder.expandButton.setOnClickListener(view -> {
+			switch (groupDetailViewModel.getGroupDescState()) {
+				case NONE:
+					onClickListener.onGroupDescriptionEditClick();
+					break;
+				case EXPANDED:
+					groupDetailViewModel.setGroupDescState(COLLAPSED);
+					showCollapsedGroupDescription();
+					break;
+				case COLLAPSED:
+					groupDetailViewModel.setGroupDescState(EXPANDED);
+					showExpandedGroupDescription();
+					break;
+			}
+		});
+
+		headerHolder.changeGroupDescButton.setOnClickListener(s -> onClickListener.onGroupDescriptionEditClick());
+	}
+
+
 	private void encodeAndDisplayDefaultLink() {
 		headerHolder.linkString.setText(
 			groupInviteService.encodeGroupInviteLink(GroupDetailAdapter.this.defaultGroupInviteModel).toString()
@@ -322,10 +403,101 @@ public class GroupDetailAdapter extends RecyclerView.Adapter<RecyclerView.ViewHo
 		onClickListener = listener;
 	}
 
+	/**
+	 * Updates the layout based on the group description data of the view model
+	 */
+	public void updateGroupDescriptionLayout() {
+		switch (groupDetailViewModel.getGroupDescState()) {
+			case NONE:
+				showNoGroupDescription();
+				break;
+			case COLLAPSED:
+				showCollapsedGroupDescription();
+				showGroupDescTimestamp();
+				break;
+			case EXPANDED:
+				showExpandedGroupDescription();
+				showGroupDescTimestamp();
+				break;
+		}
+	}
+
+	/**
+	 * Display the group desc timestamp
+	 */
+	private void showGroupDescTimestamp() {
+		headerHolder.groupDescChangedDate.setText(context.getString(R.string.changed_group_desc_date)
+			+ LocaleUtil.formatTimeStampString(context, groupDetailViewModel.getGroupDescTimestamp().getTime(), false));
+		headerHolder.groupDescChangedDate.setVisibility(View.VISIBLE);
+	}
+
+	/**
+	 * Shows the collapsed group description
+	 */
+	private void showCollapsedGroupDescription() {
+		showGroupDescription();
+		headerHolder.groupDescText.setMaxLines(3);
+		headerHolder.expandButton.setText(R.string.read_more);
+	}
+
+	/**
+	 * Shows the expanded group description
+	 */
+	private void showExpandedGroupDescription() {
+		showGroupDescription();
+		headerHolder.expandButton.setText(R.string.read_less);
+		headerHolder.groupDescText.setMaxLines(Integer.MAX_VALUE);
+	}
+
+	/**
+	 * Make the group description elements visible and hide
+	 */
+	private void showGroupDescription() {
+		headerHolder.groupDescTitle.setVisibility(View.VISIBLE);
+		headerHolder.expandButton.setVisibility(View.VISIBLE);
+		headerHolder.groupDescText.setVisibility(View.VISIBLE);
+		headerHolder.groupDescText.setText(groupDetailViewModel.getGroupDesc());
+		LinkifyUtil.getInstance().linkifyText(headerHolder.groupDescText, true);
+		if (isGroupAdmin) {
+			headerHolder.changeGroupDescButton.setVisibility(View.VISIBLE);
+		} else {
+			headerHolder.changeGroupDescButton.setVisibility(View.GONE);
+		}
+	}
+
+	/**
+	 * Hide the group description ui elements and shows a button to add a group description
+	 */
+	private void showNoGroupDescription() {
+		groupDetailViewModel.setGroupDescState(NONE);
+		headerHolder.groupDescTitle.setVisibility(View.GONE);
+		headerHolder.groupDescText.setVisibility(View.GONE);
+		headerHolder.groupDescChangedDate.setVisibility(View.GONE);
+		headerHolder.changeGroupDescButton.setVisibility(View.GONE);
+		headerHolder.expandButton.setText(R.string.add_group_description);
+		if (isGroupAdmin) {
+			headerHolder.expandButton.setVisibility(View.VISIBLE);
+		} else {
+			headerHolder.expandButton.setVisibility(View.GONE);
+		}
+	}
+
+	/**
+	 * Hides all the group description related ui elements
+	 */
+	private void disableGroupDescription() {
+		headerHolder.groupDescTitle.setVisibility(View.GONE);
+		headerHolder.groupDescText.setVisibility(View.GONE);
+		headerHolder.groupDescChangedDate.setVisibility(View.GONE);
+		headerHolder.changeGroupDescButton.setVisibility(View.GONE);
+		headerHolder.expandButton.setVisibility(View.GONE);
+	}
+
 	public interface OnGroupDetailsClickListener {
 		void onGroupOwnerClick(View v, String identity);
 		void onGroupMemberClick(View v, @NonNull ContactModel contactModel);
 		void onResetLinkClick();
 		void onShareLinkClick();
+		void onGroupDescriptionEditClick();
 	}
 }

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

@@ -197,7 +197,7 @@ public class MediaGalleryAdapter extends ArrayAdapter<AbstractMessageModel> {
 									holder.textContainerView.setVisibility(View.VISIBLE);
 								} else if (messageModel.getType() == MessageType.FILE) {
 									// try default avatar for mime type
-									thumbnail = fileService.getDefaultMessageThumbnailBitmap(getContext(), messageModel, null, messageModel.getFileData().getMimeType());
+									thumbnail = fileService.getDefaultMessageThumbnailBitmap(getContext(), messageModel, null, messageModel.getFileData().getMimeType(), false);
 									holder.topTextView.setText(messageModel.getFileData().getFileName());
 									holder.textContainerView.setVisibility(View.VISIBLE);
 									if (thumbnail != null) {

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

@@ -22,25 +22,32 @@
 package ch.threema.app.adapters;
 
 import android.content.Context;
+import android.content.res.ColorStateList;
 import android.graphics.PorterDuff;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
+import android.widget.Chronometer;
 import android.widget.ImageView;
 import android.widget.TextView;
 
+import androidx.annotation.AnyThread;
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.RecyclerView;
+
 import com.google.android.material.chip.Chip;
 
 import java.util.ArrayList;
 import java.util.List;
 
-import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.recyclerview.widget.RecyclerView;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ComposeMessageActivity;
 import ch.threema.app.emojis.EmojiMarkupUtil;
+import ch.threema.app.listeners.ConversationListener;
+import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.services.ContactService;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.ConversationTagService;
@@ -59,11 +66,16 @@ import ch.threema.app.utils.AdapterUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.MessageUtil;
 import ch.threema.app.utils.NameUtil;
+import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.StateBitmapUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.ViewUtil;
+import ch.threema.app.voip.groupcall.GroupCallDescription;
+import ch.threema.app.voip.groupcall.GroupCallManager;
+import ch.threema.app.voip.groupcall.GroupCallObserver;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ConversationModel;
+import ch.threema.storage.models.GroupModel;
 import ch.threema.storage.models.MessageType;
 import ch.threema.storage.models.TagModel;
 
@@ -76,6 +88,7 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 
 	private final Context context;
 	private final GroupService groupService;
+	private final GroupCallManager groupCallManager;
 	private final ConversationTagService conversationTagService;
 	private final ContactService contactService;
 	private final DistributionListService distributionListService;
@@ -97,7 +110,7 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 
 	private final TagModel starTagModel, unreadTagModel;
 
-	public static class MessageListViewHolder extends RecyclerView.ViewHolder {
+	public static class MessageListViewHolder extends RecyclerView.ViewHolder implements GroupCallObserver {
 
 		TextView fromView;
 		protected TextView dateView;
@@ -115,8 +128,14 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 		protected ConversationModel conversationModel;
 		AvatarListItemHolder avatarListItemHolder;
 		final View tagStarOn;
+		final GroupCallManager groupCallManager;
 
-		MessageListViewHolder(final View itemView) {
+		private final View ongoingGroupCallContainer;
+		private final Chip joinGroupCallButton;
+		private final TextView ongoingCallDivider, ongoingCallText;
+		private final Chronometer groupCallDuration;
+
+		MessageListViewHolder(final View itemView, final GroupCallManager groupCallManager) {
 			super(itemView);
 
 			tagStarOn = itemView.findViewById(R.id.tag_star_on);
@@ -139,6 +158,62 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 			avatarListItemHolder = new AvatarListItemHolder();
 			avatarListItemHolder.avatarView = avatarView;
 			avatarListItemHolder.avatarLoadingAsyncTask = null;
+			ongoingGroupCallContainer = itemView.findViewById(R.id.ongoing_group_call_container);
+			ongoingCallText = itemView.findViewById(R.id.ongoing_call_text);
+			joinGroupCallButton = itemView.findViewById(R.id.join_group_call_button);
+			ongoingCallDivider = itemView.findViewById(R.id.ongoing_call_divider);
+			groupCallDuration = itemView.findViewById(R.id.group_call_duration);
+
+			this.groupCallManager = groupCallManager;
+		}
+
+		@Override
+		public void onGroupCallUpdate(@Nullable GroupCallDescription call) {
+			if (ConfigUtils.isGroupCallsEnabled()) {
+				if (call != null && isMatchingGroup(call.getGroupIdInt()) && isNotPrivate()) {
+					updateGroupCallDuration(call);
+				} else {
+					stopGroupCallDuration();
+				}
+				ListenerManager.conversationListeners.handle(listener -> listener.onModified(conversationModel, null));
+			}
+		}
+
+		@AnyThread
+		private void updateGroupCallDuration(@NonNull GroupCallDescription call) {
+			Long runningSince = call.getRunningSince();
+			if (runningSince == null) {
+				stopGroupCallDuration();
+			} else {
+				startGroupCallDuration(runningSince);
+			}
+		}
+
+		@AnyThread
+		private void startGroupCallDuration(long base) {
+			RuntimeUtil.runOnUiThread(() -> {
+				groupCallDuration.setBase(base);
+				groupCallDuration.start();
+				groupCallDuration.setVisibility(View.VISIBLE);
+				ongoingCallDivider.setVisibility(View.VISIBLE);
+			});
+		}
+
+		@AnyThread
+		private void stopGroupCallDuration() {
+			RuntimeUtil.runOnUiThread(() -> {
+				groupCallDuration.stop();
+				groupCallDuration.setVisibility(View.GONE);
+				ongoingCallDivider.setVisibility(View.GONE);
+			});
+		}
+
+		private boolean isMatchingGroup(int groupId) {
+			return conversationModel.isGroupConversation() && conversationModel.getGroup().getId() == groupId;
+		}
+
+		private boolean isNotPrivate() {
+			return hiddenStatus.getVisibility() != View.VISIBLE;
 		}
 
 		public View getItem() {
@@ -146,6 +221,16 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 		}
 
 		public ConversationModel getConversationModel() { return conversationModel; }
+
+		@Override
+		public void onGroupCallStart(@NonNull GroupModel groupModel, @Nullable GroupCallDescription call) {
+			ListenerManager.conversationListeners.handle(new ListenerManager.HandleListener<ConversationListener>() {
+				@Override
+				public void handle(ConversationListener listener) {
+					listener.onModified(conversationModel, null);
+				}
+			});
+		}
 	}
 
 	public static class FooterViewHolder extends RecyclerView.ViewHolder {
@@ -159,21 +244,23 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 		boolean onItemLongClick(View view, int position, ConversationModel conversationModel);
 		void onAvatarClick(View view, int position, ConversationModel conversationModel);
 		void onFooterClick(View view);
+		void onJoinGroupCallClick(ConversationModel conversationModel);
 	}
 
 	public MessageListAdapter(
-			Context context,
-			ContactService contactService,
-			GroupService groupService,
-			DistributionListService distributionListService,
-			ConversationService conversationService,
-			DeadlineListService mutedChatsListService,
-			DeadlineListService mentionOnlyChatsListService,
-			DeadlineListService hiddenChatsListService,
-			ConversationTagService conversationTagService,
-			RingtoneService ringtoneService,
-			String highlightUid,
-			ItemClickListener clickListener) {
+		Context context,
+		ContactService contactService,
+		GroupService groupService,
+		GroupCallManager groupCallManager,
+		DistributionListService distributionListService,
+		ConversationService conversationService,
+		DeadlineListService mutedChatsListService,
+		DeadlineListService mentionOnlyChatsListService,
+		DeadlineListService hiddenChatsListService,
+		ConversationTagService conversationTagService,
+		RingtoneService ringtoneService,
+		String highlightUid,
+		ItemClickListener clickListener) {
 
 		this.context = context;
 		this.inflater = LayoutInflater.from(context);
@@ -201,6 +288,8 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 
 		this.starTagModel = this.conversationTagService.getTagModel(ConversationTagServiceImpl.FIXED_TAG_PIN);
 		this.unreadTagModel = this.conversationTagService.getTagModel(ConversationTagServiceImpl.FIXED_TAG_UNREAD);
+
+		this.groupCallManager = groupCallManager;
 	}
 
 	@Override
@@ -219,6 +308,16 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 		}
 	}
 
+	@Override
+	public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
+		super.onViewRecycled(holder);
+		if (holder instanceof MessageListViewHolder && ((MessageListViewHolder) holder).conversationModel.isGroupConversation()) {
+			MessageListViewHolder messageListViewHolder = (MessageListViewHolder) holder;
+			GroupModel group = messageListViewHolder.conversationModel.getGroup();
+			groupCallManager.removeGroupCallObserver(group, messageListViewHolder);
+		}
+	}
+
 	@NonNull
 	@Override
 	public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int viewType) {
@@ -227,7 +326,7 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 			itemView.setClickable(true);
 			// TODO: MaterialCardView: Setting a custom background is not supported.
 			itemView.setBackgroundResource(R.drawable.listitem_background_selector);
-			return new MessageListViewHolder(itemView);
+			return new MessageListViewHolder(itemView, groupCallManager);
 		}
 		return new FooterViewHolder(inflater.inflate(R.layout.footer_message_section, viewGroup, false));
 	}
@@ -253,28 +352,31 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 				}
 			});
 
-			holder.itemView.setOnLongClickListener(new View.OnLongClickListener() {
-				@Override
-				public boolean onLongClick(View v) {
-					// position may have changed after the item was bound. query current position from holder
-					int currentPos = holder.getLayoutPosition();
+			holder.itemView.setOnLongClickListener(v -> {
+				// position may have changed after the item was bound. query current position from holder
+				int currentPos = holder.getLayoutPosition();
 
-					if (currentPos >= 0) {
-						return clickListener.onItemLongClick(v, currentPos, getEntity(currentPos));
-					}
-					return false;
+				if (currentPos >= 0) {
+					return clickListener.onItemLongClick(v, currentPos, getEntity(currentPos));
 				}
+				return false;
 			});
 
-			holder.avatarView.setOnClickListener(new View.OnClickListener() {
-				@Override
-				public void onClick(View v) {
-					// position may have changed after the item was bound. query current position from holder
-					int currentPos = holder.getLayoutPosition();
+			holder.avatarView.setOnClickListener(v -> {
+				// position may have changed after the item was bound. query current position from holder
+				int currentPos = holder.getLayoutPosition();
 
-					if (currentPos >= 0) {
-						clickListener.onAvatarClick(v, currentPos, getEntity(currentPos));
-					}
+				if (currentPos >= 0) {
+					clickListener.onAvatarClick(v, currentPos, getEntity(currentPos));
+				}
+			});
+
+			holder.joinGroupCallButton.setOnClickListener(v -> {
+				// position may have changed after the item was bound. query current position from holder
+				int currentPos = holder.getLayoutPosition();
+
+				if (currentPos >= 0) {
+					clickListener.onJoinGroupCallClick(getEntity(currentPos));
 				}
 			});
 
@@ -332,6 +434,8 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 					holder.attachmentView.setVisibility(View.GONE);
 					holder.dateView.setVisibility(View.INVISIBLE);
 					holder.deliveryView.setVisibility(View.GONE);
+					holder.joinGroupCallButton.setVisibility(View.GONE);
+					holder.ongoingGroupCallContainer.setVisibility(View.GONE);
 				} else {
 					holder.hiddenStatus.setVisibility(View.GONE);
 					holder.dateView.setText(MessageUtil.getDisplayDate(this.context, messageModel, false));
@@ -345,16 +449,20 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 						holder.deliveryView.setVisibility(View.GONE);
 						holder.dateView.setText(" " + context.getString(R.string.draft));
 						holder.dateView.setContentDescription(null);
-						holder.dateView.setTextAppearance(context, R.style.Threema_TextAppearance_List_ThirdLine_Bold);
+						holder.dateView.setTextAppearance(context, R.style.Threema_TextAppearance_List_ThirdLine_Red);
 						holder.dateView.setVisibility(View.VISIBLE);
 						holder.subjectView.setText(emojiMarkupUtil.formatBodyTextString(context, draft + " ", 100));
 					} else {
 						if (conversationModel.isGroupConversation()) {
-							if (holder.groupMemberName != null) {
+							if (holder.groupMemberName != null && messageModel.getType() != MessageType.GROUP_CALL_STATUS) {
 								holder.groupMemberName.setText(NameUtil.getShortName(this.context, messageModel, this.contactService) + ": ");
 								holder.groupMemberName.setVisibility(View.VISIBLE);
 							}
+						} else {
+							holder.joinGroupCallButton.setVisibility(View.GONE);
+							holder.ongoingGroupCallContainer.setVisibility(View.GONE);
 						}
+
 						// Configure subject
 						MessageUtil.MessageViewElement viewElement = MessageUtil.getViewElement(this.context, messageModel);
 						String subject = viewElement.text;
@@ -400,19 +508,23 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 						if (messageModel.getType() == MessageType.VOIP_STATUS) {
 							// Always show the phone icon
 							holder.deliveryView.setImageResource(R.drawable.ic_phone_locked);
+						} else if (messageModel.getType() == MessageType.GROUP_CALL_STATUS) {
+							holder.deliveryView.setImageResource(R.drawable.ic_group_call);
 						} else {
 							if (!messageModel.isOutbox()) {
 								holder.deliveryView.setImageResource(R.drawable.ic_reply_filled);
 								holder.deliveryView.setContentDescription(context.getString(R.string.state_sent));
 
-								if (messageModel.getState() != null) {
-									switch (messageModel.getState()) {
-										case USERACK:
-											holder.deliveryView.setColorFilter(this.ackColor);
-											break;
-										case USERDEC:
-											holder.deliveryView.setColorFilter(this.decColor);
-											break;
+								if (conversationModel.isContactConversation()){
+									if (messageModel.getState() != null) {
+										switch (messageModel.getState()) {
+											case USERACK:
+												holder.deliveryView.setColorFilter(this.ackColor);
+												break;
+											case USERDEC:
+												holder.deliveryView.setColorFilter(this.decColor);
+												break;
+										}
 									}
 								}
 								holder.deliveryView.setVisibility(View.VISIBLE);
@@ -459,8 +571,12 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 				holder.subjectView.setContentDescription("");
 				holder.muteStatus.setVisibility(View.GONE);
 				holder.hiddenStatus.setVisibility(uniqueId != null && hiddenChatsListService.has(uniqueId) ? View.VISIBLE : View.GONE);
+				holder.joinGroupCallButton.setVisibility(View.GONE);
+				holder.ongoingGroupCallContainer.setVisibility(View.GONE);
 			}
 
+			initializeGroupCallIndicator(holder, conversationModel);
+
 			AdapterUtil.styleConversation(holder.fromView, groupService, conversationModel);
 
 			AvatarListItemUtil.loadAvatar(conversationModel, contactService, groupService, distributionListService, holder.avatarListItemHolder);
@@ -491,8 +607,8 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 			int archivedCount = conversationService.getArchivedCount();
 			if (archivedCount > 0) {
 				archivedChip.setVisibility(View.VISIBLE);
-				archivedChip.setOnClickListener(v -> clickListener.onFooterClick(v));
-				archivedChip.setText(String.format(context.getString(R.string.num_archived_chats), archivedCount));
+				archivedChip.setOnClickListener(clickListener::onFooterClick);
+				archivedChip.setText(ConfigUtils.getSafeQuantityString(ThreemaApplication.getAppContext(), R.plurals.num_archived_chats, archivedCount, archivedCount));
 				if (recyclerView != null) {
 					((EmptyRecyclerView) recyclerView).setNumHeadersAndFooters(0);
 				}
@@ -505,6 +621,52 @@ public class MessageListAdapter extends AbstractRecyclerAdapter<ConversationMode
 		}
 	}
 
+	/**
+	 * Initializes the view holder regarding ongoing group calls. If a group call is running, it
+	 * makes the join group call button visible and disables all the views that would be hidden
+	 * by the button.
+	 */
+	private void initializeGroupCallIndicator(@NonNull MessageListViewHolder holder, @NonNull ConversationModel conversationModel) {
+		GroupModel groupModel = conversationModel.getGroup();
+		if (conversationModel.isGroupConversation()
+			&& !groupService.isNotesGroup(groupModel)
+			&& groupService.isGroupMember(groupModel)
+		) {
+			GroupCallDescription call = groupCallManager.getCurrentChosenCall(holder.conversationModel.getGroup());
+			if (call != null && ConfigUtils.isGroupCallsEnabled()) {
+				boolean isJoined = groupCallManager.isJoinedCall(call);
+
+				holder.joinGroupCallButton.setVisibility(View.VISIBLE);
+				holder.joinGroupCallButton.setText(isJoined ? R.string.voip_gc_open_call : R.string.voip_gc_join_call);
+				ColorStateList groupCallTextColor = ColorStateList.valueOf(context.getResources().getColor(R.color.group_call_accent));
+				holder.joinGroupCallButton.setTextColor(groupCallTextColor);
+				holder.joinGroupCallButton.setChipBackgroundColor(groupCallTextColor.withAlpha(0x1a));
+				holder.ongoingCallText.setText(isJoined ? R.string.voip_gc_in_call : R.string.voip_gc_ongoing_call);
+				holder.ongoingGroupCallContainer.setVisibility(View.VISIBLE);
+
+				holder.unreadCountView.setVisibility(View.GONE);
+				holder.pinIcon.setVisibility(View.GONE);
+				holder.typingContainer.setVisibility(View.GONE);
+				holder.deliveryView.setVisibility(View.GONE);
+				holder.subjectView.setVisibility(View.GONE);
+				holder.dateView.setVisibility(View.GONE);
+				holder.attachmentView.setVisibility(View.GONE);
+				holder.groupMemberName.setVisibility(View.GONE);
+				holder.muteStatus.setVisibility(View.GONE);
+			} else {
+				holder.joinGroupCallButton.setVisibility(View.GONE);
+				holder.ongoingGroupCallContainer.setVisibility(View.GONE);
+			}
+			groupCallManager.addGroupCallObserver(groupModel, holder);
+		} else {
+			if (groupModel != null) {
+				groupCallManager.removeGroupCallObserver(groupModel, holder);
+			}
+			holder.joinGroupCallButton.setVisibility(View.GONE);
+			holder.ongoingGroupCallContainer.setVisibility(View.GONE);
+		}
+	}
+
 	@Override
 	public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
 		super.onAttachedToRecyclerView(recyclerView);

+ 55 - 177
app/src/main/java/ch/threema/app/adapters/SendMediaAdapter.kt

@@ -4,7 +4,7 @@
  *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
  *
  * Threema for Android
- * Copyright (c) 2015-2022 Threema GmbH
+ * Copyright (c) 2022 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,
@@ -21,208 +21,86 @@
 
 package ch.threema.app.adapters
 
-import android.content.Context
-import android.graphics.drawable.Drawable
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.ImageView
-import android.widget.LinearLayout
-import android.widget.TextView
-import androidx.annotation.IntDef
-import androidx.appcompat.widget.AppCompatImageView
-import androidx.recyclerview.widget.RecyclerView
-import ch.threema.app.R
-import ch.threema.app.adapters.SendMediaAdapter.SendMediaHolder
-import ch.threema.app.ui.CheckableFrameLayout
-import ch.threema.app.ui.MediaItem
-import ch.threema.app.utils.BitmapUtil
-import ch.threema.app.utils.StringConversionUtil
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import androidx.lifecycle.Lifecycle
+import androidx.viewpager2.adapter.FragmentStateAdapter
+import androidx.viewpager2.adapter.FragmentViewHolder
+import androidx.viewpager2.widget.ViewPager2
+import ch.threema.app.fragments.BigMediaFragment
+import ch.threema.app.utils.MediaAdapter
+import ch.threema.app.utils.MediaAdapterManager
 import ch.threema.base.utils.LoggingUtil
-import com.bumptech.glide.Glide
-import com.bumptech.glide.load.DataSource
-import com.bumptech.glide.load.engine.GlideException
-import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
-import com.bumptech.glide.request.RequestListener
-import com.bumptech.glide.request.target.Target
-import java.util.*
 
-class SendMediaAdapter(
-        private val context: Context,
-        private val clickListener: ClickListener) : RecyclerView.Adapter<SendMediaHolder>() {
-    private val items: MutableList<MediaItem?> = ArrayList()
-    private var checkedItem: MediaItem? = null
-
-    @kotlin.annotation.Retention(AnnotationRetention.SOURCE)
-    @IntDef(VIEW_TYPE_NORMAL, VIEW_TYPE_ADD)
-    annotation class ViewType
-
-    class SendMediaHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
-        val imageView: ImageView = itemView.findViewById(R.id.image_view)
-        val deleteView: ImageView? = itemView.findViewById(R.id.delete_view)
-        val brokenView: ImageView? = itemView.findViewById(R.id.broken_view)
-        val qualifierView: LinearLayout? = itemView.findViewById(R.id.qualifier_view)
-    }
-
-    override fun onCreateViewHolder(parent: ViewGroup, @ViewType viewType: Int): SendMediaHolder {
-        val view = LayoutInflater.from(parent.context)
-                .inflate(if (viewType == VIEW_TYPE_ADD) R.layout.item_send_media_add else R.layout.item_send_media,
-                        parent,
-                        false)
+private val logger = LoggingUtil.getThreemaLogger("SendMediaAdapter")
 
-        return SendMediaHolder(view)
-    }
-
-    override fun onBindViewHolder(holder: SendMediaHolder, position: Int) {
-        if (getItemViewType(holder.bindingAdapterPosition) == VIEW_TYPE_NORMAL) {
-            val item = items[holder.bindingAdapterPosition]
-
-            holder.itemView.setOnClickListener { clickListener.onItemClicked(holder.bindingAdapterPosition, item, getItemViewType(holder.bindingAdapterPosition)) }
-            holder.deleteView!!.setOnClickListener { clickListener.onDeleteKeyClicked(holder.bindingAdapterPosition) }
-            holder.brokenView!!.visibility = View.GONE
-            (holder.itemView as CheckableFrameLayout).isChecked = item == checkedItem
-
-            Glide.with(context).load(item!!.uri)
-                    .transition(DrawableTransitionOptions.withCrossFade())
-                    .addListener(object : RequestListener<Drawable?> {
-                        override fun onLoadFailed(e: GlideException?, model: Any, target: Target<Drawable?>, isFirstResource: Boolean): Boolean {
-                            holder.brokenView.visibility = View.VISIBLE
-                            return false
-                        }
-
-                        override fun onResourceReady(resource: Drawable?, model: Any, target: Target<Drawable?>, dataSource: DataSource, isFirstResource: Boolean): Boolean {
-                            if (item.type == MediaItem.TYPE_VIDEO_CAM || item.type == MediaItem.TYPE_VIDEO) {
-                                holder.qualifierView!!.visibility = View.VISIBLE
-                                val imageView: AppCompatImageView = holder.qualifierView.findViewById(R.id.video_icon)
-                                imageView.setImageResource(R.drawable.ic_videocam_black_24dp)
-                                val durationView = holder.qualifierView.findViewById<TextView>(R.id.video_duration_text)
-                                if (item.durationMs > 0) {
-                                    durationView.text = StringConversionUtil.getDurationString(item.durationMs)
-                                    durationView.visibility = View.VISIBLE
-                                } else {
-                                    durationView.visibility = View.GONE
-                                }
-                            } else if (item.type == MediaItem.TYPE_GIF) {
-                                holder.qualifierView!!.visibility = View.VISIBLE
-                                val imageView: AppCompatImageView = holder.qualifierView.findViewById(R.id.video_icon)
-                                imageView.setImageResource(R.drawable.ic_gif_24dp)
-                                holder.qualifierView.findViewById<View>(R.id.video_duration_text).visibility = View.GONE
-                            } else {
-                                holder.qualifierView!!.visibility = View.GONE
-                            }
-                            return false
-                        }
-                    })
-                    .into(holder.imageView)
-
-            rotateAndFlipImageView(holder.imageView, item)
-        } else {
-            holder.itemView.setOnClickListener { clickListener.onAddKeyClicked() }
-        }
-    }
+class SendMediaAdapter(
+    fm: FragmentManager,
+    lifecycle: Lifecycle,
+    private val mm: MediaAdapterManager,
+    private val viewPager: ViewPager2?
+) : FragmentStateAdapter(fm, lifecycle), MediaAdapter {
 
-    @ViewType
-    override fun getItemViewType(position: Int): Int {
-        return if (position == items.size) VIEW_TYPE_ADD else VIEW_TYPE_NORMAL
-    }
+    private var fragments: MutableMap<Int, BigMediaFragment> = mutableMapOf()
+    private var bottomElemHeight: Int = 0
 
-    override fun getItemCount(): Int {
-        return items.size + 1
+    init {
+        mm.setMediaAdapter(this)
     }
 
-    fun add(item: MediaItem) {
-        add(listOf(item))
+    fun setBottomElemHeight(bottomElemHeight: Int) {
+        this.bottomElemHeight = bottomElemHeight
     }
 
-    /**
-     * append items to list
-     */
-    fun add(itemList: List<MediaItem?>) {
-        add(itemList, items.size)
-    }
+    override fun getItemCount() = mm.size()
 
-    fun add(itemList: List<MediaItem?>, indexWhereToAdd: Int) {
-        items.addAll(indexWhereToAdd, itemList)
-        notifyItemRangeInserted(indexWhereToAdd, itemList.size)
-    }
-
-    fun remove(index: Int) {
-        items.removeAt(index)
-        notifyItemRemoved(index)
+    override fun createFragment(position: Int): Fragment {
+        return BigMediaFragment.newInstance(mm.get(position), bottomElemHeight).also {
+            fragments[position] = it
+            it.setViewPager(viewPager)
+        }
     }
 
-    fun update(position: Int) {
-        notifyItemChanged(position)
-    }
+    override fun onBindViewHolder(
+        holder: FragmentViewHolder,
+        position: Int,
+        payloads: MutableList<Any>
+    ) {
+        super.onBindViewHolder(holder, position, payloads)
 
-    fun move(fromPosition: Int, toPosition: Int): Boolean {
-        if (toPosition < items.size) {
-            Collections.swap(items, fromPosition, toPosition)
-            notifyItemMoved(fromPosition, toPosition)
-            return true
-        }
-        return false
+        fragments[position]?.setMediaItem(mm.get(position))
+        fragments[position]?.showBigMediaItem()
     }
 
-    private fun rotateAndFlipImageView(imageView: ImageView, item: MediaItem?) {
-        imageView.rotation = item!!.rotation.toFloat()
-        if (item.flip == BitmapUtil.FLIP_NONE) {
-            imageView.scaleY = 1f
-            imageView.scaleX = 1f
-        }
-        if (item.flip and BitmapUtil.FLIP_HORIZONTAL == BitmapUtil.FLIP_HORIZONTAL) {
-            imageView.scaleX = -1f
-        }
-        if (item.flip and BitmapUtil.FLIP_VERTICAL == BitmapUtil.FLIP_VERTICAL) {
-            imageView.scaleY = -1f
-        }
+    override fun getItemId(position: Int): Long {
+        return mm.get(position).uri.hashCode().toLong()
     }
 
-    fun getItems(): List<MediaItem?> {
-        return items
+    override fun containsItem(itemId: Long): Boolean {
+        return mm.getItems().map { it.uri.hashCode().toLong() }.contains(itemId)
     }
 
-    fun getItem(index: Int): MediaItem? {
-        if (items.size > 0 && index < items.size) {
-            return items[index]
+    override fun filenameUpdated(position: Int) {
+        if (position < 0 || position >= itemCount) {
+            logger.error("Could not update filename at position {} of {} items", position, itemCount)
+            return
         }
-        logger.info("Index out of range {} of {}", index, items.size)
-        return null
+        fragments[position]?.updateFilename()
     }
 
-    fun size(): Int {
-        return items.size
-    }
-
-    fun setItemChecked(position: Int) {
-        for (i in items.indices) {
-            val item = items[i]
-            if (item == checkedItem) {
-                if (i != position) {
-                    notifyItemChanged(i)
-                } else {
-                    return
-                }
-                break
-            }
-        }
-        try {
-            checkedItem = items[position]
-            notifyItemChanged(position)
-        } catch (e: IndexOutOfBoundsException) {
-            logger.error("Unable to find item to select", e)
+    override fun videoMuteStateUpdated(position: Int) {
+        if (position < 0 || position >= itemCount) {
+            logger.error("Could not update video at position {} of {} items", position, itemCount)
+            return
         }
+        fragments[position]?.updateVideoPlayerSound()
     }
 
-    interface ClickListener {
-        fun onItemClicked(position: Int, item: MediaItem?, @ViewType itemViewType: Int)
-        fun onDeleteKeyClicked(position: Int)
-        fun onAddKeyClicked()
+    override fun positionUpdated(oldPosition: Int, newPosition: Int) {
+        // Nothing to do as the view pager position is updated via the layout
     }
 
-    companion object {
-        private val logger = LoggingUtil.getThreemaLogger("SendMediaGridAdapter")
-        const val VIEW_TYPE_NORMAL = 0
-        const val VIEW_TYPE_ADD = 1
+    override fun sendAsFileStateUpdated(position: Int) {
+        // Nothing to do as the send as file state is not visible in the adapter
     }
 }

+ 313 - 0
app/src/main/java/ch/threema/app/adapters/SendMediaPreviewAdapter.kt

@@ -0,0 +1,313 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2015-2022 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.adapters
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.annotation.IntDef
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.recyclerview.widget.RecyclerView
+import ch.threema.app.R
+import ch.threema.app.adapters.SendMediaPreviewAdapter.SendMediaHolder
+import ch.threema.app.services.PreferenceService.ImageScale_SEND_AS_FILE
+import ch.threema.app.services.PreferenceService.VideoSize_SEND_AS_FILE
+import ch.threema.app.ui.CheckableFrameLayout
+import ch.threema.app.ui.MediaItem
+import ch.threema.app.ui.MediaItem.*
+import ch.threema.app.utils.*
+import com.bumptech.glide.Glide
+import com.bumptech.glide.load.DataSource
+import com.bumptech.glide.load.engine.GlideException
+import com.bumptech.glide.request.RequestListener
+import com.bumptech.glide.request.target.Target
+
+class SendMediaPreviewAdapter(
+        private val context: Context,
+        private val mm: MediaAdapterManager
+        ) : RecyclerView.Adapter<SendMediaHolder>(), MediaAdapter {
+
+    init {
+        mm.setMediaPreviewAdapter(this)
+    }
+
+    @kotlin.annotation.Retention(AnnotationRetention.SOURCE)
+    @IntDef(VIEW_TYPE_NORMAL, VIEW_TYPE_ADD)
+    annotation class ViewType
+
+    open class SendMediaHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+        val imageView: ImageView = itemView.findViewById(R.id.image_view)
+    }
+
+    class SendMediaItemHolder(itemView: View): SendMediaHolder(itemView) {
+        val contentFrameLayout: CheckableFrameLayout = itemView.findViewById(R.id.content_frame)
+        val fileIndicatorView: ImageView = itemView.findViewById(R.id.file_indicator_view)
+        val mutedIndicatorView: ImageView = itemView.findViewById(R.id.video_send_no_audio)
+        val deleteView: ImageView = itemView.findViewById(R.id.delete_view)
+        val brokenView: ImageView = itemView.findViewById(R.id.broken_view)
+        val qualifierView: LinearLayout = itemView.findViewById(R.id.qualifier_view)
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, @ViewType viewType: Int): SendMediaHolder {
+        val layout = if (viewType == VIEW_TYPE_ADD) R.layout.item_send_media_add else R.layout.item_send_media
+        val view = LayoutInflater.from(parent.context).inflate(layout, parent, false)
+
+        return if (viewType == VIEW_TYPE_ADD) SendMediaHolder(view) else SendMediaItemHolder(view)
+    }
+
+    override fun onBindViewHolder(holder: SendMediaHolder, position: Int) {
+        initializeSendMediaViewHolder(holder, position)
+    }
+
+    override fun onBindViewHolder(holder: SendMediaHolder, position: Int, payloads: List<Any>) {
+        if (holder is SendMediaItemHolder) {
+            val item = mm.get(position)
+
+            if (isVideoMuteStateUpdate(payloads)) {
+                // If it is a video sound update, we need to update the visibility of the muted icon
+                updateMutedStateLayout(holder, item)
+            }
+
+            if (isPositionUpdate(payloads)) {
+                // If the position is updated, we need to update the checked-state
+                updateCheckedLayout(holder, position)
+            }
+
+            if (isSendAsFileUpdate(payloads)) {
+                // If the send as file option has changed, we need to update the file indicator icon
+                updateSendAsFileLayout(holder, item)
+            }
+
+            if (payloads.isEmpty()) {
+                // A total refresh is needed, as the payloads may get dropped when the view is
+                // updated while not visible
+                initializeSendMediaItemViewHolder(holder, position)
+            }
+        } else {
+            initializeSendMediaViewHolder(holder, position)
+        }
+    }
+
+    @ViewType
+    override fun getItemViewType(position: Int): Int {
+        return if (position == mm.size()) VIEW_TYPE_ADD else VIEW_TYPE_NORMAL
+    }
+
+    override fun getItemCount(): Int {
+        return mm.size() + 1
+    }
+
+    /**
+     * Initialize the send media view holder
+     */
+    private fun initializeSendMediaViewHolder(holder: SendMediaHolder, position: Int) {
+        if (holder is SendMediaItemHolder) {
+            initializeSendMediaItemViewHolder(holder, position)
+        } else {
+            holder.itemView.setOnClickListener { mm.onAddClicked() }
+        }
+    }
+
+    /**
+     * Initialize the send media item view holder
+     */
+    private fun initializeSendMediaItemViewHolder(holder: SendMediaItemHolder, position: Int) {
+        val item = mm.get(position)
+
+        updateCheckedLayout(holder, position)
+
+        updateMutedStateLayout(holder, item)
+
+        updateSendAsFileLayout(holder, item)
+
+        updateItemContentLayout(holder, item)
+    }
+
+    /**
+     * Update the layout regarding the current position
+     */
+    private fun updateCheckedLayout(holder: SendMediaItemHolder, position: Int) {
+        // Update the delete button depending on checked status
+        val isChecked = position == mm.getCurrentPosition()
+        holder.contentFrameLayout.isChecked = isChecked
+        holder.deleteView.visibility = if (isChecked) View.VISIBLE else View.INVISIBLE
+
+        // Set listeners
+        holder.itemView.setOnClickListener { mm.changePosition(holder.bindingAdapterPosition) }
+        holder.deleteView.setOnClickListener { mm.remove(holder.bindingAdapterPosition) }
+    }
+
+    /**
+     * Update the layout regarding the muted state
+     */
+    private fun updateMutedStateLayout(holder: SendMediaItemHolder, item: MediaItem) {
+        holder.mutedIndicatorView.visibility =
+            if ((item.type == TYPE_VIDEO || item.type == TYPE_VIDEO_CAM) && item.isMuted) {
+                View.VISIBLE
+            } else {
+                View.GONE
+            }
+    }
+
+    /**
+     * Show the file indicator for files or images/videos that should be sent as files
+     */
+    private fun updateSendAsFileLayout(holder: SendMediaItemHolder, item: MediaItem) {
+        val sendAsFile = item.type == TYPE_FILE || item.imageScale == ImageScale_SEND_AS_FILE || item.videoSize == VideoSize_SEND_AS_FILE
+        holder.fileIndicatorView.visibility = if (sendAsFile) View.VISIBLE else View.GONE
+    }
+
+    /**
+     * Show the given item in the preview
+     */
+    private fun updateItemContentLayout(holder: SendMediaItemHolder, item: MediaItem) {
+        if (showAsFile(item)) {
+            // Show file
+            holder.imageView.setImageDrawable(AppCompatResources.getDrawable(context, IconUtil.getMimeIcon(item.mimeType)))
+            holder.brokenView.visibility = View.INVISIBLE
+        } else {
+            // Show image/video/gif
+            showMedia(holder, item)
+        }
+    }
+
+    /**
+     * Show the media item in the layout
+     */
+    private fun showMedia(holder: SendMediaItemHolder, item: MediaItem) {
+        loadImage(item, holder)
+
+        rotateAndFlipImageView(holder.imageView, item)
+    }
+
+    /**
+     * Return true if the given media item should be displayed as file
+     */
+    private fun showAsFile(mediaItem: MediaItem): Boolean {
+        return when(mediaItem.type) {
+            TYPE_FILE -> true
+            TYPE_VOICEMESSAGE -> true
+            TYPE_TEXT -> true
+            else -> false
+        }
+    }
+
+    /**
+     * Load the image asynchronously into the image view
+     */
+    private fun loadImage(item: MediaItem, holder: SendMediaItemHolder) {
+        Glide.with(context).load(item.uri)
+            .addListener(object : RequestListener<Drawable?> {
+                override fun onLoadFailed(e: GlideException?, model: Any, target: Target<Drawable?>, isFirstResource: Boolean): Boolean {
+                    holder.imageView.setImageDrawable(null)
+                    holder.brokenView.visibility = View.VISIBLE
+                    return false
+                }
+
+                override fun onResourceReady(resource: Drawable?, model: Any, target: Target<Drawable?>, dataSource: DataSource, isFirstResource: Boolean): Boolean {
+                    setQualifierView(item, holder)
+                    holder.brokenView.visibility = View.INVISIBLE
+                    return false
+                }
+            })
+            .into(holder.imageView)
+    }
+
+    /**
+     * Initialize the qualifier view
+     */
+    private fun setQualifierView(item: MediaItem, holder: SendMediaItemHolder) {
+        if (item.type == TYPE_VIDEO_CAM || item.type == TYPE_VIDEO) {
+            holder.qualifierView.visibility = View.VISIBLE
+            val imageView: AppCompatImageView = holder.qualifierView.findViewById(R.id.video_icon)
+            imageView.setImageResource(R.drawable.ic_videocam_black_24dp)
+            val durationView = holder.qualifierView.findViewById<TextView>(R.id.video_duration_text)
+            if (item.durationMs > 0) {
+                durationView.text = StringConversionUtil.getDurationString(item.durationMs)
+                durationView.visibility = View.VISIBLE
+            } else {
+                durationView.visibility = View.GONE
+            }
+        } else if (item.type == TYPE_GIF) {
+            holder.qualifierView.visibility = View.VISIBLE
+            val imageView: AppCompatImageView = holder.qualifierView.findViewById(R.id.video_icon)
+            imageView.setImageResource(R.drawable.ic_gif_24dp)
+            holder.qualifierView.findViewById<View>(R.id.video_duration_text).visibility = View.GONE
+        } else {
+            holder.qualifierView.visibility = View.GONE
+        }
+    }
+
+    /**
+     * Rotate and flip the image view based on the settings of the media item
+     */
+    private fun rotateAndFlipImageView(imageView: ImageView, item: MediaItem) {
+        imageView.rotation = item.rotation.toFloat()
+        if (item.flip == BitmapUtil.FLIP_NONE) {
+            imageView.scaleY = 1f
+            imageView.scaleX = 1f
+        }
+        if (item.flip and BitmapUtil.FLIP_HORIZONTAL == BitmapUtil.FLIP_HORIZONTAL) {
+            imageView.scaleX = -1f
+        }
+        if (item.flip and BitmapUtil.FLIP_VERTICAL == BitmapUtil.FLIP_VERTICAL) {
+            imageView.scaleY = -1f
+        }
+    }
+
+    private fun isPositionUpdate(payloads: List<Any>) = payloads.contains(CHANGE_POSITION)
+
+    private fun isVideoMuteStateUpdate(payloads: List<Any>) = payloads.contains(CHANGE_MUTE)
+
+    private fun isSendAsFileUpdate(payloads: List<Any>) = payloads.contains(CHANGE_SEND_AS_FILE)
+
+    override fun filenameUpdated(position: Int) {
+        // Nothing to do as the filename does not appear in the preview
+    }
+
+    override fun videoMuteStateUpdated(position: Int) {
+        notifyItemChanged(position, CHANGE_MUTE)
+    }
+
+    override fun positionUpdated(oldPosition: Int, newPosition: Int) {
+        notifyItemChanged(oldPosition, CHANGE_POSITION)
+        notifyItemChanged(newPosition, CHANGE_POSITION)
+    }
+
+    override fun sendAsFileStateUpdated(position: Int) {
+        notifyItemChanged(position, CHANGE_SEND_AS_FILE)
+    }
+
+    companion object {
+        const val VIEW_TYPE_NORMAL = 0
+        private const val VIEW_TYPE_ADD = 1
+        private const val CHANGE_POSITION = 1
+        private const val CHANGE_MUTE = 2
+        private const val CHANGE_SEND_AS_FILE = 3
+    }
+}

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

@@ -86,10 +86,8 @@ public class AnimGifChatAdapterDecorator extends ChatAdapterDecorator {
 							gifMessagePlayer.cancel();
 						}
 						break;
-					case ControllerView.STATUS_READY_TO_RETRY:
-						if (onClickRetry != null) {
-							onClickRetry.onClick(getMessageModel());
-						}
+					default:
+						// no action taken for other statuses
 						break;
 				}
 			});
@@ -225,6 +223,7 @@ public class AnimGifChatAdapterDecorator extends ChatAdapterDecorator {
 					holder.controller.setProgressing();
 					break;
 				case SENDFAILED:
+				case FS_KEY_MISMATCH:
 					holder.controller.setRetry();
 					break;
 				default:

+ 6 - 4
app/src/main/java/ch/threema/app/adapters/decorators/AudioChatAdapterDecorator.java

@@ -146,14 +146,15 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 						audioMessagePlayer.cancel();
 					}
 					break;
-				case ControllerView.STATUS_READY_TO_RETRY:
-					if (onClickRetry != null) {
-						onClickRetry.onClick(getMessageModel());
-					}
+				default:
+					// no action taken for other statuses
+					break;
 			}
 		});
 
 		RuntimeUtil.runOnUiThread(() -> {
+			setupResendStatus(holder);
+
 			holder.controller.setNeutral();
 
 			//reset progressbar
@@ -347,6 +348,7 @@ public class AudioChatAdapterDecorator extends ChatAdapterDecorator {
 						holder.controller.setProgressing();
 						break;
 					case SENDFAILED:
+					case FS_KEY_MISMATCH:
 						holder.controller.setRetry();
 						break;
 					default:

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

@@ -23,7 +23,6 @@ package ch.threema.app.adapters.decorators;
 
 import android.content.Context;
 import android.os.Parcel;
-import android.view.View;
 
 import org.slf4j.Logger;
 
@@ -32,15 +31,16 @@ import java.util.ArrayList;
 import ch.threema.app.R;
 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.app.utils.RuntimeUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.GroupMessageModel;
 import ch.threema.storage.models.GroupModel;
+import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.ballot.BallotModel;
 import ch.threema.storage.models.data.media.BallotDataModel;
 
@@ -88,9 +88,8 @@ public class BallotChatAdapterDecorator extends ChatAdapterDecorator {
 				holder.secondaryTextView.setText(explain);
 			}
 
-			this.setOnClickListener(new View.OnClickListener() {
-				@Override
-				public void onClick(View view) {
+			this.setOnClickListener(view -> {
+				if (messageModel.getState() != MessageState.FS_KEY_MISMATCH && messageModel.getState() != MessageState.SENDFAILED) {
 					onActionButtonClick(ballotModel);
 				}
 			}, holder.messageBlockView);
@@ -98,6 +97,8 @@ public class BallotChatAdapterDecorator extends ChatAdapterDecorator {
 			if (holder.controller != null) {
 				holder.controller.setImageResource(R.drawable.ic_outline_rule);
 			}
+
+			RuntimeUtil.runOnUiThread(() -> setupResendStatus(holder));
 		} catch (Exception e) {
 			logger.error("Exception", e);
 		}

+ 28 - 14
app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java

@@ -32,15 +32,16 @@ import android.view.MotionEvent;
 import android.view.View;
 import android.widget.TextView;
 
+import androidx.annotation.DrawableRes;
+import androidx.annotation.Nullable;
+import androidx.appcompat.content.res.AppCompatResources;
+import androidx.fragment.app.Fragment;
+
 import org.slf4j.Logger;
 
 import java.util.HashMap;
 import java.util.Map;
 
-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;
 import ch.threema.app.messagereceiver.MessageReceiver;
@@ -64,15 +65,12 @@ import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.DistributionListMessageModel;
+import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.MessageType;
 
 abstract public class ChatAdapterDecorator extends AdapterDecorator {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ChatAdapterDecorator");
 
-	public interface OnClickRetry {
-		void onClick(AbstractMessageModel messageModel);
-	}
-
 	public interface OnClickElement {
 		void onClick(AbstractMessageModel messageModel);
 	}
@@ -93,7 +91,6 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 	protected final Helper helper;
 	private final StateBitmapUtil stateBitmapUtil;
 
-	protected OnClickRetry onClickRetry = null;
 	protected OnClickElement onClickElement = null;
 	private OnLongClickElement onLongClickElement = null;
 	private OnTouchElement onTouchElement = null;
@@ -277,10 +274,6 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 		this.identityColors = identityColors;
 	}
 
-	public void setOnClickRetry(OnClickRetry onClickRetry) {
-		this.onClickRetry = onClickRetry;
-	}
-
 	public void setOnClickElement(OnClickElement onClickElement) {
 		this.onClickElement = onClickElement;
 	}
@@ -304,7 +297,8 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 		}
 
 		boolean isUserMessage = !getMessageModel().isStatusMessage()
-			&& getMessageModel().getType() != MessageType.STATUS;
+			&& getMessageModel().getType() != MessageType.STATUS
+			&& getMessageModel().getType() != MessageType.GROUP_CALL_STATUS;
 
 		String identity = (
 			messageModel.isOutbox() ?
@@ -392,6 +386,7 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 			}
 
 			stateBitmapUtil.setStateDrawable(messageModel, holder.deliveredIndicator, true);
+			stateBitmapUtil.setGroupAckCount(messageModel, holder);
 		}
 	}
 
@@ -525,4 +520,23 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 	public void setGroupedMessage(boolean grouped) {
 		isGroupedMessage = grouped;
 	}
+
+	/**
+	 * Setup "Tap to resend" UI
+	 * @param holder ComposeMessageHolder
+	 */
+	protected void setupResendStatus(ComposeMessageHolder holder) {
+		if (holder.tapToResend != null) {
+			if (getMessageModel() != null &&
+				getMessageModel().isOutbox() &&
+				(getMessageModel().getState() == MessageState.FS_KEY_MISMATCH ||
+				getMessageModel().getState() == MessageState.SENDFAILED)) {
+				holder.tapToResend.setVisibility(View.VISIBLE);
+				holder.dateView.setVisibility(View.GONE);
+			} else {
+				holder.tapToResend.setVisibility(View.GONE);
+				holder.dateView.setVisibility(View.VISIBLE);
+			}
+		}
+	}
 }

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

@@ -79,10 +79,20 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
 
 		setThumbnail(holder, false);
 
-		RuntimeUtil.runOnUiThread(() -> setControllerState(holder, fileData));
+		RuntimeUtil.runOnUiThread(() -> {
+			setupResendStatus(holder);
+			setControllerState(holder, fileData);
+		});
 
 		setControllerClickListener(holder);
-		setOnClickListener(view -> prepareDownload(fileData, fileMessagePlayer), holder.messageBlockView);
+		setOnClickListener(view -> {
+			if (
+				getMessageModel().getState() != MessageState.FS_KEY_MISMATCH &&
+				getMessageModel().getState() != MessageState.SENDFAILED
+			) {
+				prepareDownload(fileData, fileMessagePlayer);
+			}
+		}, holder.messageBlockView);
 
 		configureFileMessagePlayer(holder, position);
 		configureBodyText(holder);
@@ -245,11 +255,6 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
 								fileMessagePlayer.cancel();
 							}
 							break;
-						case ControllerView.STATUS_READY_TO_RETRY:
-							if (onClickRetry != null) {
-								onClickRetry.onClick(getMessageModel());
-							}
-							break;
 						default:
 							// no action taken for other statuses
 							break;
@@ -317,7 +322,7 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
 		} else {
 			if (thumbnail == null) {
 				try {
-					thumbnail = getFileService().getDefaultMessageThumbnailBitmap(context, getMessageModel(), null, fileData.getMimeType());
+					thumbnail = getFileService().getDefaultMessageThumbnailBitmap(context, getMessageModel(), null, fileData.getMimeType(), false);
 					if (thumbnail != null) {
 						thumbnail = AvatarConverterUtil.convert(getContext().getResources(), thumbnail, getContext().getResources().getColor(R.color.item_controller_color), Color.WHITE);
 					}
@@ -365,6 +370,7 @@ public class FileChatAdapterDecorator extends ChatAdapterDecorator {
 				holder.controller.setProgressing();
 				break;
 			case SENDFAILED:
+			case FS_KEY_MISMATCH:
 				holder.controller.setRetry();
 				break;
 			case SENT:

+ 2 - 6
app/src/main/java/ch/threema/app/adapters/decorators/FirstUnreadChatAdapterDecorator.java

@@ -25,6 +25,7 @@ import android.content.Context;
 
 import ch.threema.app.R;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
+import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.storage.models.AbstractMessageModel;
 
@@ -39,12 +40,7 @@ public class FirstUnreadChatAdapterDecorator extends ChatAdapterDecorator {
 
 	@Override
 	protected void configureChatMessage(final ComposeMessageHolder holder, final int position) {
-		String s;
-		if (this.unreadMessagesCount > 1) {
-			s = getContext().getString(R.string.unread_messages, unreadMessagesCount);
-		} else {
-			s = getContext().getString(R.string.one_unread_message);
-		}
+		String s = ConfigUtils.getSafeQuantityString(getContext(), R.plurals.unread_messages, unreadMessagesCount, unreadMessagesCount);
 
 		if(this.showHide(holder.bodyTextView, !TestUtil.empty(s))) {
 			holder.bodyTextView.setText(s);

+ 52 - 0
app/src/main/java/ch/threema/app/adapters/decorators/ForwardSecurityStatusChatAdapterDecorator.kt

@@ -0,0 +1,52 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2014-2022 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.adapters.decorators
+
+import android.content.Context
+import ch.threema.app.R
+import ch.threema.app.ui.listitemholder.ComposeMessageHolder
+import ch.threema.app.utils.ConfigUtils
+import ch.threema.app.utils.TestUtil
+import ch.threema.storage.models.AbstractMessageModel
+import ch.threema.storage.models.data.status.ForwardSecurityStatusDataModel.ForwardSecurityStatusType
+
+class ForwardSecurityStatusChatAdapterDecorator(context: Context?, messageModel: AbstractMessageModel?, helper: Helper?) : ChatAdapterDecorator(context, messageModel, helper) {
+    override fun configureChatMessage(holder: ComposeMessageHolder, position: Int) {
+        val statusDataModel = messageModel.forwardSecurityStatusData ?: return
+        var body: String? = null
+        when (statusDataModel.status) {
+            ForwardSecurityStatusType.STATIC_TEXT -> body = statusDataModel.staticText
+            ForwardSecurityStatusType.MESSAGE_WITHOUT_FORWARD_SECURITY -> body = context.getString(R.string.message_without_forward_security)
+            ForwardSecurityStatusType.FORWARD_SECURITY_RESET -> body = context.getString(R.string.forward_security_reset)
+            ForwardSecurityStatusType.FORWARD_SECURITY_ESTABLISHED -> body = context.getString(R.string.forward_security_established)
+            ForwardSecurityStatusType.FORWARD_SECURITY_ESTABLISHED_RX -> body = context.getString(R.string.forward_security_established_rx)
+            ForwardSecurityStatusType.FORWARD_SECURITY_MESSAGE_OUT_OF_ORDER -> body = context.getString(R.string.forward_security_message_out_of_order)
+            ForwardSecurityStatusType.FORWARD_SECURITY_MESSAGES_SKIPPED -> body = ConfigUtils.getSafeQuantityString(context, R.plurals.forward_security_messages_skipped, statusDataModel.quantity, statusDataModel.quantity)
+        }
+        if (showHide(holder.bodyTextView, !TestUtil.empty(body))) {
+            holder.bodyTextView.text = body
+        }
+        setOnClickListener({
+            // no action on onClick
+        }, holder.messageBlockView)
+    }
+}

+ 46 - 0
app/src/main/java/ch/threema/app/adapters/decorators/GroupCallStatusDataChatAdapterDecorator.java

@@ -0,0 +1,46 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2017-2022 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.adapters.decorators;
+
+import android.content.Context;
+
+import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
+import ch.threema.app.utils.MessageUtil;
+import ch.threema.storage.models.AbstractMessageModel;
+
+public class GroupCallStatusDataChatAdapterDecorator extends ChatAdapterDecorator {
+
+	public GroupCallStatusDataChatAdapterDecorator(Context context, AbstractMessageModel messageModel, Helper helper) {
+		super(context, messageModel, helper);
+	}
+
+	@Override
+	protected void configureChatMessage(final ComposeMessageHolder holder, final int position) {
+		if (holder.bodyTextView != null) {
+			MessageUtil.MessageViewElement viewElement = MessageUtil.getViewElement(this.getContext(), this.getMessageModel());
+			if (viewElement.text != null) {
+				holder.bodyTextView.setText(viewElement.text);
+			}
+		}
+		this.setOnClickListener(view -> {}, holder.messageBlockView);
+	}
+}

+ 16 - 6
app/src/main/java/ch/threema/app/adapters/decorators/ImageChatAdapterDecorator.java

@@ -28,9 +28,10 @@ import android.graphics.Bitmap;
 import android.view.View;
 import android.widget.Toast;
 
+import androidx.annotation.NonNull;
+
 import org.slf4j.Logger;
 
-import androidx.annotation.NonNull;
 import ch.threema.app.R;
 import ch.threema.app.activities.MediaViewerActivity;
 import ch.threema.app.activities.ThreemaActivity;
@@ -68,7 +69,14 @@ public class ImageChatAdapterDecorator extends ChatAdapterDecorator {
 
 		holder.messagePlayer = imageMessagePlayer;
 
-		setOnClickListener(view -> viewImage(getMessageModel(), holder.attachmentImage), holder.messageBlockView);
+		setOnClickListener(view -> {
+			if (
+				getMessageModel().getState() != MessageState.FS_KEY_MISMATCH &&
+				getMessageModel().getState() != MessageState.SENDFAILED
+			) {
+				viewImage(getMessageModel(), holder.attachmentImage);
+			}
+		}, holder.messageBlockView);
 
 		setControllerClickListener(holder, imageMessagePlayer);
 
@@ -78,7 +86,10 @@ public class ImageChatAdapterDecorator extends ChatAdapterDecorator {
 			holder.attachmentImage.setContentDescription(getContext().getString(R.string.image_placeholder));
 		}
 
-		RuntimeUtil.runOnUiThread(() -> setControllerState(holder, getMessageModel().getImageData()));
+		RuntimeUtil.runOnUiThread(() -> {
+			setupResendStatus(holder);
+			setControllerState(holder, getMessageModel().getImageData());
+		});
 
 		configureBodyText(holder);
 
@@ -149,9 +160,7 @@ public class ImageChatAdapterDecorator extends ChatAdapterDecorator {
 							}
 							break;
 						case ControllerView.STATUS_READY_TO_RETRY:
-							if (onClickRetry != null) {
-								onClickRetry.onClick(getMessageModel());
-							}
+							// ignore (retries will be handled by click listener for messageView)
 							break;
 						case ControllerView.STATUS_READY_TO_DOWNLOAD:
 							imageMessagePlayer.open();
@@ -247,6 +256,7 @@ public class ImageChatAdapterDecorator extends ChatAdapterDecorator {
 				holder.controller.setProgressing();
 				break;
 			case SENDFAILED:
+			case FS_KEY_MISMATCH:
 				holder.controller.setRetry();
 				break;
 			default:

+ 11 - 41
app/src/main/java/ch/threema/app/adapters/decorators/LocationChatAdapterDecorator.java

@@ -23,25 +23,21 @@ package ch.threema.app.adapters.decorators;
 
 import android.annotation.SuppressLint;
 import android.content.Context;
-import android.graphics.Bitmap;
 import android.location.Location;
-import android.os.AsyncTask;
 import android.view.View;
 import android.widget.TextView;
 import android.widget.Toast;
 
 import org.slf4j.Logger;
 
-import androidx.core.graphics.drawable.RoundedBitmapDrawable;
-import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
 import ch.threema.app.R;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
-import ch.threema.app.utils.BitmapUtil;
 import ch.threema.app.utils.GeoLocationUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.AbstractMessageModel;
+import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.data.LocationDataModel;
 
 import static android.view.View.GONE;
@@ -60,9 +56,11 @@ public class LocationChatAdapterDecorator extends ChatAdapterDecorator {
 
 		TextView addressLine = holder.bodyTextView;
 
-		this.setOnClickListener(new View.OnClickListener() {
-			@Override
-			public void onClick(View v) {
+		this.setOnClickListener(v -> {
+			if (
+				getMessageModel().getState() != MessageState.FS_KEY_MISMATCH &&
+				getMessageModel().getState() != MessageState.SENDFAILED
+			) {
 				if(!isInChoiceMode()) {
 					viewLocation(getMessageModel());
 				}
@@ -106,40 +104,12 @@ public class LocationChatAdapterDecorator extends ChatAdapterDecorator {
 			}
 		}
 
-		new AsyncTask<ComposeMessageHolder, Void, RoundedBitmapDrawable>() {
-			private ComposeMessageHolder holder;
-
-			@Override
-			protected RoundedBitmapDrawable doInBackground(ComposeMessageHolder... params) {
-				this.holder = params[0];
-
-				try {
-					Bitmap locationBitmap = getFileService().getMessageThumbnailBitmap(getMessageModel(), getThumbnailCache());
-					if (locationBitmap != null) {
-						RoundedBitmapDrawable drawable = RoundedBitmapDrawableFactory.create(getContext().getResources(), BitmapUtil.cropToSquare(locationBitmap));
-						drawable.setCircular(true);
-						return drawable;
-					}
-				} catch (Exception e) {
-					logger.error("Exception", e);
-				}
-				return null;
-			}
+		if (position == holder.position) {
+			holder.controller.setBackgroundImage(null);
+			holder.controller.setImageResource(R.drawable.ic_map_marker_outline);
+		}
 
-			@Override
-			protected void onPostExecute(RoundedBitmapDrawable drawable) {
-				if (position == holder.position) {
-					if (drawable == null) {
-						holder.controller.setBackgroundImage(null);
-						holder.controller.setImageResource(R.drawable.ic_map_marker_outline);
-					}
-					else {
-						holder.controller.setNeutral();
-						holder.controller.setBackgroundDrawable(drawable);
-					}
-				}
-			}
-		}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, holder);
+		RuntimeUtil.runOnUiThread(() -> setupResendStatus(holder));
 	}
 
 	private void viewLocation(AbstractMessageModel messageModel) {

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

@@ -28,6 +28,7 @@ import android.text.method.LinkMovementMethod;
 import android.view.View;
 
 import androidx.annotation.Nullable;
+
 import ch.threema.app.R;
 import ch.threema.app.activities.TextChatBubbleActivity;
 import ch.threema.app.emojis.EmojiConversationTextView;
@@ -38,6 +39,7 @@ import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.LinkifyUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.QuoteUtil;
+import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.GroupMessageModel;
@@ -100,6 +102,8 @@ public class TextChatAdapterDecorator extends ChatAdapterDecorator {
 				actionModeStatus.getActionModeEnabled(),
 				onClickElement);
 		}
+
+		RuntimeUtil.runOnUiThread(() -> setupResendStatus(holder));
 	}
 
 	@Nullable

+ 47 - 0
app/src/main/java/ch/threema/app/adapters/decorators/VerticalGridLayoutGutterDecoration.kt

@@ -0,0 +1,47 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2022 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.adapters.decorators
+
+import android.graphics.Rect
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+
+/**
+ * A decoration that adds a margin to RecyclerView items.
+ *
+ * This decorator can be used for RecyclerViews that uses a GridLayoutManager or LinearLayoutManager
+ * with vertical orientation.
+ */
+class VerticalGridLayoutGutterDecoration(gutterPx: Int) : RecyclerView.ItemDecoration() {
+	private val gutterPx = gutterPx / 2
+	override fun getItemOffsets(
+		outRect: Rect,
+		view: View,
+		parent: RecyclerView,
+		state: RecyclerView.State
+	) {
+		outRect.top = gutterPx
+		outRect.left = gutterPx
+		outRect.right = gutterPx
+		outRect.bottom = gutterPx
+	}
+}

+ 12 - 8
app/src/main/java/ch/threema/app/adapters/decorators/VideoChatAdapterDecorator.java

@@ -28,11 +28,12 @@ import android.text.format.Formatter;
 import android.view.View;
 import android.widget.Toast;
 
+import androidx.annotation.NonNull;
+
 import org.slf4j.Logger;
 
 import java.io.File;
 
-import androidx.annotation.NonNull;
 import ch.threema.app.R;
 import ch.threema.app.fragments.ComposeMessageFragment;
 import ch.threema.app.services.messageplayer.MessagePlayer;
@@ -69,10 +70,17 @@ public class VideoChatAdapterDecorator extends ChatAdapterDecorator {
 
 		holder.messagePlayer = videoMessagePlayer;
 
-		RuntimeUtil.runOnUiThread(() -> setControllerState(holder));
+		RuntimeUtil.runOnUiThread(() -> {
+			setupResendStatus(holder);
+			setControllerState(holder);
+		});
 
 		setOnClickListener(v -> {
-			if (!isInChoiceMode() && getMessageModel().getState() != MessageState.TRANSCODING) {
+			if (!isInChoiceMode() &&
+				getMessageModel().getState() != MessageState.TRANSCODING &&
+				getMessageModel().getState() != MessageState.SENDFAILED &&
+				getMessageModel().getState() != MessageState.FS_KEY_MISMATCH
+			) {
 				videoMessagePlayer.open();
 			}
 		}, holder.messageBlockView);
@@ -254,11 +262,6 @@ public class VideoChatAdapterDecorator extends ChatAdapterDecorator {
 					case ControllerView.STATUS_TRANSCODING:
 						// no click while processing
 						break;
-					case ControllerView.STATUS_READY_TO_RETRY:
-						if (onClickRetry != null) {
-							onClickRetry.onClick(getMessageModel());
-						}
-						break;
 					default:
 						// no action taken for other statuses
 						break;
@@ -317,6 +320,7 @@ public class VideoChatAdapterDecorator extends ChatAdapterDecorator {
 				}
 				break;
 			case SENDFAILED:
+			case FS_KEY_MISMATCH:
 				holder.controller.setRetry();
 				if (holder.transcoderView != null) {
 					holder.transcoderView.setVisibility(View.GONE);

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

@@ -86,7 +86,7 @@ public class VoipStatusDataChatAdapterDecorator extends ChatAdapterDecorator {
 			@Override
 			public void onClick(View view) {
 				// load the the contact
-				if (ConfigUtils.isCallsEnabled(getContext(), getPreferenceService(), getLicenseService())) {
+				if (ConfigUtils.isCallsEnabled()) {
 					ContactModel contactModel = helper.getContactService().getByIdentity(getMessageModel().getIdentity());
 					if (contactModel != null) {
 						String name = NameUtil.getDisplayNameOrNickname(contactModel, false);

+ 11 - 9
app/src/main/java/ch/threema/app/archive/ArchiveActivity.java

@@ -30,12 +30,6 @@ import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
 
-import com.google.android.material.appbar.MaterialToolbar;
-
-import org.slf4j.Logger;
-
-import java.util.List;
-
 import androidx.annotation.NonNull;
 import androidx.appcompat.view.ActionMode;
 import androidx.appcompat.widget.SearchView;
@@ -43,6 +37,13 @@ import androidx.lifecycle.Observer;
 import androidx.lifecycle.ViewModelProvider;
 import androidx.recyclerview.widget.DefaultItemAnimator;
 import androidx.recyclerview.widget.LinearLayoutManager;
+
+import com.google.android.material.appbar.MaterialToolbar;
+
+import org.slf4j.Logger;
+
+import java.util.List;
+
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.ThreemaActivity;
@@ -59,7 +60,6 @@ import ch.threema.app.ui.ThreemaSearchView;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
-import ch.threema.app.utils.StringConversionUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
@@ -301,7 +301,8 @@ public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAl
 	@SuppressLint("StringFormatInvalid")
 	private void delete(List<ConversationModel> checkedItems) {
 		int num = checkedItems.size();
-		String confirmText = getResources().getQuantityString(R.plurals.really_delete_thread_message, num, num) + " " + getString(R.string.messages_cannot_be_recovered);
+		String confirmText = ConfigUtils.getSafeQuantityString(this, R.plurals.really_delete_thread_message, num, num) + " " + getString(R.string.messages_cannot_be_recovered);
+		String reallyDeleteThreadText = getResources().getString(num > 1 ? R.string.really_delete_multiple_threads : R.string.really_delete_thread);
 
 		if (num == 1 && checkedItems.get(0).isGroupConversation()) {
 			if (groupService.isGroupMember(checkedItems.get(0).getGroup())) {
@@ -310,11 +311,12 @@ public class ArchiveActivity extends ThreemaToolbarActivity implements GenericAl
 				} else {
 					confirmText = getString(R.string.delete_group_message);
 				}
+				reallyDeleteThreadText = getResources().getString((R.string.action_delete_group));
 			}
 		}
 
 		GenericAlertDialog dialog = GenericAlertDialog.newInstance(
-			R.string.really_delete_thread,
+			reallyDeleteThreadText,
 			confirmText,
 			R.string.ok,
 			R.string.cancel);

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

@@ -42,6 +42,7 @@ import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.services.ConversationService;
 import ch.threema.app.services.DistributionListService;
 import ch.threema.app.services.GroupService;
+import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.DialogUtil;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.ConversationModel;
@@ -134,7 +135,7 @@ public class DeleteConversationsAsyncTask extends AsyncTask<Void, Integer, Integ
 
 			// API 19 min
 			if (feedbackView != null && feedbackView.isAttachedToWindow()) {
-				Snackbar.make(feedbackView, String.format(ThreemaApplication.getAppContext().getString(R.string.chat_deleted), count), Snackbar.LENGTH_SHORT).show();
+				Snackbar.make(feedbackView, (ConfigUtils.getSafeQuantityString(ThreemaApplication.getAppContext(), R.plurals.chat_deleted, count, count)), Snackbar.LENGTH_SHORT).show();
 			}
 
 			if (runOnCompletion != null) {

+ 11 - 6
app/src/main/java/ch/threema/app/backuprestore/BackupChatServiceImpl.java

@@ -49,6 +49,7 @@ import ch.threema.app.voicemessage.VoiceRecorderActivity;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ConversationModel;
+import ch.threema.storage.models.MessageType;
 import ch.threema.storage.models.data.media.AudioDataModel;
 import ch.threema.storage.models.data.media.FileDataModel;
 import ch.threema.storage.models.data.media.VideoDataModel;
@@ -69,7 +70,7 @@ public class BackupChatServiceImpl implements BackupChatService {
 		this.contactService = contactService;
 	}
 
-	private boolean buildThread(ConversationModel conversationModel, ZipOutputStream zipOutputStream, StringBuilder messageBody, String password, boolean includeMedia) {
+	private boolean buildThread(ConversationModel conversationModel, ZipOutputStream zipOutputStream, StringBuilder messageBody, boolean includeMedia) {
 		AbstractMessageModel m;
 
 		isCanceled = false;
@@ -87,6 +88,10 @@ public class BackupChatServiceImpl implements BackupChatService {
 				continue;
 			}
 
+			if (m.getType() == MessageType.GROUP_CALL_STATUS || m.getType() == MessageType.FORWARD_SECURITY_STATUS) {
+				continue;
+			}
+
 			String filename = "";
 			String messageLine = "";
 
@@ -129,7 +134,7 @@ public class BackupChatServiceImpl implements BackupChatService {
 						messageLine += " <" + GeoLocationUtil.getLocationUri(m) + ">";
 						break;
 					case VOIP_STATUS:
-						if (m.getVoipStatusData().getDuration() != null) {
+						if (m.getVoipStatusData() != null && m.getVoipStatusData().getDuration() != null) {
 							messageLine += " <" + StringConversionUtil.secondsToString(
 								m.getVoipStatusData().getDuration(),
 								false) + ">";
@@ -146,12 +151,12 @@ public class BackupChatServiceImpl implements BackupChatService {
 					if (includeMedia) {
 						try (InputStream is = fileService.getDecryptedMessageStream(m)) {
 							if (is != null) {
-								ZipUtil.addZipStream(zipOutputStream, is, filename);
+								ZipUtil.addZipStream(zipOutputStream, is, filename, false);
 							} else {
 								// if media is missing, try thumbnail
 								try (InputStream tis = fileService.getDecryptedMessageThumbnailStream(m)) {
 									if (tis != null) {
-										ZipUtil.addZipStream(zipOutputStream, tis, filename);
+										ZipUtil.addZipStream(zipOutputStream, tis, filename, false);
 									}
 								}
 							}
@@ -189,8 +194,8 @@ public class BackupChatServiceImpl implements BackupChatService {
 		StringBuilder messageBody = new StringBuilder();
 
 		try(final ZipOutputStream zipOutputStream = ZipUtil.initializeZipOutputStream(outputFile, password)) {
-			if (buildThread(conversationModel, zipOutputStream, messageBody, password, includeMedia)) {
-				ZipUtil.addZipStream(zipOutputStream, IOUtils.toInputStream(messageBody, StandardCharsets.UTF_8), "messages.txt");
+			if (buildThread(conversationModel, zipOutputStream, messageBody, includeMedia)) {
+				ZipUtil.addZipStream(zipOutputStream, IOUtils.toInputStream(messageBody, StandardCharsets.UTF_8), "messages.txt", true);
 			}
 			return true;
 

+ 7 - 12
app/src/main/java/ch/threema/app/backuprestore/csv/BackupRestoreDataServiceImpl.java

@@ -56,12 +56,9 @@ public class BackupRestoreDataServiceImpl implements BackupRestoreDataService {
 
 	@Override
 	public List<BackupData> getBackups() {
-		File[] files = this.fileService.getBackupPath().listFiles(new FilenameFilter() {
-			@Override
-			public boolean accept(File dir, String filename) {
-				return filename.endsWith(".zip");
-			}
-		});
+		File[] files = this.fileService
+			.getBackupPath()
+			.listFiles((dir, filename) -> filename.endsWith(".zip"));
 
 		List<BackupData> result = new ArrayList<BackupData>();
 
@@ -74,12 +71,10 @@ public class BackupRestoreDataServiceImpl implements BackupRestoreDataService {
 			}
 		}
 
-		Collections.sort(result, new Comparator<BackupData>() {
-			@Override
-			public int compare(BackupData lhs, BackupData rhs) {
-				return rhs.getBackupTime().compareTo(lhs.getBackupTime());
-			}
-		});
+		Collections.sort(
+			result,
+			(lhs, rhs) -> rhs.getBackupTime().compareTo(lhs.getBackupTime())
+		);
 		return result;
 	}
 

+ 59 - 30
app/src/main/java/ch/threema/app/backuprestore/csv/BackupService.java

@@ -38,10 +38,16 @@ import android.text.TextUtils;
 import android.text.format.DateUtils;
 import android.widget.Toast;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.app.NotificationCompat;
+import androidx.documentfile.provider.DocumentFile;
+
 import net.lingala.zip4j.io.outputstream.ZipOutputStream;
 
 import org.apache.commons.io.IOUtils;
 import org.apache.commons.io.output.ByteArrayOutputStream;
+import org.json.JSONObject;
 import org.slf4j.Logger;
 
 import java.io.ByteArrayInputStream;
@@ -53,10 +59,6 @@ import java.util.Date;
 import java.util.List;
 import java.util.Set;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.core.app.NotificationCompat;
-import androidx.documentfile.provider.DocumentFile;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -108,6 +110,7 @@ import ch.threema.storage.models.data.media.VideoDataModel;
 
 import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_ALERT;
 import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS;
+import static ch.threema.app.utils.IntentDataUtil.PENDING_INTENT_FLAG_IMMUTABLE;
 
 public class BackupService extends Service {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("BackupService");
@@ -388,7 +391,7 @@ public class BackupService extends Service {
 			logger.debug("Calculated steps " + progress);
 			this.initProgress(progress);
 
-			ZipUtil.addZipStream(zipOutputStream, new ByteArrayInputStream(settingsBuffer.toByteArray()), Tags.SETTINGS_FILE_NAME);
+			ZipUtil.addZipStream(zipOutputStream, new ByteArrayInputStream(settingsBuffer.toByteArray()), Tags.SETTINGS_FILE_NAME, true);
 
 			if (this.config.backupIdentity()) {
 				if (!this.next("backup identity")) {
@@ -399,7 +402,7 @@ public class BackupService extends Service {
 				IdentityBackupGenerator identityBackupGenerator = new IdentityBackupGenerator(identity, privateKey);
 				String backupData = identityBackupGenerator.generateBackup(this.config.getPassword());
 
-				ZipUtil.addZipStream(zipOutputStream, IOUtils.toInputStream(backupData), Tags.IDENTITY_FILE_NAME);
+				ZipUtil.addZipStream(zipOutputStream, IOUtils.toInputStream(backupData), Tags.IDENTITY_FILE_NAME, false);
 			}
 
 			//backup contacts and messages
@@ -501,7 +504,8 @@ public class BackupService extends Service {
 				ZipUtil.addZipStream(
 					zipOutputStream,
 					this.fileService.getContactAvatarStream(contactService.getMe()),
-					Tags.CONTACT_AVATAR_FILE_PREFIX + contactService.getMe().getIdentity()
+					Tags.CONTACT_AVATAR_FILE_PREFIX + contactService.getMe().getIdentity(),
+					false
 				);
 			} catch (IOException e) {
 				logger.warn("Could not back up own avatar: {}", e.getMessage());
@@ -518,7 +522,8 @@ public class BackupService extends Service {
 			Tags.TAG_CONTACT_LAST_NAME,
 			Tags.TAG_CONTACT_NICK_NAME,
 			Tags.TAG_CONTACT_HIDDEN,
-			Tags.TAG_CONTACT_ARCHIVED
+			Tags.TAG_CONTACT_ARCHIVED,
+			Tags.TAG_CONTACT_FORWARD_SECURITY
 		};
 		final String[] messageCsvHeader = {
 			Tags.TAG_MESSAGE_API_MESSAGE_ID,
@@ -537,7 +542,8 @@ public class BackupService extends Service {
 			Tags.TAG_MESSAGE_CAPTION,
 			Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID,
 			Tags.TAG_MESSAGE_DELIVERED_AT,
-			Tags.TAG_MESSAGE_READ_AT
+			Tags.TAG_MESSAGE_READ_AT,
+			Tags.TAG_GROUP_MESSAGE_STATES
 		};
 
 		// Iterate over all contacts. Then backup every contact with the corresponding messages.
@@ -561,6 +567,7 @@ public class BackupService extends Service {
 						.write(Tags.TAG_CONTACT_NICK_NAME, contactModel.getPublicNickName())
 						.write(Tags.TAG_CONTACT_HIDDEN, contactModel.isHidden())
 						.write(Tags.TAG_CONTACT_ARCHIVED, contactModel.isArchived())
+						.write(Tags.TAG_CONTACT_FORWARD_SECURITY, contactModel.isForwardSecurityEnabled())
 						.write();
 
 					// Back up contact profile pictures
@@ -570,7 +577,8 @@ public class BackupService extends Service {
 								ZipUtil.addZipStream(
 									zipOutputStream,
 									this.fileService.getContactAvatarStream(contactModel),
-									Tags.CONTACT_AVATAR_FILE_PREFIX + contactModel.getIdentity()
+									Tags.CONTACT_AVATAR_FILE_PREFIX + contactModel.getIdentity(),
+									false
 								);
 							}
 						} catch (IOException e) {
@@ -582,7 +590,8 @@ public class BackupService extends Service {
 							ZipUtil.addZipStream(
 								zipOutputStream,
 								this.fileService.getContactPhotoStream(contactModel),
-								Tags.CONTACT_PROFILE_PIC_FILE_PREFIX + contactModel.getIdentity()
+								Tags.CONTACT_PROFILE_PIC_FILE_PREFIX + contactModel.getIdentity(),
+								false
 							);
 						} catch (IOException e) {
 							// profile pics are not THAT important, so we don't care if adding them fails
@@ -641,7 +650,8 @@ public class BackupService extends Service {
 						ZipUtil.addZipStream(
 							zipOutputStream,
 							new ByteArrayInputStream(messageBuffer.toByteArray()),
-							Tags.MESSAGE_FILE_PREFIX + contactModel.getIdentity() + Tags.CSV_FILE_POSTFIX
+							Tags.MESSAGE_FILE_PREFIX + contactModel.getIdentity() + Tags.CSV_FILE_POSTFIX,
+							true
 						);
 					}
 				}
@@ -650,7 +660,8 @@ public class BackupService extends Service {
 			ZipUtil.addZipStream(
 				zipOutputStream,
 				new ByteArrayInputStream(contactBuffer.toByteArray()),
-				Tags.CONTACTS_FILE_NAME + Tags.CSV_FILE_POSTFIX
+				Tags.CONTACTS_FILE_NAME + Tags.CSV_FILE_POSTFIX,
+				true
 			);
 		}
 
@@ -658,7 +669,7 @@ public class BackupService extends Service {
 	}
 
 	/**
-	 * Backup all groups with nessages and media (if configured).
+	 * Backup all groups with messages and media (if configured).
 	 */
 	private boolean backupGroupsAndMessages(
 		@NonNull BackupRestoreDataConfig config,
@@ -671,7 +682,9 @@ public class BackupService extends Service {
 			Tags.TAG_GROUP_CREATED_AT,
 			Tags.TAG_GROUP_MEMBERS,
 			Tags.TAG_GROUP_DELETED,
-			Tags.TAG_GROUP_ARCHIVED
+			Tags.TAG_GROUP_ARCHIVED,
+			Tags.TAG_GROUP_DESC,
+			Tags.TAG_GROUP_DESC_TIMESTAMP
 		};
 		final String[] groupMessageCsvHeader = {
 			Tags.TAG_MESSAGE_API_MESSAGE_ID,
@@ -691,7 +704,8 @@ public class BackupService extends Service {
 			Tags.TAG_MESSAGE_CAPTION,
 			Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID,
 			Tags.TAG_MESSAGE_DELIVERED_AT,
-			Tags.TAG_MESSAGE_READ_AT
+			Tags.TAG_MESSAGE_READ_AT,
+			Tags.TAG_GROUP_MESSAGE_STATES,
 		};
 
 		final GroupService.GroupFilter groupFilter = new GroupService.GroupFilter() {
@@ -740,12 +754,14 @@ public class BackupService extends Service {
 						.write(Tags.TAG_GROUP_MEMBERS, this.groupService.getGroupIdentities(groupModel))
 						.write(Tags.TAG_GROUP_DELETED, groupModel.isDeleted())
 						.write(Tags.TAG_GROUP_ARCHIVED, groupModel.isArchived())
+						.write(Tags.TAG_GROUP_DESC, groupModel.getGroupDesc())
+						.write(Tags.TAG_GROUP_DESC_TIMESTAMP, groupModel.getGroupDescTimestamp())
 						.write();
 
 					//check if the group have a photo
 					if (this.config.backupAvatars()) {
 						try {
-							ZipUtil.addZipStream(zipOutputStream, this.fileService.getGroupAvatarStream(groupModel), Tags.GROUP_AVATAR_PREFIX + groupUid);
+							ZipUtil.addZipStream(zipOutputStream, this.fileService.getGroupAvatarStream(groupModel), Tags.GROUP_AVATAR_PREFIX + groupUid, false);
 						} catch (Exception e) {
 							logger.warn("Could not back up group avatar: {}", e.getMessage());
 						}
@@ -763,6 +779,11 @@ public class BackupService extends Service {
 									return false;
 								}
 
+								String groupMessageStates = "";
+								if (groupMessageModel.getGroupMessageStates() != null) {
+									groupMessageStates = new JSONObject(groupMessageModel.getGroupMessageStates()).toString();
+								}
+
 								groupMessageCsv.createRow()
 									.write(Tags.TAG_MESSAGE_API_MESSAGE_ID, groupMessageModel.getApiMessageId())
 									.write(Tags.TAG_MESSAGE_UID, groupMessageModel.getUid())
@@ -782,6 +803,7 @@ public class BackupService extends Service {
 									.write(Tags.TAG_MESSAGE_QUOTED_MESSAGE_ID, groupMessageModel.getQuotedMessageId())
 									.write(Tags.TAG_MESSAGE_DELIVERED_AT, groupMessageModel.getDeliveredAt())
 									.write(Tags.TAG_MESSAGE_READ_AT, groupMessageModel.getReadAt())
+									.write(Tags.TAG_GROUP_MESSAGE_STATES, groupMessageStates)
 									.write();
 
 								if (MessageUtil.hasDataFile(groupMessageModel)) {
@@ -799,7 +821,8 @@ public class BackupService extends Service {
 						ZipUtil.addZipStream(
 							zipOutputStream,
 							new ByteArrayInputStream(groupMessageBuffer.toByteArray()),
-							Tags.GROUP_MESSAGE_FILE_PREFIX + groupUid + Tags.CSV_FILE_POSTFIX
+							Tags.GROUP_MESSAGE_FILE_PREFIX + groupUid + Tags.CSV_FILE_POSTFIX,
+							true
 						);
 					}
 				}
@@ -807,7 +830,8 @@ public class BackupService extends Service {
 
 			ZipUtil.addZipStream(zipOutputStream, new ByteArrayInputStream(
 				groupBuffer.toByteArray()),
-				Tags.GROUPS_FILE_NAME + Tags.CSV_FILE_POSTFIX
+				Tags.GROUPS_FILE_NAME + Tags.CSV_FILE_POSTFIX,
+				true
 			);
 		}
 
@@ -988,17 +1012,20 @@ public class BackupService extends Service {
 			ZipUtil.addZipStream(
 				zipOutputStream,
 				new ByteArrayInputStream(ballotCsvBuffer.toByteArray()),
-				Tags.BALLOT_FILE_NAME + Tags.CSV_FILE_POSTFIX
+				Tags.BALLOT_FILE_NAME + Tags.CSV_FILE_POSTFIX,
+				true
 			);
 			ZipUtil.addZipStream(
 				zipOutputStream,
 				new ByteArrayInputStream(ballotChoiceCsvBuffer.toByteArray()),
-				Tags.BALLOT_CHOICE_FILE_NAME + Tags.CSV_FILE_POSTFIX
+				Tags.BALLOT_CHOICE_FILE_NAME + Tags.CSV_FILE_POSTFIX,
+				true
 			);
 			ZipUtil.addZipStream(
 				zipOutputStream,
 				new ByteArrayInputStream(ballotVoteCsvBuffer.toByteArray()),
-				Tags.BALLOT_VOTE_FILE_NAME + Tags.CSV_FILE_POSTFIX
+				Tags.BALLOT_VOTE_FILE_NAME + Tags.CSV_FILE_POSTFIX,
+				true
 			);
 
         }
@@ -1109,7 +1136,8 @@ public class BackupService extends Service {
 						ZipUtil.addZipStream(
 							zipOutputStream,
 							new ByteArrayInputStream(messageBuffer.toByteArray()),
-							Tags.DISTRIBUTION_LIST_MESSAGE_FILE_PREFIX + distributionListModel.getId() + Tags.CSV_FILE_POSTFIX
+							Tags.DISTRIBUTION_LIST_MESSAGE_FILE_PREFIX + distributionListModel.getId() + Tags.CSV_FILE_POSTFIX,
+							true
 						);
 					}
 				}
@@ -1118,7 +1146,8 @@ public class BackupService extends Service {
 			ZipUtil.addZipStream(
 				zipOutputStream,
 				new ByteArrayInputStream(distributionListBuffer.toByteArray()),
-				Tags.DISTRIBUTION_LISTS_FILE_NAME + Tags.CSV_FILE_POSTFIX
+				Tags.DISTRIBUTION_LISTS_FILE_NAME + Tags.CSV_FILE_POSTFIX,
+				true
 			);
 		}
 
@@ -1180,7 +1209,7 @@ public class BackupService extends Service {
 			if (saveMedia) {
 				InputStream is = this.fileService.getDecryptedMessageStream(messageModel);
 				if (is != null) {
-					ZipUtil.addZipStream(zipOutputStream, is, filePrefix + messageModel.getUid());
+					ZipUtil.addZipStream(zipOutputStream, is, filePrefix + messageModel.getUid(), false);
 				} else {
 					logger.debug( "Can't add media for message " + messageModel.getUid() + " (" + messageModel.getPostedAt().toString() + "): missing file");
 					// try to save thumbnail if media is missing
@@ -1192,7 +1221,7 @@ public class BackupService extends Service {
 				//save thumbnail every time (if a thumbnail exists)
 				InputStream is = this.fileService.getDecryptedMessageThumbnailStream(messageModel);
 				if (is != null) {
-					ZipUtil.addZipStream(zipOutputStream, is, thumbnailFilePrefix + messageModel.getUid());
+					ZipUtil.addZipStream(zipOutputStream, is, thumbnailFilePrefix + messageModel.getUid(), false);
 				}
 			}
 
@@ -1270,9 +1299,9 @@ public class BackupService extends Service {
 		cancelIntent.putExtra(EXTRA_ID_CANCEL, true);
 		PendingIntent cancelPendingIntent;
 		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
-			cancelPendingIntent = PendingIntent.getForegroundService(this, (int) System.currentTimeMillis(), cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+			cancelPendingIntent = PendingIntent.getForegroundService(this, (int) System.currentTimeMillis(), cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
 		} else {
-			cancelPendingIntent = PendingIntent.getService(this, (int) System.currentTimeMillis(), cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+			cancelPendingIntent = PendingIntent.getService(this, (int) System.currentTimeMillis(), cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
 		}
 
 		notificationBuilder = new NotificationBuilderWrapper(this, NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS, null)
@@ -1321,7 +1350,7 @@ public class BackupService extends Service {
 		}
 
 		Intent backupIntent = new Intent(this, HomeActivity.class);
-		PendingIntent pendingIntent = PendingIntent.getActivity(this, (int)System.currentTimeMillis(), backupIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+		PendingIntent pendingIntent = PendingIntent.getActivity(this, (int)System.currentTimeMillis(), backupIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
 
 		NotificationCompat.Builder builder =
 				new NotificationBuilderWrapper(this, NOTIFICATION_CHANNEL_ALERT, null)
@@ -1351,7 +1380,7 @@ public class BackupService extends Service {
 		String text;
 
 		Intent backupIntent = new Intent(this, HomeActivity.class);
-		PendingIntent pendingIntent = PendingIntent.getActivity(this, (int)System.currentTimeMillis(), backupIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+		PendingIntent pendingIntent = PendingIntent.getActivity(this, (int)System.currentTimeMillis(), backupIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
 
 		NotificationCompat.Builder builder =
 				new NotificationBuilderWrapper(this, NOTIFICATION_CHANNEL_ALERT, null)

+ 147 - 112
app/src/main/java/ch/threema/app/backuprestore/csv/RestoreService.java

@@ -37,15 +37,12 @@ import android.text.TextUtils;
 import android.text.format.DateUtils;
 import android.widget.Toast;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.core.app.NotificationCompat;
-
 import net.lingala.zip4j.ZipFile;
 import net.lingala.zip4j.io.inputstream.ZipInputStream;
 import net.lingala.zip4j.model.FileHeader;
 
 import org.apache.commons.io.IOUtils;
+import org.json.JSONException;
 import org.slf4j.Logger;
 
 import java.io.File;
@@ -59,6 +56,9 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.app.NotificationCompat;
 import ch.threema.app.BuildConfig;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
@@ -80,6 +80,7 @@ import ch.threema.app.utils.BackupUtils;
 import ch.threema.app.utils.CSVReader;
 import ch.threema.app.utils.CSVRow;
 import ch.threema.app.utils.ConfigUtils;
+import ch.threema.app.utils.JsonUtil;
 import ch.threema.app.utils.MessageUtil;
 import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.StringConversionUtil;
@@ -116,6 +117,7 @@ import ch.threema.storage.models.data.media.FileDataModel;
 
 import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_ALERT;
 import static ch.threema.app.services.NotificationService.NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS;
+import static ch.threema.app.utils.IntentDataUtil.PENDING_INTENT_FLAG_IMMUTABLE;
 
 public class RestoreService extends Service {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("RestoreService");
@@ -131,7 +133,6 @@ public class RestoreService extends Service {
 	private GroupService groupService;
 	private DatabaseServiceNew databaseServiceNew;
 	private PreferenceService preferenceService;
-	private ThreemaConnection threemaConnection;
 	private PowerManager.WakeLock wakeLock;
 	private NotificationManager notificationManager;
 
@@ -152,14 +153,14 @@ public class RestoreService extends Service {
 	private ZipFile zipFile;
 	private String password;
 
-	private final int STEP_SIZE_PREPARE = 100;
-	private final int STEP_SIZE_IDENTITY = 100;
-	private final int STEP_SIZE_MAIN_FILES = 200;
-	private final int STEP_SIZE_MESSAGES = 1; // per message
-	private final int STEP_SIZE_GRPOUP_AVATARS = 50;
-	private final int STEP_SIZE_MEDIA = 25; // per media file
+	private static final int STEP_SIZE_PREPARE = 100;
+	private static final int STEP_SIZE_IDENTITY = 100;
+	private static final int STEP_SIZE_MAIN_FILES = 200;
+	private static final int STEP_SIZE_MESSAGES = 1; // per message
+	private static final int STEP_SIZE_GROUP_AVATARS = 50;
+	private static final int STEP_SIZE_MEDIA = 25; // per media file
 
-	private long stepSizeTotal = STEP_SIZE_PREPARE + STEP_SIZE_IDENTITY + STEP_SIZE_MAIN_FILES + STEP_SIZE_GRPOUP_AVATARS;
+	private long stepSizeTotal = (long) STEP_SIZE_PREPARE + STEP_SIZE_IDENTITY + STEP_SIZE_MAIN_FILES + STEP_SIZE_GROUP_AVATARS;
 
 	private static boolean isCanceled = false;
 	private static boolean isRunning = false;
@@ -276,7 +277,6 @@ public class RestoreService extends Service {
 			userService = serviceManager.getUserService();
 			groupService = serviceManager.getGroupService();
 			preferenceService = serviceManager.getPreferenceService();
-			threemaConnection = serviceManager.getConnection();
 		} catch (Exception e) {
 			logger.error("Could not instantiate all required services", e);
 			stopSelf();
@@ -353,6 +353,11 @@ public class RestoreService extends Service {
 		}
 	}
 
+	/**
+	 * CSV file processor
+	 *
+	 * The {@link #row(CSVRow)} method will be called for every row in the CSV file.
+	 */
 	private interface ProcessCsvFile {
 		void row(CSVRow row) throws RestoreCanceledException;
 	}
@@ -386,7 +391,19 @@ public class RestoreService extends Service {
 		}
 
 		try {
-			// we use two passes for a restore
+			// Ensure that the server connection is stopped before restoring the backup.
+			//
+			// This is important, because during the backup restore process, some outgoing
+			// messages (e.g. group sync messages) might be enqueued. However, we only want to
+			// send those messages if the backup restore succeeded.
+			//
+			// The connection will be resumed in {@link onFinished}.
+			final ThreemaConnection connection = serviceManager.getConnection();
+			if (connection != null && connection.isRunning()) {
+				connection.stop();
+			}
+
+			// We use two passes for a restore. The first pass only scans the files in the backup, but does not write to the database. In the second pass, the files are actually written.
 			for (int nTry = 0; nTry < 2; nTry++) {
 				logger.info("Attempt {}", nTry + 1);
 				if (nTry > 0) {
@@ -403,11 +420,6 @@ public class RestoreService extends Service {
 				if (this.writeToDb) {
 					updateProgress(STEP_SIZE_PREPARE);
 
-					/*
-					this.helper.getDatabaseService().close();
-					this.helper.getDatabaseService().drop();
-					*/
-
 					//clear tables!!
 					logger.info("Clearing current tables");
 					databaseServiceNew.getMessageModelFactory().deleteAll();
@@ -424,7 +436,7 @@ public class RestoreService extends Service {
 					databaseServiceNew.getGroupMessagePendingMessageIdModelFactory().deleteAll();
 					databaseServiceNew.getGroupRequestSyncLogModelFactory().deleteAll();
 
-					//remove all media files (don't remove recursive, tmp folder contain the restoring files
+					// Remove all media files (don't remove recursively, tmp folder contain the restoring files
 					logger.info("Deleting current media files");
 					fileService.clearDirectory(fileService.getAppDataPath(), false);
 				}
@@ -433,14 +445,12 @@ public class RestoreService extends Service {
 				@SuppressWarnings({"unchecked"})
 				List<FileHeader> fileHeaders = zipFile.getFileHeaders();
 
-				FileHeader settingsHeader = Functional.select(fileHeaders, new IPredicateNonNull<FileHeader>() {
-					@Override
-					public boolean apply(@NonNull FileHeader type) {
-						return TestUtil.compare(type.getFileName(), Tags.SETTINGS_FILE_NAME);
-					}
-				});
-
+				// The restore settings file contains the data backup format version
 				this.restoreSettings = new RestoreSettings();
+				FileHeader settingsHeader = Functional.select(
+					fileHeaders,
+					type -> TestUtil.compare(type.getFileName(), Tags.SETTINGS_FILE_NAME)
+				);
 				if (settingsHeader != null) {
 					try (InputStream inputStream = zipFile.getInputStream(settingsHeader);
 					     InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
@@ -451,27 +461,19 @@ public class RestoreService extends Service {
 
 				// Restore the identity
 				logger.info("Restoring identity");
-				FileHeader identityHeader = Functional.select(fileHeaders, new IPredicateNonNull<FileHeader>() {
-					@Override
-					public boolean apply(@NonNull FileHeader type) {
-						return TestUtil.compare(type.getFileName(), Tags.IDENTITY_FILE_NAME);
-					}
-				});
+				FileHeader identityHeader = Functional.select(
+					fileHeaders,
+					type -> TestUtil.compare(type.getFileName(), Tags.IDENTITY_FILE_NAME)
+				);
 				if (identityHeader != null && this.writeToDb) {
-					//restore identity first!!
-
 					String identityContent;
 					try (InputStream inputStream = zipFile.getInputStream(identityHeader)) {
 						identityContent = IOUtils.toString(inputStream);
 					}
 
-					if (threemaConnection != null && threemaConnection.isRunning()) {
-						threemaConnection.stop();
-					}
-
 					try {
 						if (!userService.restoreIdentity(identityContent, this.password)) {
-							throw new ThreemaException("failed");
+							throw new ThreemaException("Restoring identity failed");
 						}
 					} catch (UnknownHostException e) {
 						throw e;
@@ -504,12 +506,12 @@ public class RestoreService extends Service {
 					//continue anyway!
 				}
 
-				updateProgress(STEP_SIZE_GRPOUP_AVATARS);
+				updateProgress(STEP_SIZE_GROUP_AVATARS);
 
 				logger.info("Restoring message media files");
 				mediaCount = this.restoreMessageMediaFiles(fileHeaders);
 				if (mediaCount == 0) {
-					logger.error("restore message media files failed");
+					logger.warn("No media files restored. Might be a backup without media?");
 					//continue anyway!
 				} else {
 					logger.info("{} media files found", mediaCount);
@@ -539,10 +541,10 @@ public class RestoreService extends Service {
 		} catch (RestoreCanceledException e) {
 			logger.error("Restore cancelled", e);
 			message = getString(R.string.restore_data_cancelled);
-		} catch (Exception x) {
+		} catch (Exception e) {
 			// wrong password? no connection? throw
-			logger.error("Exception while restoring backup", x);
-			message = x.getMessage();
+			logger.error("Exception while restoring backup", e);
+			message = e.getMessage();
 		}
 
 		onFinished(message);
@@ -604,13 +606,13 @@ public class RestoreService extends Service {
 			String fileName = fileHeader.getFileName();
 			if (fileName.startsWith(Tags.CONTACT_AVATAR_FILE_PREFIX)) {
 				if(!this.restoreContactAvatarFile(fileHeader)) {
-					logger.error("restore contact avatar " + fileName + " file failed or skipped");
+					logger.error("restore contact avatar {} file failed or skipped", fileName);
 					//continue anyway
 				}
 			}
 			else if (fileName.startsWith(Tags.CONTACT_PROFILE_PIC_FILE_PREFIX)) {
 				if(!this.restoreContactPhotoFile(fileHeader)) {
-					logger.error("restore contact profile pic " + fileName + " file failed or skipped");
+					logger.error("restore contact profile pic {} file failed or skipped", fileName);
 					//continue anyway
 				}
 			}
@@ -797,7 +799,7 @@ public class RestoreService extends Service {
 		return count;
 	}
 
-	private boolean restoreContactFile(FileHeader fileHeader) throws IOException, RestoreCanceledException {
+	private boolean restoreContactFile(@NonNull FileHeader fileHeader) throws IOException, RestoreCanceledException {
 		return this.processCsvFile(fileHeader, new ProcessCsvFile() {
 			@Override
 			public void row(CSVRow row) {
@@ -810,6 +812,7 @@ public class RestoreService extends Service {
 						restoreResult.incContactSuccess();
 					}
 				} catch (Exception x) {
+					logger.error("Could not restore contact", x);
 					if (writeToDb) {
 						//process next
 						restoreResult.incContactFailed();
@@ -819,65 +822,68 @@ public class RestoreService extends Service {
 		});
 	}
 
-	private boolean restoreContactAvatarFile(FileHeader fileHeader){
-		if(fileHeader != null) {
-//				fileHeader.getFileName().startsWith(Tags.CONTACT_AVATAR_FILE_PREFIX)) {
-			String filename = fileHeader.getFileName();
-			if(!TestUtil.empty(filename)) {
-				String identity = filename.substring(Tags.CONTACT_AVATAR_FILE_PREFIX.length());
-				if (!TestUtil.empty(identity)) {
-					ContactModel contactModel = contactService.getByIdentity(identity);
-					if (contactModel != null) {
-						try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
-							boolean success = fileService.writeContactAvatar(
-								contactModel,
-								IOUtils.toByteArray(inputStream));
-
-							if (contactModel.getIdentity().equals(contactService.getMe().getIdentity())) {
-								preferenceService.setProfilePicLastUpdate(new Date());
-							}
-							return success;
-						} catch (Exception e) {
-							logger.error("Exception", e);
-							//ignore, its only an avatar
-						} finally {
-							//
-							;
-						}
-					}
-				}
+	private boolean restoreContactAvatarFile(@NonNull FileHeader fileHeader){
+		// Look up avatar filename
+		String filename = fileHeader.getFileName();
+		if (TestUtil.empty(filename)) {
+			return false;
+		}
+
+		// Look up contact model for this avatar
+		String identity = filename.substring(Tags.CONTACT_AVATAR_FILE_PREFIX.length());
+		if (TestUtil.empty(identity)) {
+			return false;
+		}
+		ContactModel contactModel = contactService.getByIdentity(identity);
+		if (contactModel == null) {
+			return false;
+		}
+
+		// Set contact avatar
+		try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
+			boolean success = fileService.writeContactAvatar(
+				contactModel,
+				IOUtils.toByteArray(inputStream)
+			);
+			if (contactModel.getIdentity().equals(contactService.getMe().getIdentity())) {
+				preferenceService.setProfilePicLastUpdate(new Date());
 			}
+			return success;
+		} catch (Exception e) {
+			logger.error("Exception while writing contact avatar", e);
+			return false;
 		}
-		return false;
 	}
 
-	private boolean restoreContactPhotoFile(FileHeader fileHeader){
-		if(fileHeader != null) {
-			String filename = fileHeader.getFileName();
-			if(!TestUtil.empty(filename)) {
-				String identity = filename.substring(Tags.CONTACT_PROFILE_PIC_FILE_PREFIX.length());
-				if (!TestUtil.empty(identity)) {
-					ContactModel contactModel = contactService.getByIdentity(identity);
-					if (contactModel != null) {
-						try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
-							return fileService.writeContactPhoto(
-								contactModel,
-								IOUtils.toByteArray(inputStream));
-						} catch (Exception e) {
-							logger.error("Exception", e);
-							//ignore, its only an avatar
-						} finally {
-							//
-							;
-						}
-					}
-				}
-			}
+	private boolean restoreContactPhotoFile(@NonNull FileHeader fileHeader){
+		// Look up profile picture filename
+		String filename = fileHeader.getFileName();
+		if(TestUtil.empty(filename)) {
+			return false;
 		}
 
-		return false;
+		// Look up contact model for this avatar
+		String identity = filename.substring(Tags.CONTACT_PROFILE_PIC_FILE_PREFIX.length());
+		if (TestUtil.empty(identity)) {
+			return false;
+		}
+		ContactModel contactModel = contactService.getByIdentity(identity);
+		if (contactModel == null) {
+			return false;
+		}
+
+		// Set contact profile picture
+		try (ZipInputStream inputStream = zipFile.getInputStream(fileHeader)) {
+			return fileService.writeContactPhoto(
+				contactModel,
+				IOUtils.toByteArray(inputStream));
+		} catch (Exception e) {
+			logger.error("Exception while writing contact profile picture", e);
+			return false;
+		}
 	}
-	private boolean restoreGroupFile(FileHeader fileHeader) throws IOException, RestoreCanceledException {
+
+	private boolean restoreGroupFile(@NonNull FileHeader fileHeader) throws IOException, RestoreCanceledException {
 		return this.processCsvFile(fileHeader, new ProcessCsvFile() {
 			@Override
 			public void row(CSVRow row) {
@@ -907,6 +913,7 @@ public class RestoreService extends Service {
 						}
 					}
 				} catch (Exception x) {
+					logger.error("Could not restore group", x);
 					if (writeToDb) {
 						//process next
 						restoreResult.incContactFailed();
@@ -916,7 +923,7 @@ public class RestoreService extends Service {
 		});
 	}
 
-	private boolean restoreDistributionListFile(FileHeader fileHeader) throws IOException, RestoreCanceledException {
+	private boolean restoreDistributionListFile(@NonNull FileHeader fileHeader) throws IOException, RestoreCanceledException {
 		return this.processCsvFile(fileHeader, new ProcessCsvFile() {
 			@Override
 			public void row(CSVRow row) {
@@ -939,6 +946,7 @@ public class RestoreService extends Service {
 						}
 					}
 				} catch (Exception x) {
+					logger.error("Could not restore distribution list", x);
 					if (writeToDb) {
 						//process next
 						restoreResult.incContactFailed();
@@ -948,9 +956,11 @@ public class RestoreService extends Service {
 		});
 	}
 
-	private void restoreBallotFile(FileHeader ballotMain,
-									  final FileHeader ballotChoice,
-									  FileHeader ballotVote) throws IOException, RestoreCanceledException {
+	private void restoreBallotFile(
+		@NonNull FileHeader ballotMain,
+		@NonNull final FileHeader ballotChoice,
+		@NonNull FileHeader ballotVote
+	) throws IOException, RestoreCanceledException {
 		this.processCsvFile(ballotMain, new ProcessCsvFile() {
 			@Override
 			public void row(CSVRow row) {
@@ -989,6 +999,7 @@ public class RestoreService extends Service {
 					}
 
 				} catch (Exception x) {
+					logger.error("Could not restore ballot", x);
 					if (writeToDb) {
 						//process next
 						restoreResult.incContactFailed();
@@ -1049,6 +1060,12 @@ public class RestoreService extends Service {
 		if(restoreSettings.getVersion() >= 14) {
 			groupModel.setArchived(row.getBoolean(Tags.TAG_GROUP_ARCHIVED));
 		}
+
+		if (restoreSettings.getVersion() >= 17) {
+			groupModel.setGroupDesc(row.getString(Tags.TAG_GROUP_DESC));
+			groupModel.setGroupDescTimestamp(row.getDate(Tags.TAG_GROUP_DESC_TIMESTAMP));
+		}
+
 		return groupModel;
 	}
 
@@ -1400,6 +1417,9 @@ public class RestoreService extends Service {
 		if(restoreSettings.getVersion() >= 14) {
 			contactModel.setArchived(row.getBoolean(Tags.TAG_CONTACT_ARCHIVED));
 		}
+		if(restoreSettings.getVersion() >= 18) {
+			contactModel.setForwardSecurityEnabled(row.getBoolean(Tags.TAG_CONTACT_FORWARD_SECURITY));
+		}
 		contactModel.setIsRestored(true);
 
 		return contactModel;
@@ -1431,6 +1451,8 @@ public class RestoreService extends Service {
 			state = MessageState.SENT;
 		} else if (messageState.equals(MessageState.CONSUMED.name())) {
 			state = MessageState.CONSUMED;
+		} else if (messageState.equals(MessageState.FS_KEY_MISMATCH.name())) {
+			state = MessageState.FS_KEY_MISMATCH;
 		}
 
 		messageModel.setState(state);
@@ -1469,6 +1491,9 @@ public class RestoreService extends Service {
 		} else if (typeAsString.equals(MessageType.VOIP_STATUS.name())) {
 			messageType = MessageType.VOIP_STATUS;
 			messageContentsType = MessageContentsType.VOIP_STATUS;
+		} else if (typeAsString.equals(MessageType.GROUP_CALL_STATUS.name())) {
+			messageType = MessageType.GROUP_CALL_STATUS;
+			messageContentsType = MessageContentsType.GROUP_CALL_STATUS;
 		}
 		messageModel.setType(messageType);
 		messageModel.setMessageContentsType(messageContentsType);
@@ -1544,6 +1569,17 @@ public class RestoreService extends Service {
 			messageModel.setDeliveredAt(row.getDate(Tags.TAG_MESSAGE_DELIVERED_AT));
 			messageModel.setReadAt(row.getDate(Tags.TAG_MESSAGE_READ_AT));
 		}
+		if (restoreSettings.getVersion() >= 17) {
+			String messageStatesJson = row.getString(Tags.TAG_GROUP_MESSAGE_STATES);
+			if (!TestUtil.empty(messageStatesJson)) {
+				try {
+					Map<String, Object> messageStatesMap = JsonUtil.convertObject(messageStatesJson);
+					messageModel.setGroupMessageStates(messageStatesMap);
+				} catch (JSONException ignored) {
+					// map may not be available, empty or invalid
+				}
+			}
+		}
 		return messageModel;
 	}
 
@@ -1571,11 +1607,10 @@ public class RestoreService extends Service {
 		return messageModel;
 	}
 
-	private boolean processCsvFile(FileHeader fileHeader, ProcessCsvFile processCsvFile) throws IOException, RestoreCanceledException {
-		if (processCsvFile == null) {
-			return false;
-		}
-
+	private boolean processCsvFile(
+		@NonNull FileHeader fileHeader,
+		@NonNull ProcessCsvFile processCsvFile
+	) throws IOException, RestoreCanceledException {
 		try (ZipInputStream inputStream = this.zipFile.getInputStream(fileHeader);
 		     InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
 		     CSVReader csvReader = new CSVReader(inputStreamReader, true)) {
@@ -1676,9 +1711,9 @@ public class RestoreService extends Service {
 		cancelIntent.putExtra(EXTRA_ID_CANCEL, true);
 		PendingIntent cancelPendingIntent;
 		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
-			cancelPendingIntent = PendingIntent.getForegroundService(this, (int) System.currentTimeMillis(), cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+			cancelPendingIntent = PendingIntent.getForegroundService(this, (int) System.currentTimeMillis(), cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
 		} else {
-			cancelPendingIntent = PendingIntent.getService(this, (int) System.currentTimeMillis(), cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+			cancelPendingIntent = PendingIntent.getService(this, (int) System.currentTimeMillis(), cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
 		}
 
 		notificationBuilder = new NotificationBuilderWrapper(this, NOTIFICATION_CHANNEL_BACKUP_RESTORE_IN_PROGRESS, null)
@@ -1751,7 +1786,7 @@ public class RestoreService extends Service {
 		if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
 			// Android Q does not allow restart in the background
 			Intent backupIntent = new Intent(this, HomeActivity.class);
-			PendingIntent pendingIntent = PendingIntent.getActivity(this, (int)System.currentTimeMillis(), backupIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+			PendingIntent pendingIntent = PendingIntent.getActivity(this, (int)System.currentTimeMillis(), backupIntent, PendingIntent.FLAG_UPDATE_CURRENT | PENDING_INTENT_FLAG_IMMUTABLE);
 
 			builder.setContentIntent(pendingIntent);
 

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

@@ -36,8 +36,10 @@ public class RestoreSettings {
 	 * 13: add hidden flag to contacts
 	 * 15: add quoted message id to messages
 	 * 16: added read and delivered date
+	 * 17: group message states (ack / dec) and group descriptions
+	 * 18: contact forward security flag
 	 */
-	public static final int CURRENT_VERSION = 16;
+	public static final int CURRENT_VERSION = 18;
 	private int version = 1;
 
 	public RestoreSettings(int version) {

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

@@ -60,6 +60,7 @@ public abstract class Tags {
 	public static final String TAG_CONTACT_THREEMA_ANDROID_CONTACT_ID = "tacid";
 	public static final String TAG_CONTACT_HIDDEN = "hidden";
 	public static final String TAG_CONTACT_ARCHIVED = "archived";
+	public static final String TAG_CONTACT_FORWARD_SECURITY = "forward_security";
 
 	public static final String TAG_GROUP_ID = "id";
 	public static final String TAG_GROUP_CREATOR = "creator";
@@ -68,6 +69,8 @@ public abstract class Tags {
 	public static final String TAG_GROUP_MEMBERS = "members";
 	public static final String TAG_GROUP_DELETED = "deleted";
 	public static final String TAG_GROUP_ARCHIVED = "archived";
+	public static final String TAG_GROUP_DESC = "groupDesc";
+	public static final String TAG_GROUP_DESC_TIMESTAMP = "groupDescTimestamp";
 
 	public static final String TAG_MESSAGE_UID = "uid";
 	public static final String TAG_MESSAGE_IDENTITY = "identity";
@@ -82,6 +85,7 @@ public abstract class Tags {
 	public static final String TAG_MESSAGE_MODIFIED_AT = "modified_at";
 	public static final String TAG_MESSAGE_DELIVERED_AT = "delivered_at";
 	public static final String TAG_MESSAGE_READ_AT = "read_at";
+	public static final String TAG_GROUP_MESSAGE_STATES = "g_msg_states";
 
 	public static final String TAG_MESSAGE_MESSAGE_STATE = "messagestae";
 	public static final String TAG_MESSAGE_IS_STATUS_MESSAGE = "isstatusmessage";

+ 40 - 28
app/src/main/java/ch/threema/app/camera/CameraFragment.kt

@@ -70,17 +70,11 @@ import java.io.File
 import java.util.concurrent.ExecutorService
 import java.util.concurrent.Executors
 import kotlin.math.floor
+import kotlin.math.min
 
 class CameraFragment : Fragment() {
     private val logger = LoggingUtil.getThreemaLogger("CameraFragment")
 
-    private val ASPECT_RATIO_16_9 = Rational(16, 9)
-    private val ASPECT_RATIO_9_16 = Rational(9, 16)
-    private val PERMISSION_REQUEST_CODE_AUDIO = 869
-    private val PERMISSION_REQUEST_CODE_CAMERA = 868
-    private val RECORDING_MODE_IMAGE = 0
-    private val RECORDING_MODE_VIDEO = 1
-
     private lateinit var viewModel: CameraFragmentViewModel
 
     private var displayId: Int = -1
@@ -280,7 +274,7 @@ class CameraFragment : Fragment() {
         preferenceService = ThreemaApplication.getServiceManager()?.preferenceService
         mediaActionSound = LessObnoxiousMediaActionSound()
         mediaActionSound?.load(LessObnoxiousMediaActionSound.SHUTTER_CLICK)
-        scaleGestureDetector = ScaleGestureDetector(context, scaleGestureListener)
+        scaleGestureDetector = ScaleGestureDetector(requireContext(), scaleGestureListener)
     }
 
     override fun onAttach(context: Context) {
@@ -334,12 +328,12 @@ class CameraFragment : Fragment() {
             // Select lensFacing depending on the available cameras
             if (viewModel.lensFacing == CameraSelector.LENS_FACING_BACK && !hasBackCamera()) {
                 // try front camera
-                viewModel.lensFacing = CameraSelector.LENS_FACING_FRONT;
+                viewModel.lensFacing = CameraSelector.LENS_FACING_FRONT
             }
 
             if (viewModel.lensFacing == CameraSelector.LENS_FACING_FRONT && !hasFrontCamera()) {
                 if (hasBackCamera()) {
-                    viewModel.lensFacing = CameraSelector.LENS_FACING_BACK;
+                    viewModel.lensFacing = CameraSelector.LENS_FACING_BACK
                 } else {
                     Toast.makeText(context, R.string.no_camera_installed, Toast.LENGTH_SHORT).show()
                     logger.info("Back and front camera are unavailable")
@@ -459,10 +453,10 @@ class CameraFragment : Fragment() {
 
             // A variable number of use-cases can be passed here -
             // camera provides access to CameraControl & CameraInfo
-            if (videoCapture != null) {
-                camera = cameraProvider.bindToLifecycle(this, cameraSelector, preview, videoCapture)
+            camera = if (videoCapture != null) {
+                cameraProvider.bindToLifecycle(this, cameraSelector, preview, videoCapture)
             } else {
-                camera = cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageCapture)
+                cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageCapture)
             }
 
             // Attach the viewfinder's surface provider to preview use case
@@ -494,12 +488,16 @@ class CameraFragment : Fragment() {
                     @FlashMode val flashMode: Int = imageCapture?.flashMode ?: 0
                     it.post {
                         it.visibility = View.VISIBLE
-                        if (flashMode == ImageCapture.FLASH_MODE_AUTO) {
-                            it.setImageResource(R.drawable.ic_flash_auto_outline)
-                        } else if (flashMode == ImageCapture.FLASH_MODE_ON) {
-                            it.setImageResource(R.drawable.ic_flash_on_outline)
-                        } else if (flashMode == ImageCapture.FLASH_MODE_OFF) {
-                            it.setImageResource(R.drawable.ic_flash_off_outline)
+                        when (flashMode) {
+                            ImageCapture.FLASH_MODE_AUTO -> {
+                                it.setImageResource(R.drawable.ic_flash_auto_outline)
+                            }
+                            ImageCapture.FLASH_MODE_ON -> {
+                                it.setImageResource(R.drawable.ic_flash_on_outline)
+                            }
+                            ImageCapture.FLASH_MODE_OFF -> {
+                                it.setImageResource(R.drawable.ic_flash_off_outline)
+                            }
                         }
                     }
                     return
@@ -694,7 +692,7 @@ class CameraFragment : Fragment() {
                                             it.postDelayed({
                                                 if (isAdded && !isDetached) {
                                                     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) it.foreground = null
-                                                    progressBar?.visibility = View.VISIBLE;
+                                                    progressBar?.visibility = View.VISIBLE
                                                 }
                                             }, 50L)
                                             mediaActionSound?.play(LessObnoxiousMediaActionSound.SHUTTER_CLICK)
@@ -793,12 +791,16 @@ class CameraFragment : Fragment() {
 
     private fun switchFlash() {
         @FlashMode var flashMode: Int = imageCapture?.flashMode ?: ImageCapture.FLASH_MODE_OFF
-        if (flashMode == ImageCapture.FLASH_MODE_AUTO) {
-            flashMode = ImageCapture.FLASH_MODE_ON
-        } else if (flashMode == ImageCapture.FLASH_MODE_ON) {
-            flashMode = ImageCapture.FLASH_MODE_OFF
-        } else if (flashMode == ImageCapture.FLASH_MODE_OFF) {
-            flashMode = ImageCapture.FLASH_MODE_AUTO
+        when (flashMode) {
+            ImageCapture.FLASH_MODE_AUTO -> {
+                flashMode = ImageCapture.FLASH_MODE_ON
+            }
+            ImageCapture.FLASH_MODE_ON -> {
+                flashMode = ImageCapture.FLASH_MODE_OFF
+            }
+            ImageCapture.FLASH_MODE_OFF -> {
+                flashMode = ImageCapture.FLASH_MODE_AUTO
+            }
         }
         imageCapture?.flashMode = flashMode
         preferenceService?.cameraFlashMode = flashMode + 1
@@ -859,14 +861,15 @@ class CameraFragment : Fragment() {
     }
 
     private fun setTargetResolution(width: Int, height: Int) {
-        targetHeight = Math.min(height, CameraConfig.getDefaultImageSize())
-        targetWidth = Math.min(width, CameraConfig.getDefaultImageSize())
+        targetHeight = min(height, CameraConfig.getDefaultImageSize())
+        targetWidth = min(width, CameraConfig.getDefaultImageSize())
     }
 
     private fun restart() {
         requireActivity().recreate()
     }
 
+    @Deprecated("Deprecated in Java")
     override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
         super.onRequestPermissionsResult(requestCode, permissions, grantResults)
         if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
@@ -891,4 +894,13 @@ class CameraFragment : Fragment() {
     internal interface CameraConfiguration {
         val videoEnable: Boolean
     }
+
+    companion object {
+        private val ASPECT_RATIO_16_9 = Rational(16, 9)
+        private val ASPECT_RATIO_9_16 = Rational(9, 16)
+        private const val PERMISSION_REQUEST_CODE_AUDIO = 869
+        private const val PERMISSION_REQUEST_CODE_CAMERA = 868
+        private const val RECORDING_MODE_IMAGE = 0
+        private const val RECORDING_MODE_VIDEO = 1
+    }
 }

+ 2 - 2
app/src/main/java/ch/threema/app/camera/QRCodeAnalyer.kt

@@ -30,7 +30,7 @@ import com.google.zxing.*
 import com.google.zxing.common.HybridBinarizer
 import java.nio.ByteBuffer
 
-class QRCodeAnalyzer(private val onDecodeQRCode: (decodeQRCodeState: DecodeQRCodeState) -> Unit, ) : ImageAnalysis.Analyzer {
+class QRCodeAnalyzer(private val onDecodeQRCode: (decodeQRCodeState: DecodeQRCodeState) -> Unit) : ImageAnalysis.Analyzer {
 
     private val logger = LoggingUtil.getThreemaLogger("QRCodeAnalyzer")
 
@@ -38,7 +38,7 @@ class QRCodeAnalyzer(private val onDecodeQRCode: (decodeQRCodeState: DecodeQRCod
         listOf(ImageFormat.YUV_420_888, ImageFormat.YUV_422_888, ImageFormat.YUV_444_888)
     } else {
         listOf(ImageFormat.YUV_420_888)
-    };
+    }
     private val reader = MultiFormatReader().apply {
         setHints(mapOf(
                 DecodeHintType.POSSIBLE_FORMATS to arrayListOf(BarcodeFormat.QR_CODE),

+ 5 - 5
app/src/main/java/ch/threema/app/camera/QRScannerActivity.kt

@@ -62,7 +62,7 @@ class QRScannerActivity : ThreemaActivity() {
     private lateinit var cameraPreviewContainer: View
 
     private var hint: String = ""
-    @QRCodeColor private var qrColor = QR_TYPE_ANY;
+    @QRCodeColor private var qrColor = QR_TYPE_ANY
 
     private var camera: Camera? = null
     private var preview: Preview? = null
@@ -94,10 +94,10 @@ class QRScannerActivity : ThreemaActivity() {
         qrColor = intent.getIntExtra(KEY_QR_TYPE, QR_TYPE_ANY)
 
         if (hint.isEmpty()) {
-            if (intent.hasExtra(KEY_HINT_TEXT)) {
-                hint = intent.getStringExtra(KEY_HINT_TEXT)!!
+            hint = if (intent.hasExtra(KEY_HINT_TEXT)) {
+                intent.getStringExtra(KEY_HINT_TEXT)!!
             } else {
-                hint = getString(R.string.msg_default_status)
+                getString(R.string.msg_default_status)
             }
         }
         findViewById<TextView>(R.id.hint_view).text = hint
@@ -122,7 +122,7 @@ class QRScannerActivity : ThreemaActivity() {
 
     private fun setUpCamera() {
         val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
-        cameraProviderFuture.addListener(Runnable {
+        cameraProviderFuture.addListener({
 
             // CameraProvider
             cameraProvider = cameraProviderFuture.get()

+ 167 - 17
app/src/main/java/ch/threema/app/camera/VideoEditView.java

@@ -21,6 +21,8 @@
 
 package ch.threema.app.camera;
 
+import static com.google.android.exoplayer2.C.TIME_END_OF_SOURCE;
+
 import android.annotation.SuppressLint;
 import android.content.Context;
 import android.graphics.Bitmap;
@@ -30,12 +32,14 @@ import android.graphics.DashPathEffect;
 import android.graphics.Paint;
 import android.graphics.Path;
 import android.graphics.Rect;
+import android.media.AudioManager;
 import android.os.Handler;
 import android.os.Looper;
 import android.text.format.Formatter;
 import android.util.AttributeSet;
 import android.view.LayoutInflater;
 import android.view.MotionEvent;
+import android.view.View;
 import android.view.ViewGroup;
 import android.widget.FrameLayout;
 import android.widget.GridLayout;
@@ -44,6 +48,7 @@ import android.widget.TextView;
 
 import androidx.annotation.MainThread;
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
 import androidx.core.view.ViewCompat;
 import androidx.lifecycle.DefaultLifecycleObserver;
@@ -61,8 +66,12 @@ import java.io.File;
 import java.util.ArrayList;
 import java.util.List;
 
+import androidx.media.AudioAttributesCompat;
+import androidx.media.AudioFocusRequestCompat;
+import androidx.media.AudioManagerCompat;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
+import ch.threema.app.services.PreferenceService;
 import ch.threema.app.ui.MediaItem;
 import ch.threema.app.utils.FileUtil;
 import ch.threema.app.utils.LocaleUtil;
@@ -71,8 +80,6 @@ import ch.threema.app.utils.VideoUtil;
 import ch.threema.app.video.VideoTimelineThumbnailTask;
 import ch.threema.base.utils.LoggingUtil;
 
-import static com.google.android.exoplayer2.C.TIME_END_OF_SOURCE;
-
 public class VideoEditView extends FrameLayout implements DefaultLifecycleObserver, VideoTimelineThumbnailTask.VideoTimelineListener {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("VideoEditView");
 
@@ -83,10 +90,13 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 	private static final int MARKER_MOVE_MESSAGE_QUEUE_ID = 771294;
 	private static final int MARKER_MOVE_UPDATE_FREQUENCY_MS = 200;
 
+	private static final float VOLUME_HIGH = 1f;
+	private static final float VOLUME_MUTED = 0f;
+
 	private Context context;
 	private int targetHeight, calculatedWidth;
 	private Paint borderPaint, arrowPaint, dashPaint, progressPaint, dimPaint;
-	private int arrowWidth, arrowHeight, borderWidth;
+	private int arrowWidth, arrowHeight;
 	private int offsetLeft = 0, offsetRight = 0, touchTargetWidth;
 	private long videoCurrentPosition = 0L, videoFileSize = 0L, clippedStartTimeMs, clippedEndTimeMs;
 	private MediaItem videoItem;
@@ -97,12 +107,16 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 	private ExoPlayer videoPlayer;
 	private com.google.android.exoplayer2.MediaItem videoSourceMediaItem;
 	private DefaultMediaSourceFactory mediaSourceFactory;
-
+	private View startContainer, endContainer, sizeContainer;
 	private TextView startTimeTextView, endTimeTextView, sizeTextView;
 	private Thread thumbnailThread;
 	private final Handler progressHandler = new Handler();
 	private final Handler markerMoveHandler = new Handler(Looper.getMainLooper());
 	private final List<Rect> exclusionRects = new ArrayList<>();
+	private OnTimelineDragListener timelineDragListener;
+	private int numThumbnailsShown = -1;
+	private final AudioManager audioManager;
+	private final AudioFocusRequestCompat audioFocusRequest;
 
 	public VideoEditView(Context context) {
 		this(context, null);
@@ -115,6 +129,17 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 	public VideoEditView(Context context, AttributeSet attrs, int defStyleAttr) {
 		super(context, attrs, defStyleAttr);
 		init(context);
+
+		audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
+		audioFocusRequest = new AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
+			.setAudioAttributes(new AudioAttributesCompat.Builder()
+				.setContentType(AudioAttributesCompat.CONTENT_TYPE_MOVIE)
+				.build()
+			).setOnAudioFocusChangeListener(focusChange -> {
+				if (videoPlayer != null && (focusChange == AudioManager.AUDIOFOCUS_LOSS || focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)) {
+					videoPlayer.pause();
+				}
+			}).build();
 	}
 
 	private void init(Context context) {
@@ -122,7 +147,7 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 		this.targetHeight = context.getResources().getDimensionPixelSize(R.dimen.video_timeline_item_size);
 		this.arrowWidth = context.getResources().getDimensionPixelSize(R.dimen.video_timeline_arrow_width);
 		this.arrowHeight = context.getResources().getDimensionPixelSize(R.dimen.video_timeline_arrow_height);
-		this.borderWidth = context.getResources().getDimensionPixelSize(R.dimen.video_timeline_border_width);
+		int borderWidth = context.getResources().getDimensionPixelSize(R.dimen.video_timeline_border_width);
 		int progressWidth = context.getResources().getDimensionPixelSize(R.dimen.video_timeline_progress_width);
 
 		this.touchTargetWidth = context.getResources().getDimensionPixelSize(R.dimen.video_timeline_touch_target_width);
@@ -135,6 +160,9 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 
 		this.timelineGridLayout = findViewById(R.id.video_timeline);
 		this.videoView = findViewById(R.id.video_view);
+		this.startContainer = findViewById(R.id.start_container);
+		this.endContainer = findViewById(R.id.end_container);
+		this.sizeContainer = findViewById(R.id.size_container);
 		this.startTimeTextView = findViewById(R.id.start);
 		this.endTimeTextView = findViewById(R.id.end);
 		this.sizeTextView = findViewById(R.id.size);
@@ -144,7 +172,7 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 		this.borderPaint.setStyle(Paint.Style.STROKE);
 		this.borderPaint.setColor(Color.WHITE);
 		this.borderPaint.setAntiAlias(true);
-		this.borderPaint.setStrokeWidth(this.borderWidth);
+		this.borderPaint.setStrokeWidth(borderWidth);
 
 		this.dimPaint = new Paint();
 
@@ -158,14 +186,14 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 		this.arrowPaint.setStyle(Paint.Style.FILL_AND_STROKE);
 		this.arrowPaint.setColor(Color.WHITE);
 		this.arrowPaint.setAntiAlias(true);
-		this.arrowPaint.setStrokeWidth(this.borderWidth);
+		this.arrowPaint.setStrokeWidth(borderWidth);
 
 		this.dashPaint = new Paint();
 
 		this.dashPaint.setStyle(Paint.Style.STROKE);
 		this.dashPaint.setColor(Color.WHITE);
 		this.dashPaint.setAntiAlias(true);
-		this.dashPaint.setStrokeWidth(this.borderWidth);
+		this.dashPaint.setStrokeWidth(borderWidth);
 		this.dashPaint.setPathEffect(new DashPathEffect(new float[]{3, 8}, 0));
 
 		this.progressPaint = new Paint();
@@ -174,8 +202,65 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 		this.progressPaint.setColor(Color.WHITE);
 		this.progressPaint.setAntiAlias(true);
 		this.progressPaint.setStrokeWidth(progressWidth);
+	}
+
+	/**
+	 * Set the video source for this player. Note that the video is only displayed if the view is
+	 * currently attached to the window. If there is currently a video shown this is removed and
+	 * replaced with the given video.
+	 *
+	 * @param mediaItem the media item of the video that is displayed
+	 */
+	public void setVideo(@NonNull MediaItem mediaItem) {
+		logger.debug("setVideo");
+		this.videoItem = mediaItem;
+		if (videoPlayer != null) {
+			releasePlayer();
+		}
+		if (isAttachedToWindow()) {
+			logger.debug("showing player from setVideo");
+			initVideoView();
+			displayVideo();
+		} else {
+			logger.warn("Error showing video: video edit view is not attached to window");
+		}
+	}
 
-		initVideoView();
+	/**
+	 * Releases the video player.
+	 */
+	public void releasePlayer() {
+		if (videoPlayer != null) {
+			videoPlayer.release();
+			videoPlayer = null;
+		}
+	}
+
+	/**
+	 * Mute the player.
+	 */
+	public void mutePlayer() {
+		if (videoPlayer != null) {
+			videoPlayer.setVolume(VOLUME_MUTED);
+		}
+	}
+
+	/**
+	 * Unmute the player.
+	 */
+	public void unmutePlayer() {
+		if (videoPlayer != null) {
+			videoPlayer.setVolume(VOLUME_HIGH);
+		}
+	}
+
+	/**
+	 * Set a timeline drag listener. This can be used to detect when the user is dragging the timeline.
+	 *
+	 * @param listener the timeline drag listener
+	 */
+	public void setOnTimelineDragListener(@Nullable OnTimelineDragListener listener) {
+		this.timelineDragListener = listener;
 	}
 
 	@SuppressLint("ClickableViewAccessibility")
@@ -188,6 +273,16 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 				Player.Listener.super.onPlaybackStateChanged(playbackState);
 				updateProgressBar();
 			}
+
+			@Override
+			public void onIsPlayingChanged(boolean isPlaying) {
+				Player.Listener.super.onIsPlayingChanged(isPlaying);
+				if (isPlaying) {
+					AudioManagerCompat.requestAudioFocus(audioManager, audioFocusRequest);
+				} else {
+					AudioManagerCompat.abandonAudioFocusRequest(audioManager, audioFocusRequest);
+				}
+			}
 		});
 
 		this.videoView.setPlayer(videoPlayer);
@@ -217,6 +312,10 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 	protected void dispatchDraw(Canvas canvas) {
 		super.dispatchDraw(canvas);
 
+		if (videoItem != null && videoItem.getVideoSize() == PreferenceService.VideoSize_SEND_AS_FILE) {
+			return;
+		}
+
 		int left = this.timelineGridLayout.getLeft() + this.offsetLeft;
 		int right = this.timelineGridLayout.getRight() - this.offsetRight;
 		int top = this.timelineGridLayout.getTop();
@@ -301,6 +400,10 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 	@SuppressLint("ClickableViewAccessibility")
 	@Override
 	public boolean onTouchEvent(MotionEvent event) {
+		if (videoItem.getVideoSize() == PreferenceService.VideoSize_SEND_AS_FILE) {
+			return super.onTouchEvent(event);
+		}
+
 		int action = event.getAction();
 		int x = (int) event.getX();
 		int y = (int) event.getY();
@@ -314,6 +417,9 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 				clippedEndTimeMs = videoItem.getEndTimeMs();
 
 				if (y >= (this.timelineGridLayout.getTop() - arrowHeight) && y <= (this.timelineGridLayout.getBottom() + arrowHeight)) {
+					if (timelineDragListener != null) {
+						timelineDragListener.onTimelineDragStart();
+					}
 					if (x >= (left - touchTargetWidth) && x <= (left + touchTargetWidth)) {
 						logger.debug("start moving left: {}", x);
 						isMoving = MOVING_LEFT;
@@ -384,7 +490,9 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 			case MotionEvent.ACTION_CANCEL:
 			case MotionEvent.ACTION_UP:
 				markerMoveHandler.removeCallbacksAndMessages(null);
-
+				if (timelineDragListener != null) {
+					timelineDragListener.onTimelineDragStop();
+				}
 				if (isMoving == MOVING_LEFT || isMoving == MOVING_RIGHT) {
 					videoItem.setStartTimeMs(getVideoPositionFromTimelinePosition(offsetLeft));
 					videoItem.setEndTimeMs(getVideoPositionFromTimelinePosition(this.timelineGridLayout.getWidth() - offsetRight));
@@ -397,20 +505,30 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 					return true;
 				}
 		}
-		return super.onTouchEvent(event);
+		return false;
 	}
 
 	@SuppressLint("StaticFieldLeak")
 	@UiThread
-	public void setVideo(MediaItem mediaItem) {
+	public void displayVideo() {
 		int numColumns = calculateNumColumns();
+		if (numColumns != numThumbnailsShown || isThumbnailBitmapMissing()) {
+			updateTimelineThumbnails(numColumns);
+		}
+
+		if (isAttachedToWindow() && context != null) {
+			videoSourceMediaItem = com.google.android.exoplayer2.MediaItem.fromUri(videoItem.getUri());
+			preparePlayer();
+		}
+
+		updateVideoTimelineVisibility();
+	}
 
+	private void updateTimelineThumbnails(int numColumns) {
 		if (numColumns <= 0 || numColumns > 64) {
 			numColumns = GridLayout.UNDEFINED;
 		}
 
-		this.videoItem = mediaItem;
-
 		if (thumbnailThread != null && thumbnailThread.isAlive()) {
 			thumbnailThread.interrupt();
 		}
@@ -464,12 +582,36 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 		}
 	}
 
+	private boolean isThumbnailBitmapMissing() {
+		if (numThumbnailsShown < 0) {
+			return true;
+		}
+		for (int i = 0; i < numThumbnailsShown; i++) {
+			ImageView imageView = findViewWithTag(i);
+			if (imageView != null && imageView.getDrawable() == null) {
+				return true;
+			}
+		}
+		return false;
+	}
+
+	private void updateVideoTimelineVisibility() {
+		int visibility = videoItem.getVideoSize() == PreferenceService.VideoSize_SEND_AS_FILE ? INVISIBLE : VISIBLE;
+
+		timelineGridLayout.setVisibility(visibility);
+		startContainer.setVisibility(visibility);
+		endContainer.setVisibility(visibility);
+		sizeContainer.setVisibility(visibility);
+
+		requestLayout();
+	}
+
 	private void preparePlayer() {
 		if (videoPlayer != null && videoSourceMediaItem != null) {
 			long startPosition = videoItem.getStartTimeMs() * 1000;
 			long endPosition = (videoItem.getEndTimeMs() == videoItem.getDurationMs() || videoItem.getEndTimeMs() == 0 || videoItem.getEndTimeMs() == MediaItem.TIME_UNDEFINED) ?
-							TIME_END_OF_SOURCE :
-							videoItem.getEndTimeMs() * 1000;
+				TIME_END_OF_SOURCE :
+				videoItem.getEndTimeMs() * 1000;
 
 			logger.debug("startPosition: " + startPosition + " endPosition: " + endPosition);
 
@@ -487,6 +629,9 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 			videoPlayer.setMediaSource(clippingSource);
 			videoPlayer.setPlayWhenReady(false);
 			videoPlayer.prepare();
+			if (videoItem.isMuted()) {
+				videoPlayer.setVolume(VOLUME_MUTED);
+			}
 		}
 	}
 
@@ -530,7 +675,7 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 		progressHandler.removeCallbacks(updateProgressAction);
 		// Schedule an update if necessary.
 		int playbackState = videoPlayer == null ? Player.STATE_IDLE : videoPlayer.getPlaybackState();
-		if (playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED) {
+		if (playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED && isAttachedToWindow()) {
 			long delayMs;
 			if (videoPlayer != null && videoPlayer.getPlayWhenReady() && playbackState == Player.STATE_READY) {
 				delayMs = 100;
@@ -624,4 +769,9 @@ public class VideoEditView extends FrameLayout implements DefaultLifecycleObserv
 	public void onError(String errorMessage) {
 		logger.info("Unable to get video thumbnails. Reason: {}", errorMessage);
 	}
+
+	public interface OnTimelineDragListener {
+		void onTimelineDragStart();
+		void onTimelineDragStop();
+	}
 }

+ 13 - 10
app/src/main/java/ch/threema/app/dialogs/BottomSheetAbstractDialog.java

@@ -68,16 +68,14 @@ public abstract class BottomSheetAbstractDialog extends BottomSheetDialogFragmen
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
 
-		try {
-			callback = (BottomSheetDialogCallback) getTargetFragment();
-		} catch (ClassCastException e) {
-			//
-		}
-
-		// called from an activity rather than a fragment
 		if (callback == null) {
-			if ((activity instanceof BottomSheetDialogCallback)) {
-				callback = (BottomSheetDialogCallback) activity;
+			try {
+				callback = (BottomSheetDialogCallback) getTargetFragment();
+			} catch (ClassCastException e) {
+				// called from an activity rather than a fragment
+				if ((activity instanceof BottomSheetDialogCallback)) {
+					callback = (BottomSheetDialogCallback) activity;
+				}
 			}
 		}
 	}
@@ -146,7 +144,7 @@ public abstract class BottomSheetAbstractDialog extends BottomSheetDialogFragmen
 					dismiss();
 					if (inlineCallback != null) {
 						inlineCallback.onSelected(items.get(i).getTag());
-					} else {
+					} else if (callback != null) {
 						callback.onSelected(items.get(i).getTag());
 					}
 				}
@@ -184,4 +182,9 @@ public abstract class BottomSheetAbstractDialog extends BottomSheetDialogFragmen
 			inlineCallback.onCancel(this.getTag());
 		}
 	}
+
+	public void setCallback(BottomSheetDialogCallback callback) {
+		this.callback = callback;
+	}
+
 }

+ 95 - 0
app/src/main/java/ch/threema/app/dialogs/CallbackTextEntryDialog.kt

@@ -0,0 +1,95 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2022 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.app.Dialog
+import android.os.Bundle
+import android.view.View
+import android.widget.EditText
+import ch.threema.app.R
+import ch.threema.app.utils.EditTextUtil
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+
+class CallbackTextEntryDialog : ThreemaDialogFragment() {
+
+    private var title: String? = null
+    private var initialText: String? = null
+    private var callback: OnButtonClickedCallback? = null
+    private var editText: EditText? = null
+
+    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+        val dialogView: View? = activity?.layoutInflater?.inflate(R.layout.dialog_text_entry, null)
+        editText = dialogView?.findViewById(R.id.edit_text)
+        editText?.setText(initialText)
+        editText?.setSelection(editText?.text?.length ?: 0)
+        editText?.isFocusable = true
+        editText?.requestFocus()
+
+        val builder = MaterialAlertDialogBuilder(requireActivity())
+        builder.setView(dialogView)
+
+        title?.let {
+            builder.setTitle(it)
+        }
+
+        builder.setPositiveButton(getString(R.string.ok)) { _, _ ->
+            EditTextUtil.hideSoftKeyboard(editText)
+            callback?.onPositiveClicked(editText?.text?.toString() ?: "")
+        }
+
+        builder.setNegativeButton(getString(R.string.cancel)) { _, _ ->
+            EditTextUtil.hideSoftKeyboard(editText)
+            callback?.onNegativeClicked()
+        }
+
+        return builder.create()
+    }
+
+    override fun onResume() {
+        super.onResume()
+
+        // Show keyboard
+        editText?.postDelayed({
+            EditTextUtil.showSoftKeyboard(editText)
+        }, 100)
+    }
+
+    companion object {
+        fun getInstance(
+            title: String?,
+            initialText: String?,
+            onButtonClickedCallback: OnButtonClickedCallback
+        ): CallbackTextEntryDialog {
+            return CallbackTextEntryDialog().also {
+                it.title = title
+                it.initialText = initialText
+                it.callback = onButtonClickedCallback
+            }
+        }
+    }
+
+    interface OnButtonClickedCallback {
+        fun onPositiveClicked(text: String)
+        fun onNegativeClicked()
+    }
+
+}

+ 55 - 0
app/src/main/java/ch/threema/app/dialogs/ExpandableTextEntryDialog.java

@@ -27,6 +27,7 @@ import android.content.DialogInterface;
 import android.os.Bundle;
 import android.text.Editable;
 import android.text.TextWatcher;
+import android.view.KeyEvent;
 import android.view.View;
 import android.view.inputmethod.InputMethodManager;
 import android.widget.EditText;
@@ -38,17 +39,25 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 import com.google.android.material.textfield.TextInputLayout;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.appcompat.app.AlertDialog;
 import androidx.appcompat.app.AppCompatDialog;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
+import ch.threema.app.services.ContactService;
+import ch.threema.app.services.GroupService;
+import ch.threema.app.services.PreferenceService;
+import ch.threema.app.services.UserService;
 import ch.threema.app.ui.ComposeEditText;
 import ch.threema.app.utils.AnimationUtil;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.storage.models.GroupModel;
 
 public class ExpandableTextEntryDialog extends ThreemaDialogFragment {
 	private ExpandableTextEntryDialogClickListener callback;
 	private Activity activity;
+	private ComposeEditText captionEditText;
+	private ComposeEditText.MentionPopupData mentionPopupData;
 	private AlertDialog alertDialog;
 
 	public static ExpandableTextEntryDialog newInstance(String title, int hint, int positive, int negative, boolean expandable) {
@@ -168,6 +177,18 @@ public class ExpandableTextEntryDialog extends ThreemaDialogFragment {
 			public void afterTextChanged(Editable s) {}
 		});
 
+		if (mentionPopupData != null) {
+			this.captionEditText = editText;
+			this.captionEditText.enableMentionPopup(mentionPopupData, editTextContainer);
+			editText.setOnKeyListener((v, keyCode, event) -> {
+				if (keyCode == KeyEvent.KEYCODE_BACK) {
+					ExpandableTextEntryDialog.this.captionEditText.dismissMentionPopup();
+					return true;
+				}
+				return false;
+			});
+		}
+
 		MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(activity,  getTheme());
 		builder.setView(dialogView);
 
@@ -214,6 +235,40 @@ public class ExpandableTextEntryDialog extends ThreemaDialogFragment {
 		return alertDialog;
 	}
 
+	/**
+	 * Enable mention popup on the caption edit text. This needs to be called before the dialog is shown/created.
+	 *
+	 * @param mentionPopupData the required data to enable mentions, if this is null, mentions are not enabled
+	 */
+	public void enableMentionPopup(@Nullable ComposeEditText.MentionPopupData mentionPopupData) {
+		this.mentionPopupData = mentionPopupData;
+	}
+
+	/**
+	 * Enable mention popup on the caption edit text. This needs to be called before the dialog is shown/created.
+	 */
+	public void enableMentionPopup(
+		@NonNull Activity activity,
+		@NonNull GroupService groupService,
+		@NonNull ContactService contactService,
+		@NonNull UserService userService,
+		@NonNull PreferenceService preferenceService,
+		@NonNull GroupModel groupModel
+	) {
+		mentionPopupData = new ComposeEditText.MentionPopupData(
+			activity,
+			groupService,
+			contactService,
+			userService,
+			preferenceService,
+			groupModel
+		);
+	}
+
+	public void dismissMentionPopup() {
+		captionEditText.dismissMentionPopup();
+	}
+
 	private void toggleLayout(ImageView button, View v) {
 		InputMethodManager imm = (InputMethodManager)v.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
 		EditText editText = v.findViewById(R.id.caption_edittext);

+ 9 - 4
app/src/main/java/ch/threema/app/dialogs/GenericAlertDialog.java

@@ -30,13 +30,14 @@ import android.text.method.LinkMovementMethod;
 import android.view.View;
 import android.widget.TextView;
 
-import com.google.android.material.dialog.MaterialAlertDialogBuilder;
-
 import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
 import androidx.appcompat.app.AlertDialog;
 import androidx.appcompat.app.AppCompatDialog;
 import androidx.fragment.app.Fragment;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
 import ch.threema.app.utils.TestUtil;
 
 public class GenericAlertDialog extends ThreemaDialogFragment {
@@ -189,12 +190,16 @@ public class GenericAlertDialog extends ThreemaDialogFragment {
 
 		MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity(), getTheme());
 		if (TestUtil.empty(titleString)) {
-			builder.setTitle(title);
+			if (title != 0) {
+				builder.setTitle(title);
+			}
 		} else {
 			builder.setTitle(titleString);
 		}
 		if (TextUtils.isEmpty(messageString)) {
-			builder.setMessage(message);
+			if (message != 0) {
+				builder.setMessage(message);
+			}
 		} else {
 			if (isHtml) {
 				builder.setMessage(Html.fromHtml(messageString.toString()));

+ 105 - 0
app/src/main/java/ch/threema/app/dialogs/GroupDescEditDialog.java

@@ -0,0 +1,105 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2015-2022 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.os.Bundle;
+import android.view.View;
+import android.widget.EditText;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+import androidx.appcompat.app.AppCompatDialog;
+import ch.threema.app.R;
+import ch.threema.app.utils.EditTextUtil;
+import ch.threema.app.utils.TestUtil;
+
+
+public class GroupDescEditDialog extends ThreemaDialogFragment {
+
+	private static final String ARG_TITLE = "title";
+	private static final String ARG_GROUP_DESC = "groupDesc";
+
+
+	private OnNewGroupDescription callback;
+
+	/**
+	 * Create an EditDialog for a group-description
+	 */
+	public static GroupDescEditDialog newGroupDescriptionInstance(@StringRes int title,
+	                                                              String description, OnNewGroupDescription callback) {
+		final Bundle args = new Bundle();
+		args.putInt(ARG_TITLE, title);
+		args.putString(ARG_GROUP_DESC, description);
+
+		GroupDescEditDialog dialog = new GroupDescEditDialog(callback);
+		dialog.setArguments(args);
+		return dialog;
+	}
+
+	private GroupDescEditDialog(OnNewGroupDescription callback) {
+		this.callback = callback;
+	}
+
+	public interface OnNewGroupDescription {
+		void onNewGroupDescSet(String newGroupDesc);
+	}
+
+	public void setCallback(OnNewGroupDescription callback) {
+		this.callback = callback;
+	}
+
+
+	@NonNull
+	@Override
+	public AppCompatDialog onCreateDialog(Bundle savedInstanceState) {
+		int title = getArguments().getInt(ARG_TITLE);
+		String groupDesc = getArguments().getString(ARG_GROUP_DESC);
+
+		final View dialogView = requireActivity().getLayoutInflater().inflate(R.layout.dialog_group_description_edit, null);
+		final EditText groupDescEditText = dialogView.findViewById(R.id.group_desc_edit_text);
+
+
+		if (!TestUtil.empty(groupDesc)) {
+			groupDescEditText.setText(groupDesc);
+		}
+
+		EditTextUtil.showSoftKeyboard(groupDescEditText);
+
+		MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireActivity(), getTheme());
+
+		if (title != 0) {
+			builder.setTitle(title);
+		}
+
+		builder.setView(dialogView);
+
+		builder.setPositiveButton(getString(R.string.ok), (dialog, whichButton) -> callback.onNewGroupDescSet(groupDescEditText.getText().toString().trim())
+		);
+		builder.setNegativeButton(getString(R.string.cancel), (dialog, whichButton) -> {
+			// do nothing
+		}
+		);
+
+		setCancelable(false);
+		return builder.create();
+	}
+}

+ 279 - 15
app/src/main/java/ch/threema/app/dialogs/MessageDetailDialog.java

@@ -22,41 +22,101 @@
 package ch.threema.app.dialogs;
 
 import android.app.Activity;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.os.AsyncTask;
+import android.os.Build;
 import android.os.Bundle;
 import android.text.format.Formatter;
 import android.view.View;
+import android.widget.ImageView;
 import android.widget.TextView;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.StringRes;
-import androidx.appcompat.app.AlertDialog;
 import androidx.appcompat.app.AppCompatDialog;
+import androidx.core.app.ActivityCompat;
 
+import com.google.android.material.card.MaterialCardView;
+import com.google.android.material.chip.Chip;
+import com.google.android.material.chip.ChipDrawable;
+import com.google.android.material.chip.ChipGroup;
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.divider.MaterialDivider;
 
 import java.util.Date;
+import java.util.List;
+import java.util.Map;
 
 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.listeners.MessageListener;
+import ch.threema.app.managers.ListenerManager;
+import ch.threema.app.services.ContactService;
 import ch.threema.app.services.MessageService;
+import ch.threema.app.stores.IdentityStore;
+import ch.threema.app.ui.CountBoxView;
+import ch.threema.app.utils.AvatarConverterUtil;
+import ch.threema.app.utils.BitmapUtil;
+import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.LocaleUtil;
+import ch.threema.app.utils.NameUtil;
+import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.TextUtil;
+import ch.threema.domain.protocol.csp.messages.fs.ForwardSecurityMode;
 import ch.threema.storage.models.AbstractMessageModel;
+import ch.threema.storage.models.ContactModel;
+import ch.threema.storage.models.DistributionListMessageModel;
 import ch.threema.storage.models.GroupMessageModel;
 import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.MessageType;
 
-public class MessageDetailDialog extends ThreemaDialogFragment {
-	private AlertDialog alertDialog;
+public class MessageDetailDialog extends ThreemaDialogFragment implements View.OnClickListener {
 	private Activity activity;
+	private View dialogView;
+	private ContactService contactService = null;
+	private IdentityStore identityStore = null;
+	private AbstractMessageModel messageModel = null;
+	private final MessageListener messageListener = new MessageListener() {
+		@Override
+		public void onNew(AbstractMessageModel newMessage) {}
+
+		@Override
+		public void onModified(List<AbstractMessageModel> modifiedMessageModels) {
+			if (messageModel != null) {
+				for (AbstractMessageModel modifiedMessageModel : modifiedMessageModels) {
+					if (modifiedMessageModel.getId() == messageModel.getId()) {
+						RuntimeUtil.runOnUiThread(() -> updateAckDisplay(modifiedMessageModel));
+						break;
+					}
+				}
+			}
+		}
+
+		@Override
+		public void onRemoved(AbstractMessageModel removedMessageModel) {}
+
+		@Override
+		public void onRemoved(List<AbstractMessageModel> removedMessageModels) {}
 
-	public static MessageDetailDialog newInstance(@StringRes int title, int messageId, String type) {
+		@Override
+		public void onProgressChanged(AbstractMessageModel messageModel, int newProgress) {}
+	};
+
+	public static MessageDetailDialog newInstance(@StringRes int title, int messageId, String type, @Nullable ForwardSecurityMode forwardSecurityMode) {
 		MessageDetailDialog dialog = new MessageDetailDialog();
 		Bundle args = new Bundle();
 		args.putInt("title", title);
 		args.putInt("messageId", messageId);
 		args.putString("messageType", type);
+		if (forwardSecurityMode != null) {
+			args.putInt("forwardSecurityMode", forwardSecurityMode.getValue());
+		}
 
 		dialog.setArguments(args);
 		return dialog;
@@ -69,24 +129,54 @@ public class MessageDetailDialog extends ThreemaDialogFragment {
 		this.activity = activity;
 	}
 
+	@Override
+	public void onCreate(Bundle savedInstanceState) {
+		super.onCreate(savedInstanceState);
+
+		ListenerManager.messageListeners.add(this.messageListener);
+	}
+
+	@Override
+	public void onDestroy() {
+		ListenerManager.messageListeners.remove(this.messageListener);
+
+		super.onDestroy();
+	}
+
 	@NonNull
 	@Override
 	public AppCompatDialog onCreateDialog(Bundle savedInstanceState) {
 		MessageService messageService = null;
 		try {
 			messageService = ThreemaApplication.getServiceManager().getMessageService();
+			identityStore = ThreemaApplication.getServiceManager().getIdentityStore();
+			contactService = ThreemaApplication.getServiceManager().getContactService();
 		} catch (Exception e) {
 			//
 		}
 
-		if (messageService != null) {
+		if (messageService != null && contactService != null) {
 			@StringRes int title = getArguments().getInt("title");
 			int messageId = getArguments().getInt("messageId");
 			String messageType = getArguments().getString("messageType");
+			ForwardSecurityMode forwardSecurityMode = ForwardSecurityMode.getByValue(getArguments().getInt("forwardSecurityMode"));
+			String forwardSecurityModeStr = getString(R.string.forward_security_mode_none);
+			if (forwardSecurityMode != null) {
+				switch (forwardSecurityMode) {
+					case TWODH:
+						forwardSecurityModeStr = getString(R.string.forward_security_mode_2dh);
+						break;
+					case FOURDH:
+						forwardSecurityModeStr = getString(R.string.forward_security_mode_4dh);
+						break;
+					default:
+						break;
+				}
+			}
 
-			AbstractMessageModel messageModel = messageService.getMessageModelFromId(messageId, messageType);
+			messageModel = messageService.getMessageModelFromId(messageId, messageType);
 
-			final View dialogView = activity.getLayoutInflater().inflate(R.layout.dialog_message_detail, null);
+			dialogView = activity.getLayoutInflater().inflate(R.layout.dialog_message_detail, null);
 			final TextView createdText = dialogView.findViewById(R.id.created_text);
 			final TextView createdDate = dialogView.findViewById(R.id.created_date);
 			final TextView postedText = dialogView.findViewById(R.id.posted_text);
@@ -103,6 +193,8 @@ public class MessageDetailDialog extends ThreemaDialogFragment {
 			final TextView mimeTypeMime = dialogView.findViewById(R.id.filetype_mime);
 			final TextView fileSizeText = dialogView.findViewById(R.id.filesize_text);
 			final TextView fileSizeData = dialogView.findViewById(R.id.filesize_data);
+			final TextView forwardSecurityModeText = dialogView.findViewById(R.id.forward_security_mode_text);
+			final TextView forwardSecurityModeData = dialogView.findViewById(R.id.forward_security_mode_data);
 
 			MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity(), getTheme());
 			builder.setView(dialogView);
@@ -116,14 +208,35 @@ public class MessageDetailDialog extends ThreemaDialogFragment {
 			@StringRes int stateResource = getStateTextRes(messageModel);
 			MessageState messageState = messageModel.getState();
 
-			boolean showPostedAt = (messageState != null &&
-				messageState != MessageState.SENDING &&
-				messageState != MessageState.SENDFAILED &&
-				messageState != MessageState.PENDING) ||
-				messageModel.getType() == MessageType.BALLOT;
-
 			if (messageModel.isStatusMessage()) {
 				createdDate.setText(LocaleUtil.formatTimeStampStringAbsolute(getContext(), messageModel.getCreatedAt().getTime()));
+			} else if (messageModel.getType() == MessageType.GROUP_CALL_STATUS) {
+				Date deliveredAt = messageModel.getCreatedAt();
+				Date postedAt = messageModel.getPostedAt();
+
+				if (messageModel.isOutbox()) {
+					if (postedAt != null) {
+						postedDate.setText(LocaleUtil.formatTimeStampStringAbsolute(getContext(), postedAt.getTime()));
+						postedText.setVisibility(View.VISIBLE);
+						postedDate.setVisibility(View.VISIBLE);
+					}
+				} else {
+					if (postedAt != null) {
+						postedText.setText(R.string.state_dialog_posted);
+						postedDate.setText(LocaleUtil.formatTimeStampStringAbsolute(getContext(), postedAt.getTime()));
+						postedText.setVisibility(View.VISIBLE);
+						postedDate.setVisibility(View.VISIBLE);
+					}
+
+					if (deliveredAt != null) {
+						deliveredText.setText(R.string.state_dialog_received);
+						deliveredDate.setText(LocaleUtil.formatTimeStampStringAbsolute(getContext(), deliveredAt.getTime()));
+						deliveredText.setVisibility(View.VISIBLE);
+						deliveredDate.setVisibility(View.VISIBLE);
+					}
+				}
+				createdText.setVisibility(View.GONE);
+				createdDate.setVisibility(View.GONE);
 			} else {
 				if (messageModel.isOutbox()) {
 					// outgoing msgs
@@ -134,6 +247,13 @@ public class MessageDetailDialog extends ThreemaDialogFragment {
 						createdDate.setVisibility(View.GONE);
 					}
 
+					boolean showPostedAt = (messageState != null &&
+						messageState != MessageState.SENDING &&
+						messageState != MessageState.SENDFAILED &&
+						messageState != MessageState.FS_KEY_MISMATCH &&
+						messageState != MessageState.PENDING) ||
+						messageModel.getType() == MessageType.BALLOT;
+
 					if (showPostedAt && messageModel.getPostedAt() != null) {
 						postedDate.setText(LocaleUtil.formatTimeStampStringAbsolute(getContext(), messageModel.getPostedAt().getTime()));
 						postedText.setVisibility(View.VISIBLE);
@@ -210,14 +330,143 @@ public class MessageDetailDialog extends ThreemaDialogFragment {
 					messageIdDate.setVisibility(View.VISIBLE);
 					messageIdText.setVisibility(View.VISIBLE);
 				}
+
+				if (ConfigUtils.isForwardSecurityEnabled()) {
+					if (messageModel instanceof GroupMessageModel || messageModel instanceof DistributionListMessageModel) {
+						forwardSecurityModeData.setVisibility(View.GONE);
+						forwardSecurityModeText.setVisibility(View.GONE);
+					} else {
+						forwardSecurityModeData.setText(forwardSecurityModeStr);
+						forwardSecurityModeData.setVisibility(View.VISIBLE);
+						forwardSecurityModeText.setVisibility(View.VISIBLE);
+					}
+				}
+
+				updateAckDisplay(messageModel);
 			}
 
-			alertDialog = builder.create();
-			return alertDialog;
+			return builder.create();
 		}
 		return null;
 	}
 
+	private synchronized void updateAckDisplay(AbstractMessageModel messageModel) {
+		if (!ConfigUtils.isGroupAckEnabled()) {
+			return;
+		}
+
+		if (dialogView == null) {
+			return;
+		}
+
+		if (!isAdded()) {
+			return;
+		}
+
+		if (messageModel == null) {
+			return;
+		}
+
+		final MaterialDivider groupAckDivider = dialogView.findViewById(R.id.groupack_divider);
+		final MaterialCardView ackCard = dialogView.findViewById(R.id.ack_card);
+		final ImageView ackIcon = dialogView.findViewById(R.id.ack_icon);
+		final ChipGroup ackData = dialogView.findViewById(R.id.ack_data);
+		final CountBoxView ackCountView = dialogView.findViewById(R.id.ack_count);
+		final MaterialCardView decCard = dialogView.findViewById(R.id.dec_card);
+		final ImageView decIcon = dialogView.findViewById(R.id.dec_icon);
+		final ChipGroup decData = dialogView.findViewById(R.id.dec_data);
+		final CountBoxView decCountView = dialogView.findViewById(R.id.dec_count);
+
+		if (messageModel instanceof GroupMessageModel) {
+			Map<String, Object> messageStates = ((GroupMessageModel) messageModel).getGroupMessageStates();
+			if (messageStates != null && messageStates.size() > 0) {
+				int ackCount = 0, decCount = 0;
+				ackData.removeAllViews();
+				decData.removeAllViews();
+
+				for (Map.Entry<String, Object> entry : messageStates.entrySet()) {
+					ContactModel contactModel = contactService.getByIdentity(entry.getKey());
+					if (contactModel == null) {
+						continue;
+					}
+
+					// an ack or dec state implies "read"
+					if (MessageState.USERACK.toString().equals(entry.getValue())) {
+						appendChip(ackData, contactModel);
+						ackCount++;
+					} else if (MessageState.USERDEC.toString().equals(entry.getValue())) {
+						appendChip(decData, contactModel);
+						decCount++;
+					}
+
+					if (ackCount > 0) {
+						ackCard.setVisibility(View.VISIBLE);
+						ackCountView.setText(String.valueOf(ackCount));
+						if (messageModel.getState() == MessageState.USERACK) {
+							ackIcon.setImageResource(R.drawable.ic_thumb_up_filled);
+						}
+					} else {
+						ackCard.setVisibility(View.GONE);
+					}
+
+					if (decCount > 0) {
+						decCard.setVisibility(View.VISIBLE);
+						decCountView.setText(String.valueOf(decCount));
+						if (messageModel.getState() == MessageState.USERDEC) {
+							decIcon.setImageResource(R.drawable.ic_thumb_down_filled);
+						}
+					} else {
+						decCard.setVisibility(View.GONE);
+					}
+
+					if (decCount > 0 || ackCount > 0) {
+						groupAckDivider.setVisibility(View.VISIBLE);
+					}
+				}
+			}
+		}
+	}
+
+	private void appendChip(ChipGroup chipGroup, ContactModel contactModel) {
+		Chip chip = new Chip(getContext());
+		ChipDrawable chipDrawable = ChipDrawable.createFromAttributes(getContext(),
+			null,
+			0,
+			R.style.Chip_MessageDetails);
+		chip.setChipDrawable(chipDrawable);
+		chip.setEnsureMinTouchTargetSize(false);
+		chip.setTag(contactModel.getIdentity());
+		chip.setOnClickListener(this);
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+			chip.setTextAppearance(R.style.TextAppearance_Chip_ChatNotice);
+		} else {
+			chip.setTextSize(14);
+		}
+
+		new AsyncTask<Void, Void, Bitmap>() {
+			@Override
+			protected Bitmap doInBackground(Void... params) {
+				Bitmap bitmap = contactService.getAvatar(contactModel, false);
+				if (bitmap != null) {
+					return BitmapUtil.replaceTransparency(bitmap, Color.WHITE);
+				}
+				return null;
+			}
+
+			@Override
+			protected void onPostExecute(Bitmap avatar) {
+				if (avatar != null) {
+					chip.setChipIcon(AvatarConverterUtil.convertToRound(getResources(), avatar));
+				} else {
+					chip.setChipIconResource(R.drawable.ic_contact);
+				}
+			}
+		}.execute();
+
+		chip.setText(NameUtil.getShortName(contactModel));
+		chipGroup.addView(chip);
+	}
+
 	private @StringRes int getStateTextRes(AbstractMessageModel messageModel) {
 		int stateResource = 0;
 		if (messageModel.getState() != null) {
@@ -252,6 +501,9 @@ public class MessageDetailDialog extends ThreemaDialogFragment {
 				case CONSUMED:
 					stateResource = R.string.listened_to;
 					break;
+				case FS_KEY_MISMATCH:
+					stateResource = R.string.fs_key_mismatch;
+					break;
 			}
 		} else {
 			stateResource = R.string.state_sent;
@@ -259,4 +511,16 @@ public class MessageDetailDialog extends ThreemaDialogFragment {
 		return stateResource;
 	}
 
+	@Override
+	public void onClick(View v) {
+		if (v instanceof Chip) {
+			String identity = (String) v.getTag();
+			if (identity != null && !identity.equals(identityStore.getIdentity())) {
+				Intent intent = new Intent(getContext(), ContactDetailActivity.class);
+				intent.putExtra(ThreemaApplication.INTENT_DATA_CONTACT, identity);
+				intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+				ActivityCompat.startActivityForResult(getActivity(), intent, ThreemaActivity.ACTIVITY_ID_CONTACT_DETAIL, null);
+			}
+		}
+	}
 }

+ 18 - 0
app/src/main/java/ch/threema/app/dialogs/SimpleStringAlertDialog.java

@@ -22,11 +22,13 @@
 package ch.threema.app.dialogs;
 
 import android.app.Activity;
+import android.content.DialogInterface;
 import android.os.Bundle;
 
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatDialog;
 import androidx.fragment.app.FragmentManager;
 import androidx.fragment.app.FragmentTransaction;
@@ -35,6 +37,8 @@ import ch.threema.app.utils.TestUtil;
 
 public class SimpleStringAlertDialog extends ThreemaDialogFragment {
 	protected Activity activity;
+	@Nullable
+	private Runnable onDismissRunnable;
 
 	public static SimpleStringAlertDialog newInstance(int title, CharSequence message) {
 		SimpleStringAlertDialog dialog = new SimpleStringAlertDialog();
@@ -111,4 +115,18 @@ public class SimpleStringAlertDialog extends ThreemaDialogFragment {
 
 		return builder.create();
 	}
+
+	public SimpleStringAlertDialog setOnDismissRunnable(Runnable onDismissRunnable) {
+		this.onDismissRunnable = onDismissRunnable;
+		return this;
+	}
+
+	@Override
+	public void onDismiss(@NonNull DialogInterface dialog) {
+		super.onDismiss(dialog);
+
+		if (onDismissRunnable != null) {
+			onDismissRunnable.run();
+		}
+	}
 }

+ 6 - 10
app/src/main/java/ch/threema/app/emojis/EmojiDetailPopup.java

@@ -36,14 +36,12 @@ import ch.threema.app.utils.AnimationUtil;
 
 public class EmojiDetailPopup extends PopupWindow implements View.OnClickListener {
 
-	private ImageView originalImage;
-	private FrameLayout topLayout;
-	private View parentView;
-	private EmojiManager emojiManager;
+	private final ImageView originalImage;
+	private final View parentView;
+	private final EmojiManager emojiManager;
 	private EmojiDetailPopupListener emojiDetailPopupListener;
-	private int popupHeight, popupOffsetLeft;
-
-	final int[] location = new int[2];
+	private final int popupHeight;
+	private final int popupOffsetLeft;
 
 	public EmojiDetailPopup(final Context context, View parentView) {
 		super(context);
@@ -56,7 +54,7 @@ public class EmojiDetailPopup extends PopupWindow implements View.OnClickListene
 		this.popupOffsetLeft = context.getResources().getDimensionPixelSize(R.dimen.emoji_popup_cardview_margin_horizontal);
 
 		LayoutInflater layoutInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
-		topLayout = (FrameLayout) layoutInflater.inflate(R.layout.popup_emoji_detail, null, true);
+		FrameLayout topLayout = (FrameLayout) layoutInflater.inflate(R.layout.popup_emoji_detail, null, true);
 
 		this.originalImage = topLayout.findViewById(R.id.image_original);
 
@@ -85,8 +83,6 @@ public class EmojiDetailPopup extends PopupWindow implements View.OnClickListene
 			@Override
 			public void onGlobalLayout() {
 				getContentView().getViewTreeObserver().removeGlobalOnLayoutListener(this);
-
-				AnimationUtil.getViewCenter(originView, getContentView(), location);
 				AnimationUtil.popupAnimateIn(getContentView());
 			}
 		});

+ 42 - 7
app/src/main/java/ch/threema/app/emojis/EmojiMarkupUtil.java

@@ -28,6 +28,7 @@ import android.text.SpannableStringBuilder;
 import android.text.Spanned;
 import android.text.TextUtils;
 import android.text.style.RelativeSizeSpan;
+import android.text.style.StrikethroughSpan;
 import android.util.Pair;
 import android.widget.TextView;
 
@@ -157,6 +158,10 @@ public class EmojiMarkupUtil {
 			}
 		}
 
+		if (!ignoreMarkup) {
+			MarkupParser.getInstance().markify(builder);
+		}
+
 		if (!ignoreMentions) {
 			if (textView == null) {
 				builder = new SpannableStringBuilder(applyTextMentionMarkup(text));
@@ -165,14 +170,14 @@ public class EmojiMarkupUtil {
 			}
 		}
 
-		if (!ignoreMarkup) {
-			MarkupParser.getInstance().markify(builder);
-		}
-
 		return builder;
 	}
 
 	private SpannableStringBuilder applyMentionMarkup(Context context, SpannableStringBuilder inputText) {
+		return applyMentionMarkup(context, inputText, false);
+	}
+
+	private SpannableStringBuilder applyMentionMarkup(Context context, SpannableStringBuilder inputText, boolean isStrikeThrough) {
 		int start, end;
 
 		ArrayList<Pair<Integer, Integer>> matches = new ArrayList<>();
@@ -193,11 +198,37 @@ public class EmojiMarkupUtil {
 
 		SpannableStringBuilder s = new SpannableStringBuilder(inputText);
 
+		final StrikethroughSpan[] strikethroughSpans = s.getSpans(0, s.length(), StrikethroughSpan.class);
+		final MentionSpan[] mentionSpans = s.getSpans(0, s.length(), MentionSpan.class);
+
 		for (int i = matches.size() - 1; i >= 0; i--) {
 			start = matches.get(i).first;
 			end = matches.get(i).second;
 
-			s.setSpan(new MentionSpan(mentionColor, invertedMentionColor, mentionTextColor, invertedMentionTextColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+			// Check if there is a strike through span surrounding this mention. If there is one,
+			// then the line must be drawn explicitly as the mention draws over the text.
+			boolean inStrikethroughSpan = isStrikeThrough;
+			for (StrikethroughSpan sts : strikethroughSpans) {
+				if (s.getSpanStart(sts) <= start && s.getSpanEnd(sts) >= end) {
+					inStrikethroughSpan = true;
+					break;
+				}
+			}
+
+			MentionSpan mentionSpan = null;
+			for (MentionSpan ms : mentionSpans) {
+				if (s.getSpanStart(ms) == start) {
+					mentionSpan = ms;
+					break;
+				}
+			}
+
+			// Create a new mention span if already available, otherwise update the existing span
+			if (mentionSpan == null) {
+				s.setSpan(new MentionSpan(mentionColor, invertedMentionColor, mentionTextColor, invertedMentionTextColor, inStrikethroughSpan), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+			} else {
+				mentionSpan.setStrikeThrough(inStrikethroughSpan);
+			}
 			// hack: https://stackoverflow.com/questions/20069537/replacementspans-draw-method-isnt-called
 			if (inputText.length() == end - start) {
 				s.append(" ");
@@ -237,16 +268,20 @@ public class EmojiMarkupUtil {
 	public CharSequence addMarkup(Context context, CharSequence text) {
 		SpannableStringBuilder builder = new SpannableStringBuilder(text);
 
-		builder = applyMentionMarkup(context, builder);
 		MarkupParser.getInstance().markify(builder);
+		builder = applyMentionMarkup(context, builder);
 
 		return builder;
 	}
 
 	public CharSequence addMentionMarkup(Context context, CharSequence text) {
+		return addMentionMarkup(context, text, false);
+	}
+
+	public CharSequence addMentionMarkup(Context context, CharSequence text, boolean isStrikeThrough) {
 		SpannableStringBuilder builder = new SpannableStringBuilder(text);
 
-		builder = applyMentionMarkup(context, builder);
+		builder = applyMentionMarkup(context, builder, isStrikeThrough);
 
 		return builder;
 	}

+ 0 - 1
app/src/main/java/ch/threema/app/emojis/EmojiSearchWidget.kt

@@ -35,7 +35,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
 import ch.threema.app.R
 import ch.threema.app.utils.EditTextUtil
-import ch.threema.app.utils.ViewUtil
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.launch

+ 4 - 7
app/src/main/java/ch/threema/app/emojis/RecentEmojiRemovePopup.java

@@ -36,12 +36,11 @@ import ch.threema.app.utils.AnimationUtil;
 
 public class RecentEmojiRemovePopup extends PopupWindow implements View.OnClickListener {
 
-	private View parentView;
+	private final View parentView;
 	private RemoveListener removeListener;
-	private ImageView originalImage;
-	private int popupHeight, popupOffsetLeft;
-
-	private final int[] location = new int[2];
+	private final ImageView originalImage;
+	private final int popupHeight;
+	private final int popupOffsetLeft;
 
 	public RecentEmojiRemovePopup(final Context context, View parentView) {
 		super(context);
@@ -79,8 +78,6 @@ public class RecentEmojiRemovePopup extends PopupWindow implements View.OnClickL
 			@Override
 			public void onGlobalLayout() {
 				getContentView().getViewTreeObserver().removeGlobalOnLayoutListener(this);
-
-				AnimationUtil.getViewCenter(originView, getContentView(), location);
 				AnimationUtil.popupAnimateIn(getContentView());
 			}
 		});

+ 1 - 1
app/src/main/java/ch/threema/app/emojis/search/DiversityConverters.kt

@@ -25,7 +25,7 @@ import androidx.room.TypeConverter
 
 class DiversityConverters {
 	private companion object {
-		val DIVERSITY_SEPARATOR = ";"
+		const val DIVERSITY_SEPARATOR = ";"
 	}
 
 	@TypeConverter

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

@@ -43,7 +43,7 @@ class EmojiSearchIndex(
 	private var searchIndexVersion = preferenceService.emojiSearchIndexVersion
 
 	private companion object {
-		const val SEARCH_INDEX_VERSION = 5
+		const val SEARCH_INDEX_VERSION = 6
 		const val INDEX_FILE_EXTENSION = ".csv"
 		const val EMOJI_ORDERS_FILE = "orders.csv"
 		const val EMOJI_DIVERSITIES_FILE = "diversities.csv"

+ 1 - 1
app/src/main/java/ch/threema/app/filepicker/FilePickerAdapter.java

@@ -93,7 +93,7 @@ public class FilePickerAdapter extends ArrayAdapter<FileInfo> {
 			viewHolder.extra.setVisibility(View.GONE);
 
 			if (fileInfo.getData().equalsIgnoreCase(Constants.FOLDER)) {
-				viewHolder.icon.setImageResource(R.drawable.ic_doc_folder);
+				viewHolder.icon.setImageResource(R.drawable.ic_folder);
 				viewHolder.extra.setVisibility(View.GONE);
 				tintItem(viewHolder, true);
 			} else if (fileInfo.getData().equalsIgnoreCase(

+ 224 - 0
app/src/main/java/ch/threema/app/fragments/BigMediaFragment.kt

@@ -0,0 +1,224 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2022 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.fragments
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.ProgressBar
+import androidx.core.view.doOnLayout
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.Lifecycle
+import androidx.viewpager2.widget.ViewPager2
+import ch.threema.app.R
+import ch.threema.app.camera.VideoEditView
+import ch.threema.app.ui.BigFileView
+import ch.threema.app.ui.MediaItem
+import ch.threema.app.utils.BitmapUtil.FLIP_HORIZONTAL
+import ch.threema.app.utils.BitmapUtil.FLIP_VERTICAL
+import ch.threema.base.utils.LoggingUtil
+import com.bumptech.glide.Glide
+import com.bumptech.glide.load.resource.bitmap.FitCenter
+import com.bumptech.glide.load.resource.bitmap.Rotate
+import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
+import com.bumptech.glide.request.RequestOptions
+import pl.droidsonroids.gif.GifImageView
+
+private val logger = LoggingUtil.getThreemaLogger("BigMediaFragment")
+
+class BigMediaFragment : Fragment() {
+    private var mediaItem: MediaItem? = null
+    private var viewPager: ViewPager2? = null
+    private lateinit var bigFileView: BigFileView
+    private lateinit var bigImageView: ImageView
+    private lateinit var bigGifImageView: GifImageView
+    private lateinit var videoEditView: VideoEditView
+    private lateinit var bigProgressBar: ProgressBar
+    private var bottomElemHeight: Int = 0
+    private var isVideo = false
+
+    override fun onCreateView(
+        inflater: LayoutInflater, container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View? {
+        val view = inflater.inflate(R.layout.fragment_big_media, container, false).apply {
+            bigFileView = findViewById(R.id.big_file_view)
+            bigImageView = findViewById(R.id.preview_image)
+            bigGifImageView = findViewById(R.id.gif_image)
+            videoEditView = findViewById(R.id.video_edit_view)
+            bigProgressBar = findViewById(R.id.progress)
+        }
+
+        return view
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        showBigMediaItem()
+    }
+
+    override fun onResume() {
+        super.onResume()
+
+        if (isVideo) {
+            showBigVideo(mediaItem ?: return)
+        }
+    }
+
+    override fun onPause() {
+        super.onPause()
+
+        if (isVideo) {
+            videoEditView.releasePlayer()
+        }
+    }
+
+    fun setMediaItem(mediaItem: MediaItem) {
+        this.mediaItem = mediaItem
+        isVideo = mediaItem.type == MediaItem.TYPE_VIDEO || mediaItem.type == MediaItem.TYPE_VIDEO_CAM
+    }
+
+    // Change to drag listener here
+    fun setViewPager(viewPager: ViewPager2?) {
+        this.viewPager = viewPager
+    }
+
+    private fun setBottomElemHeight(bottomElemHeight: Int) {
+        this.bottomElemHeight = bottomElemHeight
+    }
+
+    fun showBigMediaItem() {
+        logger.debug("showBigMediaItem")
+        if (lifecycle.currentState == Lifecycle.State.INITIALIZED || lifecycle.currentState == Lifecycle.State.DESTROYED) {
+            return
+        }
+
+        val item = mediaItem ?: return
+
+
+        when (item.type) {
+            MediaItem.TYPE_IMAGE, MediaItem.TYPE_IMAGE_CAM, MediaItem.TYPE_GIF -> {
+                showBigImage(item)
+            }
+            MediaItem.TYPE_VIDEO, MediaItem.TYPE_VIDEO_CAM -> {
+                showBigVideo(item)
+            }
+            else -> {
+                if (!this::bigFileView.isInitialized) {
+                    return
+                }
+                showBigFile(item)
+            }
+        }
+    }
+
+    fun updateFilename() {
+        if (bigFileView.visibility == View.VISIBLE) {
+            bigFileView.setFilename(mediaItem?.filename)
+        }
+    }
+
+    fun updateVideoPlayerSound() {
+        if (isVideo) {
+            if (mediaItem?.isMuted == true) {
+                videoEditView.mutePlayer()
+            } else {
+                videoEditView.unmutePlayer()
+            }
+        }
+    }
+
+    private fun showBigFile(item: MediaItem) {
+        this.bigGifImageView.visibility = View.GONE
+        this.bigImageView.visibility = View.GONE
+        this.videoEditView.visibility = View.GONE
+        this.bigFileView.visibility = View.VISIBLE
+        this.bigFileView.setPadding(0, 0, 0, bottomElemHeight)
+        this.bigFileView.setMediaItem(item)
+    }
+
+    private fun showBigVideo(item: MediaItem) {
+        this.bigFileView.visibility = View.GONE
+        this.bigImageView.visibility = View.GONE
+        this.bigGifImageView.visibility = View.GONE
+        this.videoEditView.visibility = View.VISIBLE
+        this.videoEditView.setOnTimelineDragListener(object :
+            VideoEditView.OnTimelineDragListener {
+            override fun onTimelineDragStart() {
+                viewPager?.isUserInputEnabled = false
+            }
+
+            override fun onTimelineDragStop() {
+                viewPager?.isUserInputEnabled = true
+            }
+        })
+        this.videoEditView.doOnLayout {
+            this.videoEditView.setVideo(item)
+        }
+        this.videoEditView.requestLayout()
+    }
+
+    private fun showBigImage(item: MediaItem) {
+        bigImageView.visibility = View.VISIBLE
+        bigFileView.visibility = View.GONE
+        videoEditView.visibility = View.GONE
+        if (item.type == MediaItem.TYPE_GIF) {
+            bigProgressBar.visibility = View.GONE
+            bigImageView.visibility = View.GONE
+            try {
+                bigGifImageView.setImageURI(item.uri)
+                bigGifImageView.visibility = View.VISIBLE
+            } catch (e: Exception) {
+                // may crash with a SecurityException on some exotic devices
+                logger.error("Error setting GIF", e)
+            }
+        } else {
+            val flipHorizontal =
+                (item.rotation in setOf(90, 270) && item.flip and FLIP_VERTICAL == FLIP_VERTICAL)
+                    || (item.rotation in setOf(0, 180) && item.flip and FLIP_HORIZONTAL == FLIP_HORIZONTAL)
+            val flipVertical =
+                (item.rotation in setOf(90, 270) && item.flip and FLIP_HORIZONTAL == FLIP_HORIZONTAL)
+                    || (item.rotation in setOf(0, 180) && item.flip and FLIP_VERTICAL == FLIP_VERTICAL)
+            bigImageView.rotationX = if (flipVertical) 180f else 0f
+            bigImageView.rotationY = if (flipHorizontal) 180f else 0f
+
+            Glide.with(context ?: return).load(item.uri)
+                .transition(DrawableTransitionOptions.withCrossFade())
+                .apply(RequestOptions.fitCenterTransform())
+                .transform(Rotate(item.rotation), FitCenter())
+                .error(R.drawable.ic_baseline_broken_image_200)
+                .into(bigImageView)
+        }
+    }
+
+    companion object {
+        @JvmStatic
+        fun newInstance(mediaItem: MediaItem, bottomElemHeight: Int) =
+            BigMediaFragment().apply {
+                setMediaItem(mediaItem)
+                setBottomElemHeight(bottomElemHeight)
+            }
+    }
+}

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 356 - 188
app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java


+ 40 - 17
app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java

@@ -49,6 +49,17 @@ import android.widget.FrameLayout;
 import android.widget.ListView;
 import android.widget.Toast;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.SearchView;
+import androidx.core.util.Pair;
+import androidx.core.view.MenuItemCompat;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
+import androidx.work.ExistingWorkPolicy;
+import androidx.work.OneTimeWorkRequest;
+import androidx.work.WorkManager;
+
 import com.google.android.material.chip.Chip;
 import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton;
 import com.google.android.material.tabs.TabLayout;
@@ -61,15 +72,6 @@ import java.util.Date;
 import java.util.HashSet;
 import java.util.List;
 
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.widget.SearchView;
-import androidx.core.util.Pair;
-import androidx.core.view.MenuItemCompat;
-import androidx.localbroadcastmanager.content.LocalBroadcastManager;
-import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
-import androidx.work.OneTimeWorkRequest;
-import androidx.work.WorkManager;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.AddContactActivity;
@@ -86,7 +88,6 @@ import ch.threema.app.dialogs.SelectorDialog;
 import ch.threema.app.dialogs.TextWithCheckboxDialog;
 import ch.threema.app.emojis.EmojiTextView;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
-import ch.threema.app.jobs.WorkSyncService;
 import ch.threema.app.listeners.ContactListener;
 import ch.threema.app.listeners.ContactSettingsListener;
 import ch.threema.app.listeners.ConversationListener;
@@ -94,6 +95,7 @@ import ch.threema.app.listeners.PreferenceListener;
 import ch.threema.app.listeners.SynchronizeContactsListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.routines.SynchronizeContactsRoutine;
 import ch.threema.app.services.AvatarCacheService;
 import ch.threema.app.services.ContactService;
@@ -117,14 +119,17 @@ import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.ShareUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.workers.IdentityStatesWorker;
+import ch.threema.app.workers.WorkSyncWorker;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
+import ch.threema.domain.models.VerificationLevel;
 import ch.threema.localcrypto.MasterKeyLockedException;
 import ch.threema.storage.models.ContactModel;
 
 import static android.view.MenuItem.SHOW_AS_ACTION_ALWAYS;
 import static android.view.MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW;
 import static android.view.MenuItem.SHOW_AS_ACTION_NEVER;
+import static ch.threema.app.ThreemaApplication.WORKER_WORK_SYNC;
 
 public class ContactsSectionFragment
 		extends MainFragment
@@ -715,7 +720,7 @@ public class ContactsSectionFragment
 				}
 				if (numContacts > 1) {
 					final StringBuilder builder = new StringBuilder();
-					builder.append(getResources().getQuantityString(R.plurals.contacts_counter_label, numContacts, numContacts));
+					builder.append(ConfigUtils.getSafeQuantityString(getContext(), R.plurals.contacts_counter_label, numContacts, numContacts));
 					if (counts != null) {
 						builder.append(" (+").append(counts.last30d).append(" / ").append(getString(R.string.thirty_days_abbrev)).append(")");
 					}
@@ -1071,7 +1076,12 @@ public class ContactsSectionFragment
 		}
 
 		if (ConfigUtils.isWorkBuild()) {
-			WorkSyncService.enqueueWork(getActivity(), new Intent(), true);
+			try {
+				OneTimeWorkRequest workRequest = WorkSyncWorker.Companion.buildOneTimeWorkRequest(false, true, "WorkContactSync");
+				WorkManager.getInstance(ThreemaApplication.getAppContext()).enqueueUniqueWork(WORKER_WORK_SYNC, ExistingWorkPolicy.REPLACE, workRequest);
+			} catch (IllegalStateException e) {
+				logger.error("Unable to schedule work sync one time work", e);
+			}
 		}
 	}
 
@@ -1171,8 +1181,18 @@ public class ContactsSectionFragment
 			tags.add(SELECTOR_TAG_SHOW_CONTACT);
 
 			if (!ConfigUtils.isOnPremBuild()) {
-				items.add(new SelectorDialogItem(getString(R.string.spam_report), R.drawable.ic_outline_report_24));
-				tags.add(SELECTOR_TAG_REPORT_SPAM);
+				if (
+					contactModel.getAndroidContactLookupKey() == null &&
+					TestUtil.empty(contactModel.getFirstName()) &&
+					TestUtil.empty(contactModel.getLastName()) &&
+					contactModel.getVerificationLevel() == VerificationLevel.UNVERIFIED
+				) {
+					MessageReceiver messageReceiver = contactService.createReceiver(contactModel);
+					if (messageReceiver != null && messageReceiver.getMessagesCount() > 0) {
+						items.add(new SelectorDialogItem(getString(R.string.spam_report), R.drawable.ic_outline_report_24));
+						tags.add(SELECTOR_TAG_REPORT_SPAM);
+					}
+				}
 			}
 
 			if (serviceManager.getBlackListService().has(contactModel.getIdentity())) {
@@ -1225,8 +1245,11 @@ public class ContactsSectionFragment
 
 	@SuppressLint("StringFormatInvalid")
 	private void deleteSelectedContacts() {
-		GenericAlertDialog dialog = GenericAlertDialog.newInstance(R.string.delete_contact_action,
-				String.format(getString(R.string.really_delete_contacts_message), contactListAdapter.getCheckedItemCount()),
+		int contactsSelectedToDelete = contactListAdapter.getCheckedItemCount();
+		final String deleteContactTitle = getString(contactsSelectedToDelete > 1 ? R.string.delete_multiple_contact_action : R.string.delete_contact_action);
+
+		GenericAlertDialog dialog = GenericAlertDialog.newInstance(deleteContactTitle,
+				String.format(ConfigUtils.getSafeQuantityString(ThreemaApplication.getAppContext(), R.plurals.really_delete_contacts_message, contactsSelectedToDelete, contactsSelectedToDelete), contactListAdapter.getCheckedItemCount()),
 				R.string.ok,
 				R.string.cancel);
 
@@ -1255,7 +1278,7 @@ public class ContactsSectionFragment
 			public void run() {
 				if (isAdded()) {
 					if (failed > 0) {
-						Toast.makeText(getActivity(), String.format(getString(R.string.some_contacts_not_deleted), failed), Toast.LENGTH_LONG).show();
+						Toast.makeText(getActivity(), String.format(ConfigUtils.getSafeQuantityString(getContext(),  R.plurals.some_contacts_not_deleted, failed, failed)), Toast.LENGTH_LONG).show();
 					} else {
 						if (contactModels.size() > 1) {
 							Toast.makeText(getActivity(), R.string.contacts_deleted, Toast.LENGTH_LONG).show();

+ 35 - 2
app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java

@@ -116,6 +116,7 @@ import ch.threema.app.services.LockAppService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.services.RingtoneService;
+import ch.threema.app.services.UserService;
 import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.EmptyView;
 import ch.threema.app.ui.ResumePauseHandler;
@@ -133,6 +134,8 @@ import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.app.utils.ViewUtil;
+import ch.threema.app.voip.activities.GroupCallActivity;
+import ch.threema.app.voip.groupcall.GroupCallManager;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.localcrypto.MasterKeyLockedException;
@@ -195,6 +198,7 @@ public class MessageSectionFragment extends MainFragment
 	private ConversationService conversationService;
 	private ContactService contactService;
 	private GroupService groupService;
+	private GroupCallManager groupCallManager;
 	private MessageService messageService;
 	private DistributionListService distributionListService;
 	private BackupChatService backupChatService;
@@ -218,6 +222,8 @@ public class MessageSectionFragment extends MainFragment
 	private int cornerRadius;
 	private TagModel unreadTagModel;
 
+	private @Nullable String myIdentity;
+
 	private ArchiveSnackbar archiveSnackbar;
 
 	private ConversationModel selectedConversation;
@@ -303,6 +309,16 @@ public class MessageSectionFragment extends MainFragment
 		}
 	};
 
+	private final GroupListener groupListener = new GroupListener() {
+		@Override
+		public void onNewMember(GroupModel group, String newIdentity, int previousMemberCount) {
+			// If this user is added to an existing group
+			if (groupService != null && myIdentity != null && myIdentity.equals(newIdentity)) {
+				fireReceiverUpdate(groupService.createReceiver(group));
+			}
+		}
+	};
+
 	private final ChatListener chatListener = new ChatListener() {
 		@Override
 		public void onChatOpened(String conversationUid) {
@@ -385,6 +401,7 @@ public class MessageSectionFragment extends MainFragment
 				this.serviceManager,
 				this.contactListener,
 				this.groupService,
+				this.groupCallManager,
 				this.conversationService,
 				this.distributionListService,
 				this.fileService,
@@ -403,6 +420,7 @@ public class MessageSectionFragment extends MainFragment
 			try {
 				this.contactService = this.serviceManager.getContactService();
 				this.groupService = this.serviceManager.getGroupService();
+				this.groupCallManager = this.serviceManager.getGroupCallManager();
 				this.messageService = this.serviceManager.getMessageService();
 				this.conversationService = this.serviceManager.getConversationService();
 				this.distributionListService = this.serviceManager.getDistributionListService();
@@ -415,6 +433,10 @@ public class MessageSectionFragment extends MainFragment
 				this.preferenceService = this.serviceManager.getPreferenceService();
 				this.conversationTagService = this.serviceManager.getConversationTagService();
 				this.lockAppService = this.serviceManager.getLockAppService();
+				UserService userService = serviceManager.getUserService();
+				if (userService != null) {
+					myIdentity = userService.getIdentity();
+				}
 			} catch (MasterKeyLockedException e) {
 				logger.debug("Master Key locked!");
 			} catch (ThreemaException e) {
@@ -1088,6 +1110,14 @@ public class MessageSectionFragment extends MainFragment
 		AnimationUtil.startActivity(getActivity(), TestUtil.empty(filterQuery) ? view : null, intent);
 	}
 
+	@Override
+	public void onJoinGroupCallClick(ConversationModel conversationModel) {
+		GroupModel group = conversationModel.getGroup();
+		if (group != null) {
+			startActivity(GroupCallActivity.getStartOrJoinCallIntent(requireActivity(), group.getId()));
+		}
+	}
+
 	private void editGroup(ConversationModel model, View view) {
 		Intent intent = groupService.getGroupEditIntent(model.getGroup(), activity);
 		intent.putExtra(ThreemaApplication.INTENT_DATA_GROUP, model.getGroup().getId());
@@ -1455,6 +1485,7 @@ public class MessageSectionFragment extends MainFragment
 		ListenerManager.contactSettingsListeners.add(this.contactSettingsListener);
 		ListenerManager.synchronizeContactsListeners.add(this.synchronizeContactsListener);
 		ListenerManager.chatListener.add(this.chatListener);
+		ListenerManager.groupListeners.add(this.groupListener);
 	}
 
 	private void removeListeners() {
@@ -1465,6 +1496,7 @@ public class MessageSectionFragment extends MainFragment
 		ListenerManager.contactSettingsListeners.remove(this.contactSettingsListener);
 		ListenerManager.synchronizeContactsListeners.remove(this.synchronizeContactsListener);
 		ListenerManager.chatListener.remove(this.chatListener);
+		ListenerManager.groupListeners.remove(this.groupListener);
 	}
 
 	private void updateList() {
@@ -1525,6 +1557,7 @@ public class MessageSectionFragment extends MainFragment
 									MessageSectionFragment.this.activity,
 									contactService,
 									groupService,
+									groupCallManager,
 									distributionListService,
 									conversationService,
 									mutedChatsListService,
@@ -1665,9 +1698,9 @@ public class MessageSectionFragment extends MainFragment
 			}
 
 			if (getView() != null) {
-				String snackText = String.format(getString(R.string.message_archived), this.conversationModels.size());
+				int amountArchived = this.conversationModels.size();
+				String snackText = ConfigUtils.getSafeQuantityString(getContext(), R.plurals.message_archived, amountArchived, amountArchived, this.conversationModels.size());
 				this.snackbar = Snackbar.make(getView(), snackText, 7 * (int) DateUtils.SECOND_IN_MILLIS);
-
 				this.snackbar.setAction(R.string.undo, v -> conversationService.unarchive(conversationModels));
 				this.snackbar.addCallback(new Snackbar.Callback() {
 					@Override

+ 22 - 16
app/src/main/java/ch/threema/app/fragments/mediaviews/AudioViewFragment.java

@@ -21,8 +21,8 @@
 
 package ch.threema.app.fragments.mediaviews;
 
-import android.graphics.Bitmap;
-import android.graphics.drawable.BitmapDrawable;
+import android.content.Context;
+import android.graphics.drawable.Drawable;
 import android.net.Uri;
 import android.os.Bundle;
 import android.view.LayoutInflater;
@@ -38,7 +38,8 @@ import com.google.android.exoplayer2.source.ProgressiveMediaSource;
 import com.google.android.exoplayer2.ui.PlayerControlView;
 import com.google.android.exoplayer2.ui.PlayerView;
 import com.google.android.exoplayer2.upstream.DataSource;
-import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
+import com.google.android.exoplayer2.upstream.DefaultDataSource;
+import com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
 import com.google.android.exoplayer2.util.Util;
 
 import org.slf4j.Logger;
@@ -46,13 +47,18 @@ import org.slf4j.Logger;
 import java.io.File;
 import java.lang.ref.WeakReference;
 
+import androidx.annotation.NonNull;
 import androidx.annotation.UiThread;
+import androidx.core.content.res.ResourcesCompat;
 import androidx.core.view.OnApplyWindowInsetsListener;
 import androidx.core.view.ViewCompat;
 import androidx.core.view.WindowInsetsCompat;
 import ch.threema.app.R;
+import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.MediaViewerActivity;
 import ch.threema.app.mediaattacher.PreviewFragmentInterface;
+import ch.threema.app.utils.IconUtil;
+import ch.threema.app.utils.MimeUtil;
 import ch.threema.app.utils.VideoUtil;
 import ch.threema.base.utils.LoggingUtil;
 
@@ -91,16 +97,6 @@ public class AudioViewFragment extends AudioFocusSupportingMediaViewFragment imp
 		return true;
 	}
 
-	@Override
-	protected void showThumbnail(Bitmap thumbnail, boolean isGeneric, String filename) {
-		if (this.audioView != null && this.audioView.get() != null) {
-			this.audioView.get().setDefaultArtwork(new BitmapDrawable(getResources(), thumbnail));
-		}
-	}
-
-	@Override
-	protected void hideThumbnail() {}
-
 	@Override
 	protected void handleDecryptingFile() {
 		if (progressBarRef.get() != null) {
@@ -135,10 +131,12 @@ public class AudioViewFragment extends AudioFocusSupportingMediaViewFragment imp
 			audioView.setControllerHideOnTouch(true);
 			audioView.setControllerShowTimeoutMs(-1);
 			audioView.setControllerAutoShow(true);
+			audioView.setDefaultArtwork(ResourcesCompat.getDrawable(getResources(), IconUtil.getMimeCategoryIcon(MimeUtil.MimeCategory.AUDIO), ThreemaApplication.getAppContext().getTheme()));
 			View controllerView = audioView.findViewById(R.id.position_container);
 			ViewCompat.setOnApplyWindowInsetsListener(controllerView, new OnApplyWindowInsetsListener() {
+				@NonNull
 				@Override
-				public WindowInsetsCompat onApplyWindowInsets(View v, WindowInsetsCompat insets) {
+				public WindowInsetsCompat onApplyWindowInsets(@NonNull View v, @NonNull WindowInsetsCompat insets) {
 					ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) v.getLayoutParams();
 					params.leftMargin = insets.getSystemWindowInsetLeft();
 					params.rightMargin = insets.getSystemWindowInsetRight();
@@ -151,6 +149,13 @@ public class AudioViewFragment extends AudioFocusSupportingMediaViewFragment imp
 		this.progressBarRef = new WeakReference<>(rootViewReference.get().findViewById(R.id.progress_bar));
 	}
 
+	@Override
+	protected void showThumbnail(@NonNull Drawable thumbnail) {
+		if (this.audioView != null && this.audioView.get() != null) {
+			this.audioView.get().setDefaultArtwork(thumbnail);
+		}
+	}
+
 	@Override
 	protected void handleDecryptedFile(final File file) {
 		if (this.isAdded()) {
@@ -174,8 +179,9 @@ public class AudioViewFragment extends AudioFocusSupportingMediaViewFragment imp
 	}
 
 	private void loadAudio(Uri audioUri) {
-		if (this.audioPlayer != null) {
-			DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(getContext(), Util.getUserAgent(getContext(), getContext().getString(R.string.app_name)));
+		Context context = getContext();
+		if (this.audioPlayer != null && context != null) {
+			DataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(Util.getUserAgent(context, getContext().getString(R.string.app_name))));
 			MediaSource audioSource = new ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(audioUri));
 
 			this.audioPlayer.setPlayWhenReady(this.isImmediatePlay);

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