瀏覽代碼

Version 5.3.2

Threema 1 年之前
父節點
當前提交
6c11d963a9
共有 100 個文件被更改,包括 2968 次插入3308 次删除
  1. 1796 0
      app/assets/emojis/search-index/gsw.csv
  2. 6 13
      app/assets/license.html
  3. 5 3
      app/build.gradle
  4. 0 18
      app/jni/Android.mk
  5. 0 338
      app/jni/scrypt/c/crypto_scrypt-nosse.c
  6. 0 114
      app/jni/scrypt/c/scrypt_jni.c
  7. 0 412
      app/jni/scrypt/c/sha256.c
  8. 0 10
      app/jni/scrypt/include/config.h
  9. 0 46
      app/jni/scrypt/include/crypto_scrypt.h
  10. 0 12
      app/jni/scrypt/include/scrypt_platform.h
  11. 0 62
      app/jni/scrypt/include/sha256.h
  12. 0 140
      app/jni/scrypt/include/sysendian.h
  13. 6 13
      app/src/foss_based/assets/license.html
  14. 1 1
      app/src/google_services_based/java/ch/threema/app/services/VoiceActionService.java
  15. 2 0
      app/src/libre/play/listings/de/full-description.txt
  16. 2 0
      app/src/libre/play/listings/en-US/full-description.txt
  17. 3 3
      app/src/main/AndroidManifest.xml
  18. 21 36
      app/src/main/java/ch/threema/app/ThreemaApplication.java
  19. 2 0
      app/src/main/java/ch/threema/app/activities/EnterSerialActivity.java
  20. 32 2
      app/src/main/java/ch/threema/app/activities/HomeActivity.java
  21. 1 4
      app/src/main/java/ch/threema/app/activities/MediaViewerActivity.java
  22. 21 52
      app/src/main/java/ch/threema/app/adapters/ComposeMessageAdapter.java
  23. 2 1
      app/src/main/java/ch/threema/app/adapters/MediaGalleryAdapter.kt
  24. 10 7
      app/src/main/java/ch/threema/app/adapters/SendMediaPreviewAdapter.kt
  25. 0 266
      app/src/main/java/ch/threema/app/adapters/decorators/AnimGifChatAdapterDecorator.java
  26. 33 12
      app/src/main/java/ch/threema/app/adapters/decorators/AnimatedImageDrawableDecorator.java
  27. 0 4
      app/src/main/java/ch/threema/app/adapters/decorators/ChatAdapterDecorator.java
  28. 18 0
      app/src/main/java/ch/threema/app/adapters/decorators/TextChatAdapterDecorator.java
  29. 1 1
      app/src/main/java/ch/threema/app/emojis/EmojiMarkupUtil.java
  30. 19 26
      app/src/main/java/ch/threema/app/fragments/BigMediaFragment.kt
  31. 151 95
      app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java
  32. 2 0
      app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java
  33. 3 3
      app/src/main/java/ch/threema/app/fragments/MessageSectionFragment.java
  34. 0 93
      app/src/main/java/ch/threema/app/fragments/mediaviews/GifViewFragment.kt
  35. 28 15
      app/src/main/java/ch/threema/app/fragments/mediaviews/ImageViewFragment.java
  36. 1 1
      app/src/main/java/ch/threema/app/glide/AvatarGlideModule.java
  37. 6 6
      app/src/main/java/ch/threema/app/globalsearch/GlobalSearchAdapter.java
  38. 7 11
      app/src/main/java/ch/threema/app/managers/ServiceManager.java
  39. 11 27
      app/src/main/java/ch/threema/app/mediaattacher/ImagePreviewFragment.java
  40. 3 6
      app/src/main/java/ch/threema/app/mediaattacher/ImagePreviewPagerAdapter.java
  41. 3 4
      app/src/main/java/ch/threema/app/mediaattacher/MediaAttachAdapter.java
  42. 22 2
      app/src/main/java/ch/threema/app/mediaattacher/MediaAttachItem.java
  43. 7 8
      app/src/main/java/ch/threema/app/mediaattacher/MediaRepository.java
  44. 1 2
      app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionActivity.java
  45. 39 40
      app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionBaseActivity.java
  46. 2 8
      app/src/main/java/ch/threema/app/mediaattacher/PreviewFragment.java
  47. 1 1
      app/src/main/java/ch/threema/app/mediaattacher/VideoPreviewFragment.java
  48. 4 0
      app/src/main/java/ch/threema/app/messagereceiver/ContactMessageReceiver.java
  49. 4 0
      app/src/main/java/ch/threema/app/messagereceiver/GroupMessageReceiver.java
  50. 11 2
      app/src/main/java/ch/threema/app/preference/SettingsRateFragment.java
  51. 1 0
      app/src/main/java/ch/threema/app/preference/SettingsSummaryFragment.kt
  52. 2 2
      app/src/main/java/ch/threema/app/routines/SynchronizeContactsRoutine.java
  53. 33 22
      app/src/main/java/ch/threema/app/routines/UpdateWorkInfoRoutine.java
  54. 136 23
      app/src/main/java/ch/threema/app/services/AppRestrictionService.java
  55. 1 3
      app/src/main/java/ch/threema/app/services/AvatarCacheServiceImpl.java
  56. 2 10
      app/src/main/java/ch/threema/app/services/ConversationServiceImpl.java
  57. 14 13
      app/src/main/java/ch/threema/app/services/ConversationTagService.java
  58. 22 28
      app/src/main/java/ch/threema/app/services/ConversationTagServiceImpl.java
  59. 11 3
      app/src/main/java/ch/threema/app/services/DistributionListServiceImpl.java
  60. 5 0
      app/src/main/java/ch/threema/app/services/GroupServiceImpl.java
  61. 2 6
      app/src/main/java/ch/threema/app/services/MessageServiceImpl.java
  62. 13 17
      app/src/main/java/ch/threema/app/services/license/LicenseService.java
  63. 1 1
      app/src/main/java/ch/threema/app/services/license/LicenseServiceSerial.java
  64. 63 17
      app/src/main/java/ch/threema/app/services/license/LicenseServiceThreema.java
  65. 3 1
      app/src/main/java/ch/threema/app/services/license/LicenseServiceUser.java
  66. 21 1
      app/src/main/java/ch/threema/app/services/license/UserCredentials.java
  67. 46 32
      app/src/main/java/ch/threema/app/services/messageplayer/AnimatedImageDrawableMessagePlayer.java
  68. 0 198
      app/src/main/java/ch/threema/app/services/messageplayer/GifMessagePlayer.java
  69. 4 16
      app/src/main/java/ch/threema/app/services/messageplayer/MessagePlayerServiceImpl.java
  70. 10 2
      app/src/main/java/ch/threema/app/services/systemupdate/SystemUpdateToVersion89.kt
  71. 50 0
      app/src/main/java/ch/threema/app/services/systemupdate/SystemUpdateToVersion92.kt
  72. 28 12
      app/src/main/java/ch/threema/app/tasks/OutgoingFileMessageTask.kt
  73. 1 1
      app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeServerTestResponse.java
  74. 4 5
      app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeServiceImpl.java
  75. 12 6
      app/src/main/java/ch/threema/app/ui/AckjiPopup.java
  76. 1 1
      app/src/main/java/ch/threema/app/ui/ContentCommitComposeEditText.java
  77. 6 7
      app/src/main/java/ch/threema/app/ui/MediaItem.java
  78. 42 19
      app/src/main/java/ch/threema/app/ui/QuotePopup.kt
  79. 0 50
      app/src/main/java/ch/threema/app/ui/SquareGifView.java
  80. 3 4
      app/src/main/java/ch/threema/app/utils/ConfigUtils.java
  81. 3 3
      app/src/main/java/ch/threema/app/utils/FileUtil.java
  82. 1 4
      app/src/main/java/ch/threema/app/utils/LinkifyUtil.java
  83. 0 5
      app/src/main/java/ch/threema/app/utils/LocaleUtil.java
  84. 25 9
      app/src/main/java/ch/threema/app/utils/MimeUtil.java
  85. 1 1
      app/src/main/java/ch/threema/app/utils/ThumbnailUtil.java
  86. 30 53
      app/src/main/java/ch/threema/app/video/transcoder/VideoConfig.java
  87. 7 6
      app/src/main/java/ch/threema/app/voip/groupcall/service/GroupCallService.kt
  88. 8 1
      app/src/main/java/ch/threema/app/voip/services/VoipCallService.java
  89. 2 1
      app/src/main/java/ch/threema/app/webclient/converter/MessageState.java
  90. 1 1
      app/src/main/java/ch/threema/app/webclient/services/instance/message/receiver/ActiveConversationHandler.java
  91. 24 11
      app/src/main/java/ch/threema/app/webclient/services/instance/message/receiver/FileMessageCreateHandler.java
  92. 2 2
      app/src/main/java/ch/threema/app/webclient/services/instance/message/receiver/ModifyConversationHandler.java
  93. 3 4
      app/src/main/java/ch/threema/localcrypto/MasterKey.java
  94. 5 1
      app/src/main/java/ch/threema/storage/DatabaseServiceNew.java
  95. 32 48
      app/src/main/java/ch/threema/storage/factories/ConversationTagFactory.java
  96. 0 157
      app/src/main/java/com/lambdaworks/codec/Base64.java
  97. 0 89
      app/src/main/java/com/lambdaworks/crypto/PBKDF.java
  98. 0 235
      app/src/main/java/com/lambdaworks/crypto/SCrypt.java
  99. 0 236
      app/src/main/java/com/lambdaworks/crypto/SCryptUtil.java
  100. 0 21
      app/src/main/java/com/lambdaworks/jni/LibraryLoader.java

+ 1796 - 0
app/assets/emojis/search-index/gsw.csv

@@ -0,0 +1,1796 @@
+⁉️|ausrufe- und fragezeichen|ausrufezeichen|fragezeichen|rot|satzzeichen
+ℹ️|buchstabe i in blauem quadrat|information
+↔️|entgegengesetzt|nach links und rechts|pfeil nach links und rechts
+↕️|entgegengesetzt|nach oben und unten|pfeil nach oben und unten
+↖️|nach links oben|nordwesten|pfeil nach links oben
+↗️|nach rechts oben|nordosten|pfeil nach rechts oben
+↘️|nach rechts unten|pfeil nach rechts unten|südosten
+↙️|nach links unten|pfeil nach links unten
+⌨️|computer|tastatur
+☀️|sonnenstrahlen|sonnig|strahlen|wetter
+☁️|wetter|wolke|wolkig
+☂️|bekleidung|regenschirm|wetter
+☃️|schneemann im schnee
+☄️|komet|weltall
+☑️|✓|abgehaktes kästchen|feld|kästchen mit häkchen
+☔|regenschirm im regen
+☕|dampfend|getränk|heißgetränk|kaffee|tee|trinken|heissgetränk
+☘️|kleeblatt|pflanze
+☠️|gesicht|piratenflagge|tod|totenkopf mit gekreuzten knochen
+☢️|radioaktivität
+☣️|biogefährdung|zeichen
+☦️|christentum|kreuz|orthodoxes kreuz|religion
+☸️|buddhismus|dharma-rad
+☹️|düsteres gesicht|gesicht|traurig
+♈|sternzeichen|widder (sternzeichen)
+♉|sternzeichen|stier (sternzeichen)
+♐|schütze (sternzeichen)|sternzeichen
+♑|steinbock (sternzeichen)|sternzeichen
+♒|sternzeichen|wassermann (sternzeichen)
+♓|fische (sternzeichen)|sternzeichen
+♠️|kartenspiel|pik
+♣️|kartenspiel|kreuz
+♥️|herz|kartenspiel
+♦️|karo|kartenspiel
+♨️|dampfend|heiße quellen|quellen|heisse quellen
+⚒️|hammer und pickel|pickel|werkzeug
+⚓|anker|hafen|meer
+⚔️|gekreuzte schwerter|schwerter
+⚖️|gerechtigkeit|gewicht|waage|werkzeug|wiegen
+⚗️|destillierapparat|werkzeug|labor
+⚙️|werkzeug|zahnrad
+✂️|schere|basteln|schneiden
+✅|abgehakt|erledigt|weißes häkchen|weisses häkchen
+✈️|flieger|flugzeug
+✉️|briefumschlag|e-mail
+✒️|federhalter|füller|schwarzer federhalter|stift
+✔️|abhaken|erledigt|häkchen|kräftiges häkchen
+✖️|×|abbrechen|mal|multiplikationszeichen|multiplizieren|x
+✡️|davidstern|jüdisch|religion
+✨|*|funkelnde sterne|sterne
+✳️|*|achtzackiger stern|stern
+✴️|*|achtstrahliger stern|stern
+❄️|flocke|schneeflocke
+❇️|*|funkeln
+❓|fragezeichen|rotes fragezeichen|satzzeichen
+❔|satzzeichen|weißes fragezeichen|weisses fragezeichen
+❕|satzzeichen|weißes ausrufezeichen|weisses ausrufezeichen
+❗|ausrufezeichen|rotes ausrufezeichen|satzzeichen
+❣️|ausrufezeichen|herz als ausrufezeichen|satzzeichen
+❤️|herz|rotes herz
+➕|+|pluszeichen
+➖|-|−|minuszeichen
+➗|÷|division|geteilt durch|geteiltzeichen
+⤴️|geschwungener pfeil nach oben|nach oben|oben|pfeil
+⤵️|geschwungener pfeil nach unten|nach unten|pfeil|unten
+〰️|gewellt|linie|wellenlinie
+㊗️|gratulation|japanisches schriftzeichen|schriftzeichen für gratulation
+㊙️|geheimnis|japanisches schriftzeichen|schriftzeichen für geheimnis
+😀|gesicht|grinsendes gesicht|lol|lustig
+😃|gesicht|grinsendes gesicht mit großen augen|lächeln|lol|lustig|grinsendes gesicht mit grossen augen
+😄|gesicht|grinsendes gesicht mit lachenden augen|lol|lustig
+😁|gesicht|lustig|strahlendes gesicht mit lachenden augen|zähne
+😆|geschlossene augen|gesicht|grinsegesicht mit zugekniffenen augen|grinsendes gesicht mit zusammengekniffenen augen|offener mund
+😅|gesicht|grinsendes gesicht mit schweißtropfen|lustig|schweiß|schwitzen|lachender smiley mit kaltem schweiss|schweiss
+🤣|gesicht|lachen|sich vor lachen auf dem boden wälzen|rofl
+😂|gesicht mit freudentränen|lachen|tränen
+🙂|gesicht|lächelnd|leicht lächelndes gesicht
+🙃|auf dem kopf stehen|gesicht|umgekehrtes gesicht
+🫠|auflösen|flüssig|gesicht|schmelzendes gesicht|verschwinden|verflüssigen
+😉|gesicht|zwinkerndes gesicht
+😊|erröten|freude|gesicht|lächelndes gesicht mit lachenden augen|rote wangen
+😇|gesicht|heiligenschein|lächelndes gesicht mit heiligenschein
+🥰|anhimmeln|lächelndes gesicht mit herzen|verknallt|verliebt
+😍|gesicht|lächelndes gesicht mit herzförmigen augen|verliebt
+🤩|augen|gesicht|grinsen|stern|überwältigt
+😘|gesicht|kuss zuwerfendes gesicht
+😗|gesicht|kuss|küssendes gesicht
+☺️|fröhlich|lächelndes gesicht
+😚|gesicht|küssendes gesicht mit geschlossenen augen|rote wangen
+😙|gesicht|kuss|küssendes gesicht mit lächelnden augen|lächelnde augen
+🥲|berührt|dankbar|erleichtert|lächelnd|lachendes gesicht mit träne|stolz|träne
+😋|gesicht|leckeres essen|sich die lippen leckendes gesicht
+😛|gesicht mit herausgestreckter zunge|herausgestreckte zunge
+😜|gesicht|herausgestreckte zunge|zwinkerndes gesicht mit herausgestreckter zunge
+🤪|auge|groß|irres gesicht|klein|gross
+😝|gesicht mit herausgestreckter zunge und zusammengekniffenen augen|herausgestreckte zunge
+🤑|geld|gesicht mit dollarzeichen|zunge
+🤗|gesicht mit umarmenden händen|umarmen|umarmung
+🤭|huch|verlegen kicherndes gesicht
+🫢|erschrecken|erstaunen|gesicht mit offenen augen und hand über dem mund|überraschung|unglauben|verlegenheit
+🫣|gebannt|gesicht mit durch die finger linsendem auge|linsen|spähen|starren
+🤫|ermahnendes gesicht|leise|pst
+🤔|gesicht|nachdenkendes gesicht|nachdenklich
+🫡|aye, aye|gesicht|militär|ok|respekt|salutierendes gesicht|aye aye|grüssendes gesicht|jawohl|truppen
+🤐|gesicht mit reißverschlussmund|mund|reißverschluss|reissverschluss|smiley mit reissverschlussmund
+🤨|argwöhnisch|gesicht mit hochgezogenen augenbrauen|skeptisch
+😐|gesicht|kein kommentar|neutrales gesicht
+😑|ausdrucksloses gesicht|gesicht|kein kommentar
+😶|gesicht ohne mund|kein mund|sprachlos
+🫥|deprimiert|gesicht|gestricheltes gesicht|introvertiert|unsichtbar|verschwinden|verstecken
+😶‍🌫️|gesicht in wolken|kopf in den wolken
+😏|gesicht|süffisant lächelndes gesicht
+😒|gesicht|unglücklich|verstimmtes gesicht
+🙄|augen verdrehendes gesicht|gesicht
+😬|gesicht|grimassen schneidendes gesicht|zähne
+😮‍💨|gesicht, das ausatmet|ausatmendes gesicht
+🤥|gesicht|lügendes gesicht|pinocchio-nase
+🫨|erdbeben|gesicht|schock|zitterndes doppelgesicht
+😌|erleichtertes gesicht|geschlossene augen|gesicht
+😔|gesicht|nachdenkliches gesicht
+😪|gesicht|müde|schläfriges gesicht
+🤤|gesicht|sabberndes gesicht
+😴|gesicht|schlafendes gesicht|schnarchen|zzz
+😷|arzt|gesicht mit atemschutzmaske|krankheit
+🤒|fieberthermometer|gesicht mit fieberthermometer|krank
+🤕|gesicht mit kopfverband|schmerzen|verband|verletzung
+🤢|erbrechen|gesicht|übelkeit|würgendes gesicht
+🤮|kotzendes gesicht|krank
+🤧|gesicht|niesendes gesicht
+🥵|erhitzt|fieber|heiß|hitzschlag|schwitzendes gesicht|heiss|schweiss|schweiß
+🥶|eiszapfen|frierendes gesicht|frostbeule|kalt
+🥴|angetrunken|beschwipst|betrunken|schwindeliges gesicht
+😵|benommenes gesicht|gesicht
+😵‍💫|gesicht mit spiralen als augen|gesicht mit spiralaugen
+🤯|entsetzt|explodierender kopf|geschockt
+🤠|cowboy|gesicht mit cowboyhut|hut
+🥳|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
+🧐|gesicht mit monokel|monokel
+😕|gesicht|verwundertes gesicht
+🫤|enttäuscht|gesicht mit schrägem mund|langweilig|na ja|skeptisch|unsicher
+😟|besorgtes gesicht|gesicht
+🙁|betrübtes gesicht|gesicht|traurig
+😮|erstaunt|gesicht mit offenem mund|offener mund
+😯|erstaunt|gesicht|sprachlos|verdutztes gesicht
+😲|erstauntes gesicht|gesicht
+😳|errötetes gesicht mit großen augen|gesicht|rote wangen|überrascht|erröteter smiley mit grossen augen
+🥺|bettelndes gesicht|gnade|welpenaugen
+🥹|aufgebracht|gesicht, das tränen zurückhält|stolz|tränen zurückhalten|traurig|weinen
+😦|entsetztes gesicht|gesicht|offener mund|verwundert
+😧|gesicht|leidend|qualvolles gesicht
+😨|ängstliches gesicht|gesicht|angst
+😰|besorgtes gesicht mit schweißtropfen|gesicht|kalter schweiß|offener mund|kalter schweiss|smiley mit offenem mund und kaltem schweiss|schweiss|schweiß
+😥|enttäuscht|erleichtert|gesicht|schweiß|trauriges aber erleichtertes gesicht|schweiss
+😢|gesicht|träne|traurig|weinendes gesicht
+😭|gesicht|heulendes gesicht|tränen|traurig
+😱|angst|gesicht|schreien|vor angst schreiendes gesicht
+😖|gesicht|verwirrtes gesicht
+😣|durchhalten|entschlossenes gesicht|gesicht
+😞|enttäuschtes gesicht|gesicht|traurig
+😓|angstschweiß|bedrücktes gesicht mit schweiß|gesicht|geschlossene augen|smiley mit kaltem schweiss|schweiss|schweiß
+😩|erschöpftes gesicht|gesicht|müde
+😫|gesicht|müdes gesicht
+🥱|gähnendes gesicht|gelangweilt|müde
+😤|erleichtert|gesicht|gewonnen|schnaubendes gesicht
+😡|gesicht|rot|schmollendes gesicht|wütend
+😠|gesicht|verärgertes gesicht
+🤬|fluchen|gesicht mit symbolen über dem mund
+😈|grinsendes gesicht mit hörnern|teufel
+👿|fantasy|gesicht|teufelchen|wütendes gesicht mit hörnern
+💀|gesicht|tod|totenkopf
+💩|kothaufen|mist
+🤡|clown-gesicht|gesicht
+👹|gesicht|japan|märchen|monster|ungeheuer
+👺|gesicht|japan|kobold|märchen|monster|tengu
+👻|fantasy|gesicht|gespenst|märchen
+👽|alien|außerirdischer|gesicht|ufo|ausserirdischer|ausserirdisches wesen
+👾|computerspiel-monster|gesicht|monster|ufo
+🤖|gesicht|monster|roboterkopf
+😺|gesicht|grinsende katze|grinsendes katzengesicht|katze|lol|lustig
+😸|gesicht|grinsende katze mit lachenden augen|grinsendes katzengesicht mit lachenden augen|katze
+😹|gesicht|katze mit freudentränen|katzengesicht mit freudentränen|lachen|tränen
+😻|gesicht|katze|lachende katze mit herzen als augen|lachendes katzengesicht mit herzen als augen|verliebt
+😼|gesicht|ironisch|katze|verwegen lächelnde katze|verwegen lächelndes katzengesicht
+😽|gesicht|katze|küssende katze|küssendes katzengesicht|rote wangen
+🙀|angst|erschöpfte katze|erschöpftes katzengesicht|gesicht|katze|schreien
+😿|gesicht|katze|träne|traurig|weinende katze|weinendes katzengesicht
+😾|gesicht|katze|schmollende katze|schmollendes katzengesicht|verärgert
+🙈|affe|nichts sehen|sich die augen zuhaltendes affengesicht|verboten
+🙉|affe|nichts hören|sich die ohren zuhaltendes affengesicht|verboten
+🙊|affe|nichts sagen|sich den mund zuhaltendes affengesicht|verboten
+💌|brief|herz|liebesbrief
+💘|herz mit pfeil|liebe|pfeil
+💝|herz mit schleife|schleife|valentinstag
+💖|aufregung|funkelndes herz|liebe
+💗|aufregung|liebe|nervosität|wachsendes herz
+💓|herz|liebe|schlagendes herz
+💞|kreisende herzen|liebe
+💕|herz|liebe|zwei herzen
+💟|herzdekoration
+💔|gebrochenes herz|schmerz|trennung
+❤️‍🔥|herz in flammen|brennendes herz
+❤️‍🩹|herz mit verband|herz mit pflaster
+🩷|flirten|herz|pinkes herz|süß|verliebt
+🧡|oranges herz
+💛|gelbes herz|herz
+💚|grünes herz|herz
+💙|blaues herz|herz
+🩵|aquamarin|hellblaues herz|herz|türkis
+💜|herz|lila|violett
+🤎|braunes herz|herz
+🖤|böse|herz|schwarzes herz
+🩶|graues herz|herz|schieferfarben|silberfarben
+🤍|herz|weißes herz|weisses herz
+💋|kussabdruck|lippen
+💯|100 punkte|punktestand|volle punktzahl
+💢|ärger|comic|wut
+💥|comic|kollision|zusammenstoß|zusammenstoss
+💫|benommenheit|comic|schwindlig|sterne sehen
+💦|comic|schweißtropfen|schweisstropfen
+💨|comic|rennen|staubwolke|weglaufen
+🕳️|loch|schwarz
+💬|dialog|gespräch|sprechblase mit drei punkten|unterhaltung
+👁️‍🗨️|auge in sprechblase|dialog|reden|sprechen
+🗨️|dialog|reden|sprechblase links|sprechen|unterhaltung
+🗯️|sprechblase für wütende aussage rechts|wütend
+💭|comic|gedankenblase|nachdenken
+💤|comic|schlafen|schnarchen|zzz
+👋|hand|winkende hand
+🤚|erhobene hand von hinten|erhobener handrücken|hand
+🖐️|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
+🫱|hand|nach rechts weisende hand|rechts
+🫲|hand|links|nach links weisende hand
+🫳|abweisen|hand mit handfläche nach unten|handgeste|hau ab|von sich weisen|wegscheuchen|wegschicken
+🫴|anbieten|darbieten|einladen|hand mit handfläche nach oben|handgeste|kommen|locken
+🫷|high five|nach links schiebende hand|schieben|stopp|warte mal|drücken|nach links drückende hand|nein|warten
+🫸|high 5|high five|nach rechts schiebende hand|schieben|stopp|warte mal|drücken|nach rechts drückende hand|nein|warten
+👌|exzellent|hand|in ordnung|ok-zeichen|perfekt
+🤌|bündelhand|geht’s noch?|handgeste|was soll das?|was willst du?|zusammengedrückte finger|zusammengelegte fingerspitzen
+🤏|kleine menge|kleiner betrag|unbedeutend|wenig-geste
+✌️|sieg|victory-geste
+🤞|finger|gekreuzt|hand mit gekreuzten fingern
+🫰|fingerherz|geld|hand mit gekreuztem zeigefinger und daumen|handgeste|teuer
+🤟|hand|ich liebe dich|ich-liebe-dich-geste
+🤘|finger|hand|hörner|rock|teufelsgruß|teufelsgruss
+🤙|anrufen|hand|ruf-mich-an-handzeichen
+👈|finger|handrückseite|links|nach links weisender zeigefinger
+👉|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|runter
+☝️|finger|handvorderseite|nach oben weisender zeigefinger von vorne|zeigefinger|hoch
+🫵|auf betrachter zeigender zeigefinger|du|handgeste|mit finger zeigen|sie|zeigen
+👍|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
+🤜|faust nach rechts|nach rechts
+👏|applaudieren|beifall|hände|klatschende hände
+🙌|feiern|zwei erhobene handflächen
+🫶|hände, die herz bilden|händeherz|handgeste|herz|liebe
+👐|hände|offene hände
+🤲|beten|handflächen nach oben
+🤝|händeschütteln|handschlag|vereinbarung
+🙏|betende hände|bitten|danken|gebet|grüßen|zusammengelegte handflächen|grüssen
+✍️|hand|schreibende hand
+💅|kosmetik|maniküre|nagellack|nagelpflege
+🤳|selfie|smartphone
+💪|angespannter bizeps|comic|muskeln anspannen|stark
+🦾|armprothese|barrierefreiheit|prothese
+🦿|barrierefreiheit|beinprothese|prothese
+🦵|bein|treten|tritt
+🦶|fuß|stampfen|treten|fuss|kick
+👂|körperteil|ohr
+🦻|barrierefreiheit|gehörlos|hörgerät|hörhilfe|ohr mit hörgerät|taub
+👃|körperteil|nase
+🧠|gehirn|intelligent
+🫀|herz (organ)|herzschlag|mitte|organ|pulsieren
+🫁|atem|atmen|ausatmen|einatmen|lungenflügel|organ
+🦷|zahn ziehen|zahnarzt|zahnärztin
+🦴|knochen|skelett
+👀|augen|gesicht
+👁️|auge|körperteil
+👅|körperteil|zunge
+👄|körperteil|lippen|mund
+🫦|angst|ängstlich|auf lippe beißen|besorgt|flirtend|nervös|unbehaglich|auf lippe beissen
+👶|baby|gesicht
+🧒|geschlechtsneutral|jung|kind
+👦|gesicht|junge|männlich
+👧|gesicht|mädchen|weiblich
+🧑|erwachsene person|geschlechtsneutral|mensch|ohne eindeutiges geschlecht|person
+👱|blonde haare|blonde person|gesicht|person: blondes haar
+👨|mann|männlich
+🧔|bart|person mit bart|person: bart
+🧔‍♂️|mann: bart
+👱‍♂️|blonder mann|gesicht|haar|mann: blond|männlich
+👩|frau|weiblich
+🧔‍♀️|frau: bart
+👱‍♀️|blonde frau|frau: blond|gesicht|haar|weiblich
+🧓|alt|ältere person|älterer mensch|erwachsene person|geschlechtsneutral|ohne eindeutiges geschlecht
+👴|älterer mann|gesicht|mann|senior|männlich
+👵|ältere frau|frau|gesicht|seniorin|weiblich
+🙍|gesicht|missmutige person
+🙍‍♂️|gesicht|mann|missmutiger mann|stirn runzeln|männlich
+🙍‍♀️|frau|gesicht|missmutige frau|stirn runzeln|weiblich
+🙎|schmollende person
+🙎‍♂️|gesicht|mann|schmollender mann|männlich
+🙎‍♀️|frau|gesicht|schmollende frau|weiblich
+🙅|person mit überkreuzten armen|verboten|x
+🙅‍♂️|arme|gesicht|mann mit überkreuzten armen|männlich
+🙅‍♀️|arme|frau mit überkreuzten armen|gesicht|weiblich
+🙆|alles in ordnung|o|person mit händen auf dem kopf
+🙆‍♂️|arme|gesicht|mann mit händen auf dem kopf|männlich
+🙆‍♀️|arme|frau mit händen auf dem kopf|gesicht|weiblich
+💁|gesicht|hilfe|informationen|infoschalter-mitarbeiter(in)
+💁‍♂️|auskunft|informationen|infoschalter-mitarbeiter|mann|männlich
+💁‍♀️|auskunft|frau|informationen|infoschalter-mitarbeiterin|weiblich
+🙋|person mit erhobenem arm|siegerpose
+🙋‍♂️|geste|mann mit erhobenem arm|siegerpose|männlich
+🙋‍♀️|frau mit erhobenem arm|geste|siegerpose|weiblich
+🧏|barrierefreiheit|gehörlose person|hören|ohr|taub
+🧏‍♂️|gehörloser mann|mann|taub|männlich
+🧏‍♀️|frau|gehörlose frau|taub|weiblich
+🙇|entschuldigung|geste|sich verbeugende person|verbeugen
+🙇‍♂️|demut|mann|sich verbeugender mann|verbeugen|männlich
+🙇‍♀️|demut|frau|sich verbeugende frau|verbeugen|weiblich
+🤦|frustriert|genervt|gesicht|sich an den kopf fassende person
+🤦‍♂️|frustriert|genervt|mann|sich an den kopf fassender mann|männlich
+🤦‍♀️|frau|frustriert|genervt|sich an den kopf fassende frau|weiblich
+🤷|egal|gleichgültig|keine ahnung|schulterzuckende person|zweifel
+🤷‍♂️|gleichgültig|keine ahnung|mann|schulterzuckender mann|zweifel|männlich
+🤷‍♀️|frau|gleichgültig|keine ahnung|schulterzuckende frau|zweifel|weiblich
+🧑‍⚕️|arzt/ärztin|gesundheitswesen|krankenschwester|therapeut
+👨‍⚕️|arztkittel|doktor
+👩‍⚕️|ärztin|arztkittel
+🧑‍🎓|absolvent|student(in)
+👨‍🎓|absolvent|doktorhut|student|uni
+👩‍🎓|absolventin|doktorhut|studentin|uni
+🧑‍🏫|dozent|lehrer(in)|professor
+👨‍🏫|dozent|lehrer|professor
+👩‍🏫|dozentin|lehrerin|professorin
+🧑‍⚖️|richter(in)|waage
+👨‍⚖️|gerechtigkeit|recht|richter
+👩‍⚖️|gerechtigkeit|recht|richterin
+🧑‍🌾|bauer/bäuerin|farmer|gärtner|landwirt
+👨‍🌾|ähre|bauer|landwirt
+👩‍🌾|ähre|bäuerin|landwirtin
+🧑‍🍳|koch/köchin
+👨‍🍳|kochen
+👩‍🍳|kochen|köchin
+🧑‍🔧|elektriker|klempner|mechaniker(in)
+👨‍🔧|elektriker|handwerker|klempner|mechaniker
+👩‍🔧|elektrikerin|handwerkerin|klempnerin|mechanikerin
+🧑‍🏭|arbeiter|fabrikarbeiter(in)|industriell|montage
+👨‍🏭|fabrikarbeiter
+👩‍🏭|fabrikarbeiterin
+🧑‍💼|architekt|büroangestellte(r)|business|manager|schlips und kragen
+👨‍💼|angestellter|büroangestellter|manager
+👩‍💼|angestellte|büroangestellte|mangerin
+🧑‍🔬|biologe|chemiker|ingenieur|physiker|wissenschaftler(in)
+👨‍🔬|forscher|labor|wissenschaftler
+👩‍🔬|forscherin|labor|wissenschaftlerin
+🧑‍💻|entwickler|erfinder|it-experte/it-expertin|programmierer|software|technologe
+👨‍💻|bildschirm|computer|entwickler|it-experte
+👩‍💻|bildschirm|computer|entwickler|it-expertin
+🧑‍🎤|entertainer|rock|sänger(in)|schauspieler|star
+👨‍🎤|mann|mikrofon|popstar|sänger|männlich
+👩‍🎤|frau|mikrofon|popstar|sängerin|weiblich
+🧑‍🎨|künstler(in)|palette
+👨‍🎨|farbpalette|künstler|maler|mann|männlich
+👩‍🎨|farbpalette|frau|künstlerin|malerin|weiblich
+🧑‍✈️|flugzeug|pilot(in)
+👨‍✈️|flugzeug|mann|pilot|männlich
+👩‍✈️|flugzeug|frau|pilotin|weiblich
+🧑‍🚀|astronaut(in)|rakete
+👨‍🚀|astronaut|mann|raumfahrt|weltraum|männlich
+👩‍🚀|astronautin|frau|raumfahrt|weltraum|weiblich
+🧑‍🚒|feuerwehrfahrzeug|feuerwehrmann/-frau
+👨‍🚒|feuerwehrhelm|feuerwehrmann|mann|männlich
+👩‍🚒|feuerwehrfrau|feuerwehrhelm|frau|weiblich
+👮|gesicht|polizei|polizist(in)
+👮‍♂️|mann|polizei|polizist|männlich
+👮‍♀️|frau|polizei|polizistin|weiblich
+🕵️|detektiv(in)|spion
+🕵️‍♂️|detektiv|mann|spion|männlich
+🕵️‍♀️|detektivin|frau|spionin|weiblich
+💂|buckingham palace|wache|wachfrau|wachmann|wachsoldatin
+💂‍♂️|buckingham palace|wache|wachsoldat
+💂‍♀️|buckingham palace|wachsoldatin
+🥷|ausdauer|kämpfer|ninja|vermummt
+👷|bauarbeiter(in)|gesicht|helm
+👷‍♂️|bauarbeiter|baustelle|helm
+👷‍♀️|bauarbeiterin|baustelle|helm|mann|männlich
+🫅|adelig|königliche hoheit|monarchin|person mit krone
+🤴|prinz
+👸|gesicht|krone|märchen|prinzessin
+👳|person mit turban|turban
+👳‍♂️|gesicht|mann mit turban|turban|männlich
+👳‍♀️|frau mit turban|gesicht|turban|weiblich
+👲|china|gesicht|hut|mann mit chinesischem hut|männlich
+🧕|frau mit kopftuch|hidschab|kopftuch|mantilla|tichel|weiblich
+🤵|bräutigam|person im smoking|smoking
+🤵‍♂️|mann im smoking|smoking|männlich
+🤵‍♀️|frau im smoking|smoking|weiblich
+👰|braut|hochzeit|person mit schleier|schleier
+👰‍♂️|mann mit schleier|schleier|männlich
+👰‍♀️|frau mit schleier|schleier|weiblich
+🤰|frau|schwangere frau|weiblich
+🫃|aufgebläht|bauch|dick|schwangerer mann
+🫄|aufgebläht|bauch|dick|schwangere person
+🤱|baby|brust|stillen
+👩‍🍼|baby|frau|stillende frau|weiblich
+👨‍🍼|baby|mann|stillender mann|männlich
+🧑‍🍼|baby|person|stillende person
+👼|engel|gesicht|märchen|putte
+🎅|weihnachten|weihnachtsmann|samichlaus
+🤶|weihnachten|weihnachtsfrau
+🧑‍🎄|weihnachten|weihnachtsperson
+🦸|comic|held|superheld(in)|superheldin|superkraft|übermensch
+🦸‍♂️|held|superheld
+🦸‍♀️|heldin|superheldin
+🦹|bösewicht
+🦹‍♂️|bösewicht|männlicher bösewicht
+🦹‍♀️|bösewicht|weiblicher bösewicht
+🧙|hexenmeister|magier(in)|zauberer|zauberin
+🧙‍♂️|hexenmeister|magier|zauberer
+🧙‍♀️|hexe|magierin|zauberin
+🧚|märchenfee|oberon|puck|titania
+🧚‍♂️|männliche fee|oberon|puck|zauberer
+🧚‍♀️|fee|titania
+🧛|dracula|untoter|vampir
+🧛‍♂️|dracula|männlicher vampir|untoter|mann
+🧛‍♀️|untoter|weiblicher vampir|frau
+🧜|meerjungfrau|wasserfrau|wassermann|wassermensch
+🧜‍♂️|triton|wassermann
+🧜‍♀️|meerjungfrau|nixe|wasserfrau
+🧝|elben|elbin|elf(e)|magisch
+🧝‍♂️|alb|elbe|elf|magisch
+🧝‍♀️|elbin|elfe|magisch
+🧞|dschinn|flaschengeist
+🧞‍♂️|dschinn|männlicher flaschengeist
+🧞‍♀️|dschinn|weiblicher flaschengeist
+🧟|untoter|wandelnder toter|zombie
+🧟‍♂️|männlicher zombie|untoter|wandelnder toter|zombiemann
+🧟‍♀️|untote|wandelnde tote|weiblicher zombie|zombie
+🧌|fantasy|kobold|märchen|monster|troll|ungeheuer
+💆|massage|person, die eine kopfmassage bekommt|salon
+💆‍♂️|kopfmassage|mann, der eine kopfmassage bekommt|männlich
+💆‍♀️|frau, die eine kopfmassage bekommt|kopfmassage|weiblich
+💇|friseur|frisur|person beim haareschneiden
+💇‍♂️|friseur|haarschnitt|mann beim haareschneiden|schere|männlich
+💇‍♀️|frau beim haareschneiden|friseur|haarschnitt|schere|weiblich
+🚶|fußgänger(in)|gehend|wandern|fussgänger(in)
+🚶‍♂️|fußgänger|gehen|mann|spaziergang|fussgänger|männlich
+🚶‍♀️|frau|fußgängerin|gehen|spaziergang|fussgängerin|weiblich
+🧍|stand|stehende person
+🧍‍♂️|mann|stehender mann|männlich
+🧍‍♀️|frau|stehende frau|weiblich
+🧎|kniende person
+🧎‍♂️|kniender mann|mann|männlich
+🧎‍♀️|frau|kniende frau|weiblich
+🧑‍🦯|barrierefreiheit|blind|person mit blindenstock|person mit gehstock|person mit langstock
+👨‍🦯|barrierefreiheit|blind|mann mit blindenstock|mann mit langstock|sehbehindert|mann mit gehstock|männlich
+👩‍🦯|barrierefreiheit|blind|frau mit blindenstock|frau mit gehstock|frau mit langstock|weiblich
+🧑‍🦼|barrierefreiheit|person in motorisiertem rollstuhl|rollstuhl
+👨‍🦼|barrierefreiheit|mann in elektrischem rollstuhl|rollstuhl|männlich
+👩‍🦼|barrierefreiheit|frau in elektrischem rollstuhl|rollstuhl|weiblich
+🧑‍🦽|barrierefreiheit|person in manuellem rollstuhl|rollstuhl
+👨‍🦽|barrierefreiheit|mann in manuellem rollstuhl|rollstuhl|männlich
+👩‍🦽|barrierefreiheit|frau in manuellem rollstuhl|rollstuhl|weiblich
+🏃|laufende person|marathon|sport|rennen
+🏃‍♂️|joggen|jogger|laufender mann|marathon|rennen|männlich
+🏃‍♀️|joggen|joggerin|laufende frau|marathon|rennen|weiblich
+💃|frau|tanzende frau|weiblich
+🕺|mann|tanzender mann|männlich
+🕴️|anzug|geschäftlich|mann|schwebender mann im anzug|männlich
+👯|bunnys|hasenohren|leute|personen mit hasenohren
+👯‍♂️|bunnys|hasenohren|männer mit hasenohren|party
+👯‍♀️|bunnys|frauen mit hasenohren|hasenohren|party|weiblich
+🧖|dampfsauna|person in dampfsauna|sauna
+🧖‍♂️|dampfsauna|mann in dampfsauna|sauna|männlich
+🧖‍♀️|dampfsauna|frau in dampfsauna|sauna|weiblich
+🧗|bergsteiger(in)|klettern
+🧗‍♂️|bergsteiger|klettern
+🧗‍♀️|bergsteigerin|klettern
+🤺|fechten|fechter(in)|schwert|sport
+🏇|jockey auf pferd|pferderennen|sport
+⛷️|schnee|skifahrer(in)|skifahrerin|sport
+🏂|snowboarden|snowboarder(in)|snowboarderin|sport
+🏌️|golfer(in)
+🏌️‍♂️|golfen|golfer|golfspieler|mann|männlich
+🏌️‍♀️|frau|golfen|golferin|golfspielerin|weiblich
+🏄|surfen|surfer(in)|wassersport|wellenreiten|wellenreiterin
+🏄‍♂️|mann|surfer|wellenreiten|männlich
+🏄‍♀️|frau|surferin|wellenreiten|weiblich
+🚣|boot|person im ruderboot
+🚣‍♂️|boot|mann im ruderboot|rudern|männlich
+🚣‍♀️|boot|frau im ruderboot|rudern|weiblich
+🏊|kraulen|schwimmen|schwimmer(in)|sport|wasser
+🏊‍♂️|kraulen|pool|schwimmbad|schwimmen|schwimmer
+🏊‍♀️|kraulen|pool|schwimmbad|schwimmen|schwimmerin
+⛹️|ball|basketball|person mit ball
+⛹️‍♂️|ballsport|handball|mann mit ball|männlich
+⛹️‍♀️|ballsport|frau mit ball|handball|weiblich
+🏋️|gewichtheber(in)
+🏋️‍♂️|gewicht heben|gewichtheber|mann|männlich
+🏋️‍♀️|frau|gewicht heben|gewichtheberin|weiblich
+🚴|radfahren|radfahrer(in)
+🚴‍♂️|fahrrad|mann|radfahrer|männlich
+🚴‍♀️|fahrrad|frau|radfahrerin|weiblich
+🚵|mountainbiker(in)|radfahren
+🚵‍♂️|fahrrad|mann|mountainbiker|rad|männlich
+🚵‍♀️|fahrrad|frau|mountainbikerin|rad|weiblich
+🤸|bodenturnen|person|rad schlagende person|radschlag
+🤸‍♂️|bodenturnen|mann|rad schlagender mann|radschlag|männlich
+🤸‍♀️|bodenturnen|frau|rad schlagende frau|radschlag|weiblich
+🤼|ringer(in)|ringkampf|wrestling
+🤼‍♂️|ringende männer|ringer|ringkampf|wrestling
+🤼‍♀️|ringende frauen|ringer|ringkampf|wrestling|weiblich
+🤽|sport|wasserballspieler(in)
+🤽‍♂️|wasserballspieler|wassersport
+🤽‍♀️|wasserballspielerin|wassersport
+🤾|handballspieler(in)|sport
+🤾‍♂️|handballspieler|mann|männlich
+🤾‍♀️|frau|handballspielerin|weiblich
+🤹|geschickt|jongleur(in)|jonglieren|multitasking
+🤹‍♂️|geschickt|jongleur|jonglieren|mann|multitasking|männlich
+🤹‍♀️|frau|geschickt|jongleurin|jonglieren|multitasking|weiblich
+🧘|meditation|person im lotossitz|yoga
+🧘‍♂️|mann im lotossitz|meditation|yoga|männlich
+🧘‍♀️|frau im lotossitz|meditation|yoga|weiblich
+🛀|badende person|badewanne|badezimmer
+🛌|bett|im bett liegende person|schlafen
+🧑‍🤝‍🧑|hände halten|paar|sich an den händen haltende personen
+👭|frauen|händchen haltende frauen|paar|pärchen aus frau und frau|weiblich
+👫|frau|händchen halten|mann und frau halten hände|paar|pärchen aus frau und mann
+👬|händchen haltende männer|männer|paar|pärchen aus mann und mann|männlich
+💏|frau|herz|kuss|mann|sich küssendes paar
+💑|frau|herz|liebespaar|mann
+👪|familie|kind|mutter|vater
+🗣️|gesicht|kopf|silhouette|sprechender kopf
+👤|büste|person|silhouette einer büste
+👥|büsten|personen|silhouette mehrerer büsten
+🫂|danke|hallo|sich umarmende personen|tschüss|umarmung
+👣|abdruck|fußabdruck|fußabdrücke|fussabdrücke
+🐵|affengesicht|gesicht|tier
+🐒|affe|tier
+🦍|affe|gesicht|gorilla|tier
+🦧|affe|orang-utan
+🐶|gesicht|hundegesicht|tier|haustier
+🐕|haustier|hund|tier
+🦮|barrierefreiheit|blindenhund|sehbehindert
+🐕‍🦺|assistenzhund|barrierefreiheit|hund
+🐩|hund|pudel|tier|haustier
+🐺|gesicht|tier|wolfsgesicht
+🦊|fuchsgesicht|gesicht|tier
+🦝|neugierig|waschbär
+🐱|gesicht|katzengesicht|tier|haustier
+🐈|haustier|katze|tier
+🐈‍⬛|katze|pech|schwarze katze|unglück
+🦁|gesicht|löwengesicht|sternzeichen|tierkreis
+🐯|gesicht|tier|tigergesicht
+🐅|tier|tiger
+🐆|leopard|tier
+🐴|gesicht|pferdegesicht|tier
+🫎|elch|geweih|säugetier|tier
+🫏|dummkopf|esel|maultier|säugetier|störrisch|stur|tier|grautier
+🐎|pferd|rennen|rennpferd|tier
+🦄|einhorngesicht|gesicht
+🦓|streifen|zebra
+🦌|gesicht|hirsch|tier
+🦬|bison|büffel|herde|wisent
+🐮|gesicht|kuhgesicht|tier
+🐂|ochse|sternzeichen|stier|tierkreis
+🐃|büffel|tier|wasserbüffel
+🐄|kuh|tier
+🐷|gesicht|schweinegesicht|tier
+🐖|sau|schwein|tier
+🐗|schwein|tier|wildschwein
+🐽|nase|schweinerüssel|tier
+🐏|schaf|sternzeichen|tierkreis|widder
+🐑|schaf|tier
+🐐|steinbock|sternzeichen|tierkreis|ziege
+🐪|dromedar|einhöckrig|kamel|tier
+🐫|kamel|tier|zweihöckrig
+🦙|alpaca|lama|wolle
+🦒|flecken|giraffe
+🐘|elefant|tier
+🦣|aussterben|groß|mammut|stoßzahn|wollig|gross|stosszahn
+🦏|nashorn|rhinozeros|tier
+🦛|hippo|nilpferd
+🐭|gesicht|maus|mäusegesicht|tier
+🐁|maus|tier
+🐀|ratte|tier
+🐹|gesicht|hamstergesicht|tier|haustier
+🐰|gesicht|hasengesicht|tier|haustier
+🐇|hase|kaninchen|tier|haustier
+🐿️|streifenhörnchen|tier
+🦫|biber|damm
+🦔|igel|stachelig
+🦇|fledermaus|tier|vampir
+🐻|bärengesicht|gesicht|tier
+🐻‍❄️|arktis|eisbär|nordpol|weiß
+🐨|koalabär|tier
+🐼|gesicht|pandabär|pandagesicht|tier
+🦥|faultier|langsam
+🦦|fischen|otter|verspielt
+🦨|skunk|stinken|stinktier
+🦘|hüpfen|känguru
+🦡|dachse
+🐾|abdruck|tatzenabdrücke|tier
+🦃|geflügel|truthahn
+🐔|geflügel|henne|huhn|tier
+🐓|hahn|tier
+🐣|küken|schlüpfendes küken|tier
+🐤|küken|tier
+🐥|geflügel|küken von vorne|tier
+🐦|papagei|taube|vogel
+🐧|pinguin|tier
+🕊️|fliegen|frieden|taube|vogel
+🦅|adler|vogel
+🦆|ente|vogel
+🦢|hässliches entlein|schwan|vogel
+🦉|eule|vogel|weise
+🦤|aussterben|dodo|groß|mauritius|gross
+🪶|federn|fliegen|leicht
+🦩|bunt|farbenfroh|flamingo|tropen|tropisch
+🦚|pfau|stolzieren|vogel
+🦜|papagei|pirat|vogel|wiederholen
+🪽|engelsflügel|federn|fliegen|flügel|mythologie|vogel
+🪿|blöd|dumm|gans|geflügel|vogel|schnattern
+🐸|froschgesicht|gesicht|tier
+🐊|krokodil|tier
+🐢|schildkröte|tier
+🦎|eidechse|reptil
+🐍|schlangenträger|sternzeichen|tierkreis
+🐲|drachengesicht|gesicht|tier
+🐉|drache|märchen|tier
+🦕|brachiosaurus|brontosaurus|dinosaurier|diplodocus|saurier|sauropode
+🦖|dinosaurier|saurier|t-rex|tyrannosaurus rex
+🐳|blasender wal|tier|wal
+🐋|tier|wal
+🐬|delfin|tier
+🦭|seehund|seelöwe
+🐟|fische|sternzeichen|tierkreis
+🐠|fisch|tier|tropenfisch
+🐡|fisch|kugelfisch|tier
+🦈|haifisch
+🐙|krake|oktopus|tier|tintenfisch
+🐚|muschel|schneckenhaus|tier
+🪸|korallenriff|ozean|riff
+🪼|autsch|brennen|gallert|glibber|meerestier|nesseltier|qualle|gift|invertebrate|stechen
+🐌|schnecke|tier
+🦋|schmetterling|schön|insekt
+🐛|insekt|raupe|tier
+🐜|ameise|insekt|tier
+🐝|biene|honigbiene|hummel|tier|insekt
+🪲|insekt|käfer
+🐞|glückskäfer|käfer|marienkäfer|tier|insekt
+🦗|grille|heuschrecke
+🪳|insekt|kakerlake|schabe
+🕷️|insekt|spinne|tier
+🕸️|netz|spinnennetz
+🦂|skorpion|sternzeichen|tierkreis
+🦟|fieber|insekt|malaria|moskito|mücke
+🪰|fliege|krankheit|made|plage|verwesen|insekt
+🪱|parasit|regenwurm|ringelwurm|wurm
+🦠|amöbe|bakterie|einzeller|mikrobe
+💐|blumenstrauß|bouquet|blumenstrauss
+🌸|blume|blüte|kirschblüte|kirsche|pflanze
+💮|blumenstempel
+🪷|blume|buddhismus|hinduismus|lotusblüte|reinheit
+🏵️|pflanze|rosette
+🌹|blume|blüte|pflanze|rose
+🥀|blume|verwelkt|welke blume
+🌺|blume|blüte|hibiskus|pflanze
+🌻|blume|blüte|pflanze|sonnenblume
+🌼|blume|blüte|gelbe blüte|pflanze
+🌷|blume|blüte|pflanze|tulpe
+🪻|blaue wiesenlupine|blume|blüte|hyazinthe|löwenmaul|lupine|lavendel
+🌱|junge pflanze|spross
+🪴|haus|langweilig|nutzlos|pflanze|pflegen|topfpflanze|wachsen
+🌲|baum|nadelbaum|pflanze
+🌳|baum|laubbaum|pflanze
+🌴|baum|palme|pflanze
+🌵|kaktus|pflanze
+🌾|ähre|pflanze|reisähre
+🌿|blätter|kräuter
+🍀|glücksklee|kleeblatt|vierblättrig
+🍁|ahornblatt|blatt|herbst|laub|pflanze
+🍂|blatt|blätter|herbst|laub|pflanze
+🍃|blatt|blätter im wind|laub|pflanze|wind
+🪹|leeres nest|nestbau|nisten|vogelnest
+🪺|nest mit eiern|nestbau|nisten|vogelnest
+🍄|fliegenpilz|pilz
+🍇|frucht|obst|trauben
+🍈|frucht|honigmelone|obst
+🍉|frucht|melone|obst|wassermelone
+🍊|frucht|mandarine|obst|orange
+🍋|frucht|obst|zitrone|zitrusfrucht
+🍌|banane|frucht|obst
+🍍|ananas|frucht|obst
+🥭|frucht|früchte|mango|tropisch
+🍎|apfel|frucht|obst|roter apfel
+🍏|apfel|frucht|grüner apfel|obst
+🍐|birne|frucht|obst
+🍑|frucht|obst|pfirsich
+🍒|frucht|kirschen|obst
+🍓|beere|erdbeere|frucht|obst
+🫐|beere|blaubeeren|heidelbeere
+🥝|frucht|kiwi|obst
+🍅|gemüse|tomate
+🫒|lebensmittel|olive
+🥥|kokosnuss|palme|piña colada
+🥑|avocado|frucht
+🍆|aubergine|gemüse
+🥔|essen|kartoffel
+🥕|gemüse|karotte|möhre|mohrrübe
+🌽|maiskolben
+🌶️|chili|paprika|peperoni|pfeffer|pflanze|scharf
+🫑|gemüsepaprika|paprika|peperoni
+🥒|essen|gemüse|gurke
+🥬|blattgemüse|gemüse|grünzeug|kohl|salat|spinat
+🥦|brokkoli|gemüsekohl
+🧄|geschmack|knoblauch
+🧅|geschmack|zwiebel
+🥜|erdnuss|essen
+🫘|bohnen|essen|hülsenfrucht|kidney-bohne|lebensmittel
+🌰|kastanie|marone
+🫚|gewürz|ginger ale|ingwerbier|wurzel|ginger beer|ingwerwurzel|scharf
+🫛|edamame|erbsenschote|gemüse|hülsenfrucht|schote|bohne|hülsenfrüchte
+🍞|brotlaib|laib brot
+🥐|croissant|französisch|frühstückshörnchen|brot
+🥖|baguette|französisch|frühstück|brot
+🫓|arepa|fladenbrot|lavasch|naan|pita|brot
+🥨|brezel|gedreht
+🥯|bäckerei|backwaren|bagel|frühstück|brot
+🥞|eierpfannkuchen|essen|pfannkuchen
+🧇|waffel mit butter
+🧀|käsestück
+🍖|fleischhachse|knochen|restaurant
+🍗|geflügel|hähnchenschenkel|restaurant|pouletschenkel
+🥩|fleischstück|kotelett|lammkotelett|schweinekotelett|steak
+🥓|bacon|essen|frühstücksspeck|speck
+🍔|burger|hamburger|restaurant
+🍟|fritten|pommes frites
+🍕|pizzastück|pizzeria
+🌭|frankfurter|hot dog|hotdog|wurst|würstchen
+🥪|brot|sandwich
+🌮|mexikanisch|taco
+🌯|burrito|mexikanisch
+🫔|eingewickelt|mexikanisch|tamale
+🥙|döner kebab|falafel|wrap
+🧆|bällchen|falafel|kichererbsen
+🥚|ei|frühstücksei
+🍳|kochen|pfanne|spiegelei in bratpfanne
+🥘|essen|paella|pfannengericht|reispfanne
+🍲|eintopf|gericht|topf mit essen
+🫕|fondue|geschmolzen|käse|schokolade|schweizerisch|topf
+🥣|cerealien|frühstück|reisbrei|schüssel mit löffel
+🥗|essen|salat
+🍿|popcorn|snack
+🧈|butter|milchprodukt
+🧂|geschmack|salzstreuer
+🥫|dose|konserve
+🍱|bento-box
+🍘|cracker|reiscracker
+🍙|reisbällchen
+🍚|reis in schüssel
+🍛|curry|reis mit curry
+🍜|dampfend|eiernudeln|nudeln|schüssel und essstäbchen|stäbchen|suppe
+🍝|nudeln mit tomatensoße|pasta|spaghetti|nudeln mit tomatensauce
+🍠|geröstete süßkartoffel|süßkartoffel|geröstete süsskartoffel|süsskartoffel
+🍢|japanisches gericht|oden|restaurant
+🍣|japanisches gericht|restaurant|sushi
+🍤|frittierte garnele|garnele|restaurant
+🍥|fischfrikadelle
+🥮|festival|herbst|mondkuchen|yuebing
+🍡|dango|japanisches gericht|mochi-kugeln auf einem spieß|restaurant|mochi-kugeln auf einem spiess
+🥟|chinesische teigtasche|empanada|gyōza|jiaozi|pierogi|teigtasche
+🥠|glückskeks|prophezeiung
+🥡|takeaway-box|takeaway-schachtel
+🦀|krebs|sternzeichen|tierkreis
+🦞|hummer|meeresfrüchte
+🦐|garnele|gourmet|krustentier
+🦑|kalmar|tintenfisch
+🦪|auster|perle|tauchen
+🍦|eis|softeis
+🍧|eis|sorbet|wassereis
+🍨|eisbecher|eiscreme|eisdiele
+🍩|donut|doughnut
+🍪|cookie|keks
+🎂|geburtstagskuchen|torte|dessert|kuchen
+🍰|kuchenstück|stück torte|tortenstück|dessert
+🧁|cupcake|gebäck|konditorei|muffin|süß|süss|dessert
+🥧|füllung|gebäck|kuchen
+🍫|schokoladentafel
+🍬|bonbon|süßigkeit|süssigkeit
+🍭|lolli|lutscher|süßigkeit|süssigkeit
+🍮|dessert|nachspeise|nachtisch|pudding|schokolade|soße|sauce
+🍯|honigtopf
+🍼|babyflasche|fläschchen|kind|milch|trinken|getränk
+🥛|getränk|glas|milch
+🫖|kanne|teekanne|trinken|teekrug|getränk
+🍵|teetasse ohne henkel|getränk
+🍶|flasche|getränk|sake-flasche mit tasse|tasse|trinken
+🍾|champagner|flasche mit knallendem korken|korken|sekt|trinken|getränk
+🍷|bar|glas|weinglas|getränk
+🍸|bar|cocktailglas|getränk
+🍹|bar|cocktail|exotisches getränk|getränk
+🍺|bar|bierkrug|krug|getränk
+🍻|anstoßen|bierkrüge|anstossen|getränk
+🥂|anstoßen|feiern|getränk|sektgläser|anstossen|champagner
+🥃|bar|trinkglas|whiskey|getränk
+🫗|ausgießen|flüssigkeit ausgießen|getränk|gießen|glas|leer|verschütten|ausgiessen|flüssigkeit ausgiessen|giessen|trinken
+🥤|becher mit strohhalm|saft|selters|getränk
+🧋|blase|bubble tea|milch|perle|tee|getränk
+🧃|getränk|saftpackung|trinkpäckchen
+🧉|getränk|mate-tee
+🧊|eisberg|eiswürfel|kalt
+🥢|essstäbchen|hashi|stäbchen
+🍽️|gabel|kochen|messer|teller mit messer und gabel
+🍴|besteck|gabel und messer|messer und gabel
+🥄|besteck|löffel
+🔪|küchenmesser|messer
+🫙|einmachglas|einweckglas|gewürzglas|leer|marmeladeglas
+🏺|amphore|gefäß|kochen|krug|vase|wassermann|gefäss
+🌍|afrika|europa|globus mit europa und afrika|weltkugel
+🌎|globus mit amerika|nordamerika|südamerika|weltkugel
+🌏|asien|australien|globus mit asien und australien|weltkugel
+🌐|breitengrad|globus mit meridianen|längengrad
+🗺️|karte|weltkarte
+🗾|japan|karte|umriss von japan
+🧭|himmelsrichtung|kompass|magnetisch|navigation|orientierung|windrose
+🏔️|berg|kalt|schneebedeckter berg
+⛰️|berg|gebirge
+🌋|ausbruch|berg|vulkan|wetter
+🗻|berg|fuji
+🏕️|campen|camping|zelten
+🏖️|meer|sonnenschirm|strand mit sonnenschirm
+🏜️|wüste
+🏝️|einsame insel|insel|meer|strand|verlassen
+🏞️|nationalpark|park
+🏟️|arena|stadion
+🏛️|antikes gebäude|gebäude|klassizistisch
+🏗️|bauen|kran
+🧱|klinker|mauerwerk|wand|ziegelstein
+🪨|felsen|stein
+🪵|feuerholz|holzscheite
+🛖|haus|hütte|jurte|rundhaus
+🏘️|gebäude|haus|häuser|wohnhaus|wohnhäuser|wohnsiedlung
+🏚️|gebäude|haus|heruntergekommen|verfallenes haus|verlassen
+🏠|gebäude|haus|zuhause
+🏡|baum|haus mit garten
+🏢|bürogebäude|hochhaus
+🏣|japanisches postgebäude|post
+🏤|europa|postgebäude
+🏥|arzt|gebäude|krankenhaus|medizin
+🏦|bank|gebäude|geld
+🏨|gebäude|hotel|übernachten|unterkunft
+🏩|gebäude|hotel|liebe|stundenhotel|unterkunft
+🏪|einkaufen|gebäude|geschäft|lebensmittel|minimarkt
+🏫|gebäude|schule|schulgebäude
+🏬|einkaufen|gebäude|geschäft|kaufhaus|shoppen
+🏭|fabrikgebäude|gebäude
+🏯|bauwerk|gebäude|japanisches schloss|schloss
+🏰|bauwerk|europa|europäisch|gebäude|schloss
+💒|herz|hochzeit|kirche
+🗼|fernsehturm|tokio|tokyo tower
+🗽|amerika|freiheitsstatue
+⛪|christentum|christlich|gebäude|kirche|kreuz|religion
+🕌|islam|moschee|moslem|muslim|religion
+🛕|hindutempel|tempel|religion
+🕍|jude|jüdisch|religion|synagoge|tempel
+⛩️|religion|schrein|shinto-schrein
+🕋|islam|kaaba|moslem|muslim|religion
+⛲|brunnen|garten|park|springbrunnen
+⛺|campingurlaub|zeltplatz
+🌁|nebel|neblig|wetter
+🌃|nacht|sternenhimmel
+🏙️|gebäude|häuser|hochhäuser|skyline|stadt|wolkenkratzer
+🌄|berge|sonnenaufgang über bergen
+🌅|meer|sonnenaufgang über dem meer
+🌆|abendstimmung in der stadt|hochhäuser|sonnenuntergang
+🌇|hochhäuser|sonnenuntergang in der stadt
+🌉|brücke vor nachthimmel|golden gate|nachts
+🎠|karussellpferd|pferd
+🛝|rutsche|spielen|spielplatzrutsche|vergnügungspark
+🎡|freizeitpark|rad|riesenrad|volksfest
+🎢|achterbahn|freizeitpark|volksfest
+💈|barbershop-säule|herrenfriseur|säule
+🎪|unterhaltung|zelt|zirkuszelt
+🚂|dampflokomotive|fahrzeug|lokomotive|zug
+🚃|eisenbahnwagen|fahrzeug|wagen|waggon|zug|strassenbahnwagen|tram|wagon
+🚄|hochgeschwindigkeitszug mit spitzer nase|shinkansen|tgv|zug
+🚅|hochgeschwindigkeitszug|japan|shinkansen|zug
+🚆|eisenbahn|zug
+🚇|metro|u-bahn
+🚈|s-bahn|zug
+🚉|bahnhof|zug
+🚊|straßenbahn|tram|strassenbahn
+🚝|bahn|einschienenbahn|magnetschwebebahn
+🚞|bahn|bergbahn
+🚋|straßenbahnwagen|tramwagen|strassenbahnwagen
+🚌|bus|fahrzeug
+🚍|bus von vorne
+🚎|oberleitungsbus|trolleybus
+🚐|bus|kleinbus
+🚑|krankenwagen|notfall
+🚒|brand|feuerwehrauto|löschfahrzeug
+🚓|polizeiwagen|streifenwagen
+🚔|polizeiwagen von vorne|streifenwagen
+🚕|auto|fahrzeug|taxi
+🚖|taxi von vorne
+🚗|auto|fahrzeug
+🚘|auto von vorne|automobil|fahrzeug
+🚙|verreisen|wohnmobil
+🛻|laster|lieferwagen|pick-up
+🚚|lastwagen|lieferwagen|lkw
+🚛|lastwagen|lkw|sattelzug
+🚜|landwirtschaft|traktor|trecker
+🏎️|autorennen|rennauto
+🏍️|motorrad|motorrennen
+🛵|motorroller|roller|vespa
+🦽|barrierefreiheit|manueller rollstuhl
+🦼|barrierefreiheit|elektrischer rollstuhl
+🛺|autorikscha|tuk-tuk
+🚲|fahrrad|rad
+🛴|tretroller
+🛹|skateboard fahren
+🛼|rollen|rollschuh|schuh
+🚏|bushaltestelle|haltestelle
+🛣️|autobahn|schnellstraße|schnellstrasse
+🛤️|bahngleis|schienen
+🛢️|fass|ölfass
+⛽|benzin|tanken|tanksäule|tankstelle
+🛞|autorad|drehen|rad|reifen|rotieren
+🚨|polizeilicht
+🚥|ampel|horizontale verkehrsampel|verkehrsampel|vertikal
+🚦|ampel|horizontal|verkehrsampel|vertikale verkehrsampel
+🛑|achteckig|schild|stoppschild
+🚧|baustellenabsperrung|schild
+🛟|leben retten|retten|rettungsring|schwimmring|sicherheit
+⛵|boot|segelboot
+🛶|boot|kanu|wassersport
+🚤|boot|schnellboot
+🛳️|passagierschiff|schiff|seereise
+⛴️|fähre|schiff
+🛥️|boot|motorboot|schiff
+🚢|dampfer|kreuzfahrtschiff|schiff
+🛩️|flugzeug|kleines flugzeug
+🛫|abflug|flugzeug|start eines flugzeugs
+🛬|flugzeug|landung eines flugzeugs
+🪂|fallschirmspringen|paragliding|skydiving
+💺|flugzeug|sitzplatz|zug
+🚁|helikopter|hubschrauber
+🚟|hängebahn|schwebebahn
+🚠|bergschwebebahn|schwebebahn
+🚡|bergseilbahn|gondel|seilbahn
+🛰️|satellit|weltraum
+🚀|rakete|weltraum
+🛸|fliegende untertasse|ufo
+🛎️|klingel|rezeptionsklingel
+🧳|ballast|gepäck|koffer|reise
+⌛|prozess|sanduhr|vorgang läuft
+⏳|laufende sanduhr|prozess|sanduhr|vorgang läuft
+⌚|armbanduhr|uhr
+⏰|uhrzeit|wecker
+⏱️|stoppuhr|uhr
+⏲️|timer|uhr|zeitschaltuhr
+🕰️|kaminuhr|uhr
+🕛|0 uhr|12:00 uhr|mittag|mitternacht|uhr|ziffernblatt 12:00 uhr|zwölf uhr
+🕧|00:30|12:30|halb eins|uhr|ziffernblatt 12:30 uhr
+🕐|01:00|1:00 uhr|13:00|punkt eins|uhr|ziffernblatt 1:00 uhr
+🕜|1:30 uhr|halb zwei|uhr|ziffernblatt 1:30 uhr
+🕑|2:00 uhr|uhr|ziffernblatt 2:00 uhr
+🕝|2:30 uhr|halb drei|uhr|ziffernblatt 2:30 uhr
+🕒|3:00 uhr|uhr|ziffernblatt 3:00 uhr
+🕞|3:30 uhr|halb vier|uhr|ziffernblatt 3:30 uhr
+🕓|4:00 uhr|uhr|ziffernblatt 4:00 uhr
+🕟|04:30|16:30|4:30 uhr|halb fünf|uhr|ziffernblatt 4:30 uhr
+🕔|05:00|17:00|5:00 uhr|punkt fünf|uhr|ziffernblatt 5:00 uhr
+🕠|05:30|17:30|5:30 uhr|halb sechs|uhr|ziffernblatt 5:30 uhr
+🕕|6:00 uhr|uhr|ziffernblatt 6:00 uhr
+🕡|6:30 uhr|halb sieben|uhr
+🕖|7:00 uhr|uhr|ziffernblatt 7:00 uhr
+🕢|7:30 uhr|halb acht|uhr|ziffernblatt 7:30 uhr
+🕗|8:00 uhr|uhr|ziffernblatt 8:00 uhr
+🕣|8:30 uhr|halb neun|uhr|ziffernblatt 8:30 uhr
+🕘|9:00 uhr|uhr|ziffernblatt 9:00 uhr
+🕤|9:30 uhr|halb zehn|uhr|ziffernblatt 9:30 uhr
+🕙|10:00 uhr|uhr|ziffernblatt 10:00 uhr
+🕥|10:30 uhr|halb elf|uhr|ziffernblatt 10:30 uhr
+🕚|11:00 uhr|uhr|ziffernblatt 11:00 uhr
+🕦|11:30 uhr|halb zwölf|uhr|ziffernblatt 11:30 uhr
+🌑|mond|neumond
+🌒|erstes mondviertel|mond|zunehmend
+🌓|halbmond|zunehmender halbmond
+🌔|mond|zunehmend|zweites mondviertel
+🌕|mond|vollmond
+🌖|abnehmend|drittes mondviertel|mond
+🌗|abnehmender halbmond|halbmond
+🌘|abnehmend|letztes mondviertel|mond
+🌙|mondsichel
+🌚|gesicht|neumond mit gesicht
+🌛|gesicht|mondsichel mit gesicht links
+🌜|gesicht|mondsichel mit gesicht rechts
+🌡️|temperatur|thermometer|wetter
+🌝|gesicht|vollmond mit gesicht
+🌞|gesicht|sonne mit gesicht
+🪐|ringplanet|saturn
+⭐|stern|weißer mittelgroßer stern|weisser mittelgrosser stern
+🌟|funkelnder stern|stern
+🌠|himmel|sternschnuppe
+🌌|galaxie|milchstraße|milchstrasse
+⛅|sonne hinter wolke|wolke|wolkig
+⛈️|blitz|gewitter|regen|wetter|wolke mit blitz und regen|wolkig
+🌤️|kleine wolke|sonne hinter kleiner wolke|wetter|wolke
+🌥️|große wolke|sonne hinter großer wolke|wetter|wolke|grosse wolke|sonne hinter grosser wolke
+🌦️|regenwolke|sonne hinter regenwolke|wetter
+🌧️|regenwolke|wetter|wolke mit regen
+🌨️|schnee|wetter|wolke mit schnee
+🌩️|blitz|gewitter|wetter|wolke mit blitz
+🌪️|wetter|wirbelsturm
+🌫️|nebel|neblig|wetter
+🌬️|wetter|windig
+🌀|spirale|wirbelsturm
+🌈|regenbogen|wetter
+🌂|geschlossener regenschirm|regenschirm
+⛱️|aufgestellter sonnenschirm|sonnenschirm
+⚡|blitz|gefahr|hochspannung
+⛄|kalt|schneemann ohne schneeflocken|winter
+🔥|feuer|flamme|heiß|heiss
+💧|schweiß|tropfen|wassertropfen|schweiss
+🌊|meer|welle
+🎃|halloweenkürbis|kürbis
+🎄|baum|tanne|weihnachten|weihnachtsbaum
+🎆|feuerwerk|silvester
+🎇|feuerwerk|wunderkerze
+🧨|dynamit|explosiv|feuerwerkskörper|knaller
+🎈|geburtstag|luftballon
+🎉|feier|konfettibombe|party
+🎊|feier|konfettiball
+🎋|baum|fest|japan|sternenfest|tanabata-baum|zettel
+🎍|japan|neujahrsfest|piniendekoration
+🎎|japanische puppen|puppenfest japan
+🎏|feier|karpfen|traditionelle japanische windsäcke|windsäcke
+🎐|feier|glocke|japanisches windspiel|wind
+🎑|japan|mondfest|traditionelles mondfest
+🧧|geld|geschenk|glück|hongbao|roter umschlag
+🎀|feier|pinke schleife|schleife
+🎁|feier|geschenk|verpackt
+🎗️|gedenkschleife|schleife
+🎟️|eintrittskarten|ticket
+🎫|konzert|ticket|unterhaltung
+🎖️|militärorden|orden
+🏆|pokal|preis
+🏅|medaille|sportmedaille
+🥇|erster|goldmedaille|medaille 1. platz
+🥈|medaille 2. platz|silbermedaille|zweiter
+🥉|bronzemedaille|dritter|medaille 3. platz
+⚽|ball|fußball|fussball
+⚾|ball|baseball
+🥎|ball|handschuh|softball
+🏀|ball|basketball|korb|sport
+🏐|ball|volleyball
+🏈|amerika|ball|football|sport
+🏉|ball|rugbyball|sport
+🎾|ball|sport|tennisball
+🥏|frisbee|ultimate
+🎳|bowlingkugel|kugel|spiel
+🏏|ball|cricket|kricket|schläger
+🏑|feldhockey|hockey|schläger
+🏒|eishockey|hockey|puck|schläger
+🥍|ball|lacrosse|schläger|stock
+🏓|ball|schläger|tischtennis
+🏸|badminton|federball|schläger
+🥊|boxen|boxhandschuh|handschuh|sport
+🥋|judo|kampfkunst|kampfsportanzug|karate|taekwondo
+🥅|sport|tor
+⛳|golffahne|golfplatz|flagge
+⛸️|eislauf|schlittschuh
+🎣|angel mit fisch|angeln|entspannung
+🤿|schnorcheln|sporttauchen|tauchen|tauchmaske
+🎽|laufen|laufshirt|schärpe|sport
+🎿|ski und stöcke
+🛷|rodel|schlitten
+🥌|curlingstein|spiel|stein
+🎯|darts|spiel|volltreffer|zielscheibe
+🪀|jo-jo|spielzeug
+🪁|drachen|fliegen|steigen
+🔫|pistole|revolver|waffe|wasserpistole
+🎱|8-ball|billardkugel|kugel|spiel
+🔮|kristallkugel|wahrsager
+🪄|hexer|zauberei|zauberer|zauberin|zauberstab
+🎮|gamepad|gaming|videospiel
+🕹️|gaming|joystick|videospiel
+🎰|glücksspiel|spielautomat
+🎲|spielwürfel|würfel
+🧩|puzzlestück|puzzleteil
+🧸|kuscheltier|plüschteddy|plüschtier|spielzeug|teddybär
+🪅|feier|party|piñata
+🪩|discokugel|lichtreflexe|party|spiegelkugel|tanzen
+🪆|matrioschka|matroschka|puppe|russland
+♟️|bauer schach|schach
+🃏|jokerkarte|spielkarte
+🀄|mahjong-stein|roter drache
+🎴|blumenkarte|hanafuda|japanische blumenkarte|karte
+🎭|kunst|masken|theater|unterhaltung
+🖼️|bild|gemälde|gerahmtes bild|kunst|malen|rahmen|zeichnung
+🎨|farben|kunst|künstler|mischpalette|palette
+🧵|faden|nadel|nähen|zwirn
+🪡|nadel|nähen|nähnadel|nähte|schneidern|stiche|sticken
+🧶|häkeln|stricken|wolle|wollknäuel
+🪢|binden|knoten|schnur|seil|zusammendrehen
+👓|accessoire|brille
+🕶️|augen|brille|dunkel|sonnenbrille
+🥽|augenschutz|schutzbrille|schweißen|schwimmen|schweissen
+🥼|doktor|experiment|laborkittel|wissenschaftler
+🦺|notfall|sicherheitsweste|weste
+👔|hemd mit krawatte|kleidung|kragen|schlips
+👕|kleidung|shirt|t-shirt
+👖|hose|jeans|kleidung
+🧣|hals|schal
+🧤|handschuhe
+🧥|jacke|mantel
+🧦|socken|strümpfe
+👗|kleidung
+👘|kimono|kleidung
+🥻|kleidung|sari
+🩱|badeanzug|einteiliger badeanzug
+🩲|badeanzug|einteiler|slip|unterwäsche
+🩳|badebekleidung|boxershorts|schwimmshorts|shorts
+👙|badeanzug|bikini|kleidung
+👚|bluse|damenmode|kleidung|oberbekleidung
+🪭|faltfächer|fächer
+👛|accessoire|brieftasche|geldbörse|portemonnaie
+👜|accessoire|handtasche|tasche
+👝|accessoire|clutch|tasche
+🛍️|einkaufen|einkaufstüten|shoppen|shopping
+🎒|ranzen|rucksack|schule|schulranzen|tornister|schulsack
+🩴|zehensandalen|flipflops
+👞|herrenschuh|schuh
+👟|schuh|sneaker|sportlich|sportschuh
+🥾|camping|wandern|wanderstiefel|wanderung
+🥿|ballet-pumps|flacher schuh|slipper
+👠|absatzschuh|damen|highheels|pumps|stöckelschuh
+👡|damensandale|sandale|schuh
+🩰|ballettschuhe|tanz
+👢|damenstiefel|schuh|stiefel
+🪮|haarkamm|afro pick
+👑|königin|krone
+👒|damenhut mit schleife|hut|kopfbedeckung|schleife
+🎩|hut|kopfbedeckung|zylinderhut
+🎓|abschlussfeier|doktorhut
+🧢|baseballkappe|baseballmütze|schirmkappe
+🪖|helm|kämpferin|militärhelm|soldatin
+⛑️|bergen|helm mit weißem kreuz|hilfe|retten|rettungshelm|helm mit weissem kreuz|kreuz
+📿|gebetskette|kette|religion|rosenkranz
+💄|kosmetik|lippenstift|make-up|schminke
+💍|diamantring|edelstein|ring|schmuck|verlobung
+💎|diamant|edelstein
+🔇|durchgestrichener lautsprecher|stummgeschaltet
+🔈|eingeschaltet|lautsprecher mit geringer lautstärke
+🔉|lautsprecher mit mittlerer lautstärke|mittellaut
+🔊|lautsprecher mit hoher lautstärke
+📢|lautsprecher
+📣|jubel|lautsprecher|megafon
+📯|brief|e-mail|posthorn
+🔔|glocke|ton eingeschaltet
+🔕|durchgestrichene glocke|ton ausgeschaltet
+🎼|musik|notenschlüssel|partitur|violinschlüssel
+🎵|musiknote|note
+🎶|musiknoten|noten
+🎙️|mikrofon|studiomikrofon
+🎚️|musik|schieberegler
+🎛️|bedienknöpfe|drehregler|stellknöpfe
+🎤|karaoke|mikrofon|singen|unterhaltung
+🎧|kopfhörer|musik|unterhaltung
+📻|musik|radio
+🎷|instrument|musikinstrument|saxofon
+🪗|akkordeons|concertina|quetschkommoden|ziehharmonikas
+🎸|gitarre|instrument|musikinstrument
+🎹|instrument|klaviatur|musikinstrument|tastatur|tasten
+🎺|instrument|musikinstrument|trompete
+🎻|geige|instrument|musikinstrument
+🪕|banjo|musik|streichinstrument
+🥁|trommelstöcke
+🪘|afrikanische trommel|conga|rhythmus
+🪇|maracas
+🪈|flöte
+📱|handy|mobiltelefon|smartphone
+📲|anruf|mobiltelefon mit pfeil|pfeil
+☎️|festnetz|telefon
+📞|anrufen|hörer|telefonhörer
+📟|pager
+📠|faxgerät
+🔋|akku|batterie
+🪫|elektronik|niedriger akkustand|schwache batterie|schwacher akku|akku leer|batterie leer
+🔌|netzstecker|stecker|stromstecker
+💻|computer|laptop|notebook|pc
+🖥️|bildschirm|desktopcomputer|monitor
+🖨️|computer|drucker
+🖱️|computermaus
+🖲️|computer|trackball
+💽|md|minidisc
+💾|datenträger|diskette
+💿|blu-ray|cd|dvd
+📀|cd|dvd
+🧮|abaki|abakusse|rechenhilfe|rechenschieber
+🎥|filmkamera|kino|unterhaltung
+🎞️|filmband|filmstreifen|kino
+📽️|filmprojektor|kino|unterhaltung
+🎬|filmklappe|klappe|unterhaltung
+📺|fernseher|film|tv
+📷|fotoapparat|fotos|kamera
+📸|blitz|fotoapparat mit blitz
+📹|videokamera|videos
+📼|videokassette
+🔍|lupe nach links|suche|vergrößerungsglas|vergrösserungsglas
+🔎|lupe nach rechts|suche|vergrößerungsglas|vergrösserungsglas
+🕯️|kerze|licht
+💡|glühbirne|idee|licht
+🔦|lampe|licht|taschenlampe
+🏮|izakaya|japanisches lokal|rote papierlaterne
+🪔|diya|lampe|öllampe
+📔|einband|notizbuch mit dekorativem einband
+📕|buch|geschlossenes buch
+📖|buch|geöffnet|offenes buch
+📗|buch|grünes buch
+📘|blaues buch|buch
+📙|buch|orangefarbenes buch
+📚|bücherstapel
+📓|notizbuch|notizen
+📒|notizblock|spiralblock
+📃|dokument|papier|seite|teilweise eingerolltes blatt
+📜|papier|schriftrolle
+📄|dokument|papier|seite|vorderseite eines blattes
+📰|nachrichten|zeitung
+🗞️|zeitung|zusammengerollte zeitung
+📑|notizen|pagemarker
+🔖|lesen|lesezeichen
+🏷️|etikett|label|marke
+💰|geldsack|sack
+🪙|geld|gold|metall|münze|schatz|silber
+💴|geldschein|yen-banknote
+💵|dollar-banknote|geldschein
+💶|euro-banknote|euroschein|geldschein
+💷|geldschein|pfund-banknote
+💸|bank|geldschein mit flügeln
+💳|guthaben|karte|kreditkarte
+🧾|belege|buchhaltung|rechnungslegung
+💹|diagramm|markt|steigende kurve mit yen-zeichen
+📧|brief|e-mail-symbol
+📨|e-mail|eingehender briefumschlag|empfangen
+📩|e-mail|gesendet|umschlag mit pfeil
+📤|ablage|postausgang
+📥|ablage|posteingang
+📦|päckchen|paket
+📫|briefkasten|e-mail|geschlossener briefkasten mit post|post
+📪|briefkasten|geschlossener briefkasten ohne post|keine e-mail|keine post|post
+📬|briefkasten|e-mail|offener briefkasten mit post|post
+📭|briefkasten|keine e-mail|keine post|offener briefkasten ohne post|post
+📮|briefkasten
+🗳️|urne mit wahlzettel|wahlzettel
+✏️|bleistift
+🖋️|füller|füllfederhalter|füllhalter
+🖊️|kugelschreiber|stift
+🖌️|kunst|malen|pinsel
+🖍️|buntstift|wachsmalstift
+📝|bleistift|kurzmitteilung|nachricht|papier und bleistift
+💼|aktentasche|tasche
+📁|dokument|geschlossen|ordner
+📂|dokument|geöffneter ordner|offen|ordner
+🗂️|büromaterial|karteikarten|karteireiter
+📅|kalenderblatt
+📆|abreißkalender|kalender|abreisskalender
+🗒️|block|notizblock
+🗓️|kalender|spiralkalender
+📇|rotationskartei|visitenkarten
+📈|aufwärtstrend|diagramm|kurve|steigend
+📉|abwärtstrend|diagramm|fallend|kurve
+📊|balkendiagramm|diagramm
+📋|clipboard|klemmbrett|zwischenablage
+📌|anpinnen|reißzwecke|reisszwecke
+📍|anpinnen|reißzwecke|runde reißzwecke|stecknadel|reisszwecke|runde reisszwecke
+📎|büroklammer
+🖇️|büroklammern|verhakte büroklammern
+📏|lineal
+📐|dreieckiges lineal|geodreieck|lineal
+🗃️|büromaterial|karteikasten
+🗄️|ablage|aktenschrank|archiv
+🗑️|papierkorb
+🔒|datenschutz|geschlossenes schloss|schloss|sicherheit
+🔓|nicht gesichert|offenes schloss|schloss
+🔏|datenschutz|privat|schloss mit füller|sicherheit
+🔐|datenschutz|privat|schloss mit schlüssel|sicherheit
+🔑|passwort|schlüssel
+🗝️|alter schlüssel|schlüssel
+🔨|hammer|werkzeug
+🪓|axt|beil|hacken|holz|spalten
+⛏️|pickel|werkzeug
+🛠️|hammer und schraubenschlüssel|schraubenschlüssel|werkzeug
+🗡️|dolch|waffe
+💣|bombe|comic
+🪃|boomerang|bumerang
+🏹|bogen|pfeil und bogen
+🛡️|schild|schutzschild
+🪚|handsäge|holz|säge|tischler|werkzeug
+🔧|schraubenschlüssel|werkzeug
+🪛|schraubendreher|schraubenzieher|werkzeug
+🔩|mutter und schraube|schraube
+🗜️|schraubzwinge|werkzeug
+🦯|barrierefreiheit|blindenstock
+🔗|kettenglieder|linksymbol|verknüpfungssymbol|zwei ringe
+⛓️|eisen|ketten
+🪝|angelhaken|haken
+🧰|mechaniker|werkzeugkasten
+🧲|anziehungskraft|magnetisch
+🪜|klettern|leiter|sprosse|stufe
+🧪|chemie|experiment|labor|reagenzglas|versuche
+🧫|bakterienkultur|biologie|petrischale|labor
+🧬|biologie|dna|evolution|genetik|leben
+🔬|labor|mikroskop
+🔭|teleskop
+📡|antenne|satellitenschüssel|schüssel
+💉|arzt|injektion|nadel|spritze
+🩸|blutspende|blutstropfen|medizin|menstruation
+💊|arzt|kapsel|medizin|tabletten
+🩹|heftpflaster|pflaster
+🩼|behinderung|gehhilfe|gehstütze|krücke|schmerzen|stock|verletzt
+🩺|arzt|herz|medizin|stethoskop
+🩻|knochen|medizin|radiologie|röntgenbild|skelett|arzt|bildgebung
+🚪|eingang|geschlossen|tür|ausgang
+🛗|aufzug|fahrstuhl|lift
+🪞|reflexion|spiegelbild
+🪟|aussicht|durchsichtig|fenster|frische luft|öffnung|rahmen
+🛏️|bett|hotel|schlafen|übernachtung
+🛋️|lampe|sofa und lampe
+🪑|sitzen|stuhl
+🚽|toilette|wc
+🪠|saugglocken|toilette|verstopft|klempner|pümpel|sanitär|wc
+🚿|dusche
+🛁|badewanne|badezimmer
+🪤|falle|mausefalle|mäusefalle
+🪒|rasieren|rasierer|scharf
+🧴|creme|feuchtigkeitscreme|körpercreme|shampoo|sonnencreme
+🧷|punk|sicherheitsnadel|windel
+🧹|besen|fegen|hexe|kehren
+🧺|korb|picknick|wäsche
+🧻|klopapier|küchenrolle|papiertücher|toilettenpapier
+🪣|behälter|bottich|eimer|kübel
+🧼|baden|säubern|seifenschale
+🫧|blasen|reinigen|seifenblasen|unter wasser|wasserblasen|sauber|schaum
+🪥|badezimmer|bürste|sauber|zahnbürste|zähne|zahnhygiene
+🧽|absorbieren|aufsaugen|porös|schwamm
+🧯|feuerlöscher|löschen
+🛒|einkaufen|einkaufswagen
+🚬|rauchen|rauchersymbol|zigarette
+⚰️|beerdigung|sarg|tod|tot
+🪦|friedhof|grabstein
+⚱️|beerdigung|tod|tot|urne
+🧿|glücksbringer|nazar-amulett|talisman
+🪬|amulett|fatima|glückssymbol|hamsa|maria|miriam|schutz
+🗿|gesicht|maske|osterinsel|statue
+🪧|demonstration|mahnwache|plakat|protestschild|schild
+🪪|ausweis|führerschein|personalausweis|plakette|fahrausweis|id
+🏧|atm|symbol geldautomat|geldautomat
+🚮|müll|sauberkeit|symbol papierkorb|papierkorb
+🚰|trinkwasser|wasser
+♿|barrierefrei|behindertengerecht|symbol rollstuhl|rollstuhl
+🚹|herrentoilette
+🚺|damentoilette
+🚻|toiletten|wc
+🚼|symbol baby|wickelraum|baby-symbol
+🚾|toilette|wc
+🛂|passkontrolle
+🛃|zollkontrolle
+🛄|gepäckausgabe
+🛅|gepäckaufbewahrung|schließfach|schliessfach
+⚠️|dreieck|warnung|vorsicht|achtung
+🚸|kinder überqueren die straße|vorsicht|kinder-queren-die-strasse-schild
+⛔|keine durchfahrt|verboten|zutritt verboten|einbahn|halt
+🚫|verboten|verbotszeichen
+🚳|fahrräder verboten|radfahren verboten
+🚭|rauchen verboten|rauchverbot
+🚯|abfall verboten|müll|verboten
+🚱|kein trinkwasser|verboten|wasser
+🚷|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|hoch
+➡️|nach rechts|osten|pfeil nach rechts|rechtspfeil
+⬇️|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
+🔃|im uhrzeigersinn|kreisförmige pfeile im uhrzeigersinn|pfeile
+🔄|gegen den uhrzeigersinn|kreisförmige pfeile gegen den uhrzeigersinn|pfeile gegen den uhrzeigersinn
+🔙|back-pfeil|links|pfeil|zurück
+🔚|end-pfeil|links|pfeil
+🔛|on!-pfeil|pfeil|rechts und links
+🔜|pfeil|rechts|soon-pfeil
+🔝|pfeil nach oben|top-pfeil|hoch
+🛐|religion|religiöse stätte
+⚛️|atheist|atomzeichen
+🕉️|hinduismus|om|religion
+☯️|daoismus|religion|yang|yin und yang
+✝️|christentum|kreuz|lateinisches kreuz|religion
+☪️|hilal und stern|islam|religion|stern
+☮️|friedensbewegung|friedenssymbol|friedenszeichen
+🕎|leuchter|menora|religion
+🔯|hexagramm mit punkt|wahrsager
+🪯|religion|schwert|sikhismus|khanda-emblem
+♊|sternzeichen|zwillinge (sternzeichen)
+♋|krebs (sternzeichen)|sternzeichen
+♌|löwe (sternzeichen)|sternzeichen
+♍|jungfrau (sternzeichen)|sternzeichen
+♎|sternzeichen|waage (sternzeichen)
+♏|skorpion (sternzeichen)|sternzeichen
+⛎|schlangenträger|sternbild
+🔀|gekreuzt|pfeile|verschlungene pfeile nach rechts|zufallsmodus
+🔁|im uhrzeigersinn|pfeile|wiederholen
+🔂|dasselbe wiederholen|im uhrzeigersinn|noch einmal|pfeile|titel wiederholen|wiederholen
+▶️|abspielen|dreieck|pfeil|rechts|wiedergabe
+⏩|doppelpfeile nach rechts|überspringen|vorwärts|weiter
+⏭️|doppelpfeil|dreieck|nächster titel|vorwärts|weiter
+⏯️|dreieck|pause|pfeil|rechts|wiedergabe oder pause
+◀️|dreieck|links|pfeil|zurück
+⏪|doppelpfeile nach links|dreieck|pfeil|vorheriger titel|zurückspulen
+⏮️|doppelpfeil|dreieck|pfeil|vorheriger titel|zurück
+🔼|aufwärts-schaltfläche|nach oben|pfeil|schaltfläche
+⏫|aufwärts|doppelpfeile nach oben|doppelt|nach oben|pfeil
+🔽|abwärts-schaltfläche|nach unten|pfeil|schaltfläche
+⏬|doppelpfeile nach unten|doppelt abwärts|nach unten|pfeil
+⏸️|pause|streifen|vertikal
+⏹️|aufnahme stoppen|quadrat|stopp
+⏺️|aufnahme|aufnehmen|kreis
+⏏️|auswerfen|auswurftaste|medien
+🎦|filmkamera|kinosymbol|unterhaltung
+🔅|dimmen|gedimmt|helligkeit|schwache helligkeit|taste dimmen
+🔆|heller-taste|helligkeit|starke helligkeit
+📶|balkenförmige signalstärkenanzeige|empfang|mobilfunksignal|mobiltelefon|signalstärke
+🛜|computer|drahtlos|internet|kabellos|netzwerk|wlan|network|wifi
+📳|mobiltelefon|vibrationsmodus
+📴|ausschalten|handy aus|mobiltelefon aus
+⚧️|symbol für transgender|transgender-symbol
+🟰|gleichheitszeichen extrafett|mathematik
+♾️|ewig|grenzenlos|unendlichkeit
+‼️|ausrufezeichen|doppeltes ausrufezeichen|rot|satzzeichen
+💱|geldwechsel|währung|wechsel
+💲|dollarzeichen extrafett|geld|währung
+♻️|recycling-symbol
+⚜️|fleur-de-lis|lilie
+🔱|anker|dreizack|triton
+📛|namensschild|schild
+🔰|anfänger|japanisches anfänger-zeichen|japanisches symbol
+⭕|großer kreis|hohler roter kreis|kreis|o|rot|grosser kreis
+❌|abbrechen|durchgestrichen|kreuzzeichen|multiplikation|multiplizieren|x
+❎|angekreuztes feld|angekreuztes kästchen|feld|kreuz|quadrat|x
+➰|schleife|looping
+➿|doppelschleife|rechteck|schleife
+〽️|japanisch|teilalternationszeichen|zeichensetzung
+🔠|eingabesymbol lateinische großbuchstaben|großbuchstaben|lateinische großbuchstaben|eingabesymbol lateinische grossbuchstaben|grossbuchstaben|lateinische grossbuchstaben
+🔡|eingabesymbol lateinische kleinbuchstaben|kleinbuchstaben|lateinische kleinbuchstaben
+🔢|eingabesymbol zahlen|zahlen
+🔣|eingabesymbol sonderzeichen|sonderzeichen
+🔤|buchstaben|eingabesymbol lateinische buchstaben|lateinische buchstaben
+🅰️|a|blutgruppe|großbuchstabe a in rotem quadrat|negativ|positiv|grossbuchstabe a in quadrat
+🆎|ab|blutgruppe|großbuchstaben ab in rotem quadrat|negativ|positiv|grossbuchstaben ab in rotem quadrat
+🅱️|blutgruppe|großbuchstabe b in rotem quadrat|negativ|positiv|grossbuchstabe b in quadrat
+🆑|cl|großbuchstaben cl in rotem quadrat|grossbuchstaben cl in rotem quadrat
+🆒|cool|wort cool in blauem quadrat
+🆓|free|wort free in blauem quadrat
+🆔|großbuchstaben id in lila quadrat|id|grossbuchstaben id in lila quadrat
+Ⓜ️|buchstabe m in kreis|kreis|m
+🆕|neu|new|wort new in blauem quadrat
+🆖|großbuchstaben ng in blauem quadrat|ng|grossbuchstaben ng in blauem quadrat
+🅾️|0|blutgruppe|großbuchstabe o in rotem quadrat|negativ|positiv|grossbuchstabe o in quadrat
+🆗|großbuchstaben ok in blauem quadrat|ok|grossbuchstaben ok in blauem quadrat
+🅿️|großbuchstabe p in blauem quadrat|parkplatz|quadrat|grossbuchstabe p in quadrat
+🆘|hilfe|sos-zeichen
+🆙|up|blau|quadrat|schriftzug up! im blauen quadrat
+🆚|großbuchstaben vs in orangefarbenem quadrat|schriftzug vs in orangem quadrat|versus|vs|grossbuchstaben vs in orangefarbenem quadrat
+🈁|koko|japanisches schriftzeichen|schriftzeichen koko
+🈂️|sa|japanisches schriftzeichen|schriftzeichen sa
+🈷️|japanisches schriftzeichen|schriftzeichen für monatsbetrag
+🈶|japanisches schriftzeichen|nicht gratis|schriftzeichen für nicht gratis
+🈯|japanisches schriftzeichen|reserviert|schriftzeichen für reserviert
+🉐|japanisches schriftzeichen|schnäppchen|schriftzeichen für schnäppchen
+🈹|japanisches schriftzeichen|rabatt|schriftzeichen für rabatt
+🈚|gratis|japanisches schriftzeichen|schriftzeichen für gratis
+🈲|japanisches schriftzeichen|schriftzeichen für verbieten|verbieten
+🉑|akzeptieren|japanisches schriftzeichen|schriftzeichen für akzeptieren
+🈸|anwenden|japanisches schriftzeichen|schriftzeichen für anwenden
+🈴|bestehen|japanisches schriftzeichen|schriftzeichen für note zum bestehen
+🈳|japanisches schriftzeichen|schriftzeichen für zimmer frei|zimmer frei
+🈺|geöffnet|japanisches schriftzeichen|schriftzeichen für geöffnet
+🈵|japanisches schriftzeichen|kein zimmer frei|schriftzeichen für kein zimmer frei
+🔴|ball|punkt|roter punkt|grosser roter kreis|kreis
+🟠|oranger punkt|punkt
+🟡|gelber punkt|punkt
+🟢|grüner punkt|punkt
+🔵|ball|blauer punkt|punkt|grosser blauer kreis|kreis
+🟣|lila|punkt
+🟤|brauner punkt|punkt
+⚫|ball|punkt|schwarzer punkt|grosser schwarzer kreis|kreis
+⚪|ball|punkt|weißer punkt|grosser weisser kreis|kreis|weiss
+🟥|quadrat|rotes quadrat
+🟧|oranges quadrat|quadrat
+🟨|gelbes quadrat|quadrat
+🟩|grünes quadrat|quadrat
+🟦|blaues quadrat|quadrat
+🟪|lila|quadrat
+🟫|braunes quadrat|quadrat
+⬛|großes schwarzes quadrat|quadrat|schwarz|grosses schwarzes quadrat
+⬜|großes weißes quadrat|quadrat|weiß|grosses weisses quadrat|weiss
+◼️|mittelgroßes schwarzes quadrat|quadrat|schwarz|mittelgrosses schwarzes quadrat
+◻️|mittelgroßes weißes quadrat|quadrat|weiß|mittelgrosses weisses quadrat|weiss
+◾|mittelkleines schwarzes quadrat|quadrat|schwarz
+◽|mittelkleines weißes quadrat|quadrat|weiß|mittelkleines weisses quadrat|weiss
+▪️|kleines schwarzes quadrat|quadrat|schwarz
+▫️|kleines weißes quadrat|quadrat|weiß|kleines weisses quadrat|weiss
+🔶|große orangefarbene raute|orangefarben|raute|grosse orangefarbene raute
+🔷|blau|große blaue raute|raute|grosse blaue raute
+🔸|kleine orangefarbene raute|orangefarben|raute
+🔹|blau|kleine blaue raute|raute
+🔺|aufwärts|dreieck|rotes dreieck mit der spitze nach oben
+🔻|abwärts|dreieck|rotes dreieck mit der spitze nach unten
+💠|diamant|mit punkt|rautenform mit punkt
+🔘|optionsfeld|schaltfläche
+🔳|quadratisch|schaltfläche|weiße quadratische schaltfläche|weisse quadratische schaltfläche
+🔲|quadratisch|schaltfläche|schwarze quadratische schaltfläche
+🏁|karierte flagge|rennen|sport|zielflagge|flagge
+🚩|dreiecksflagge|flagge|rot|wimpel
+🎌|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|lgbtqia|pride
+🏳️‍⚧️|flagge|transgender-flagge|hellblau|pink|transgenderflagge|weiss|lgbtqia
+🏴‍☠️|jolly roger|piratenfahne|piratenflagge|schatz|flagge
+🇦🇩|andorra
+🇦🇪|vereinigte arabische emirate
+🇦🇫|afghanistan
+🇦🇬|antigua und barbuda
+🇦🇮|anguilla
+🇦🇱|albanien
+🇦🇲|armenien
+🇦🇴|angola
+🇦🇶|antarktis
+🇦🇷|argentinien
+🇦🇸|amerikanisch-samoa
+🇦🇹|österreich
+🇦🇺|australien
+🇦🇼|aruba
+🇦🇽|ålandinseln
+🇦🇿|aserbaidschan
+🇧🇦|bosnien und herzegowina
+🇧🇧|barbados
+🇧🇩|bangladesch
+🇧🇪|belgien
+🇧🇫|burkina faso
+🇧🇬|bulgarien
+🇧🇭|bahrain
+🇧🇮|burundi
+🇧🇯|benin
+🇧🇱|st. barthélemy
+🇧🇲|bermuda
+🇧🇳|brunei darussalam
+🇧🇴|bolivien
+🇧🇶|karibische niederlande
+🇧🇷|brasilien
+🇧🇸|bahamas
+🇧🇹|bhutan
+🇧🇼|botsuana|botswana
+🇧🇾|belarus
+🇧🇿|belize
+🇨🇦|kanada
+🇨🇨|kokosinseln
+🇨🇩|kongo-kinshasa
+🇨🇫|zentralafrikanische republik
+🇨🇬|kongo-brazzaville
+🇨🇭|schweiz
+🇨🇮|côte d’ivoire
+🇨🇰|cookinseln
+🇨🇱|chile
+🇨🇲|kamerun
+🇨🇳|china
+🇨🇴|kolumbien
+🇨🇷|costa rica
+🇨🇺|kuba
+🇨🇻|cabo verde|kapverden
+🇨🇼|curaçao
+🇨🇽|weihnachtsinsel
+🇨🇾|zypern
+🇨🇿|tschechien
+🇩🇪|deutschland
+🇩🇯|dschibuti
+🇩🇰|dänemark
+🇩🇲|dominica
+🇩🇴|dominikanische republik
+🇩🇿|algerien
+🇪🇨|ecuador
+🇪🇪|estland
+🇪🇬|ägypten
+🇪🇭|westsahara
+🇪🇷|eritrea
+🇪🇸|spanien
+🇪🇹|äthiopien
+🇪🇺|europäische union
+🇫🇮|finnland
+🇫🇯|fidschi
+🇫🇰|falklandinseln
+🇫🇲|mikronesien
+🇫🇴|färöer
+🇫🇷|frankreich
+🇬🇦|gabun
+🇬🇧|vereinigtes königreich|grossbritannien
+🇬🇩|grenada
+🇬🇪|georgien
+🇬🇫|französisch-guayana
+🇬🇬|guernsey
+🇬🇭|ghana
+🇬🇮|gibraltar
+🇬🇱|grönland
+🇬🇲|gambia
+🇬🇳|guinea
+🇬🇵|guadeloupe
+🇬🇶|äquatorialguinea
+🇬🇷|griechenland
+🇬🇸|südgeorgien und die südlichen sandwichinseln
+🇬🇹|guatemala
+🇬🇺|guam
+🇬🇼|guinea-bissau
+🇬🇾|guyana
+🇭🇰|sonderverwaltungsregion hongkong
+🇭🇳|honduras
+🇭🇷|kroatien
+🇭🇹|haiti
+🇭🇺|ungarn
+🇮🇨|kanarische inseln
+🇮🇩|indonesien
+🇮🇪|irland
+🇮🇱|israel
+🇮🇲|isle of man
+🇮🇳|indien
+🇮🇴|britisches territorium im indischen ozean
+🇮🇶|irak
+🇮🇷|iran
+🇮🇸|island
+🇮🇹|italien
+🇯🇪|jersey
+🇯🇲|jamaika
+🇯🇴|jordanien
+🇯🇵|japan
+🇰🇪|kenia
+🇰🇬|kirgisistan
+🇰🇭|kambodscha
+🇰🇮|kiribati
+🇰🇲|komoren
+🇰🇳|st. kitts und nevis
+🇰🇵|nordkorea
+🇰🇷|südkorea
+🇰🇼|kuwait
+🇰🇾|kaimaninseln
+🇰🇿|kasachstan
+🇱🇦|laos
+🇱🇧|libanon
+🇱🇨|st. lucia
+🇱🇮|liechtenstein
+🇱🇰|sri lanka
+🇱🇷|liberia
+🇱🇸|lesotho
+🇱🇹|litauen
+🇱🇺|luxemburg
+🇱🇻|lettland
+🇱🇾|libyen
+🇲🇦|marokko
+🇲🇨|monaco
+🇲🇩|republik moldau
+🇲🇪|montenegro
+🇲🇬|madagaskar
+🇲🇭|marshallinseln
+🇲🇰|nordmazedonien
+🇲🇱|mali
+🇲🇲|myanmar
+🇲🇳|mongolei
+🇲🇴|sonderverwaltungsregion macau
+🇲🇵|nördliche marianen
+🇲🇶|martinique
+🇲🇷|mauretanien
+🇲🇸|montserrat
+🇲🇹|malta
+🇲🇺|mauritius
+🇲🇻|malediven
+🇲🇼|malawi
+🇲🇽|mexiko
+🇲🇾|malaysia
+🇲🇿|mosambik
+🇳🇦|namibia
+🇳🇨|neukaledonien
+🇳🇪|niger
+🇳🇫|norfolkinsel
+🇳🇬|nigeria
+🇳🇮|nicaragua
+🇳🇱|niederlande
+🇳🇴|norwegen
+🇳🇵|nepal
+🇳🇷|nauru
+🇳🇺|niue
+🇳🇿|neuseeland
+🇴🇲|oman
+🇵🇦|panama
+🇵🇪|peru
+🇵🇫|französisch-polynesien
+🇵🇬|papua-neuguinea
+🇵🇭|philippinen
+🇵🇰|pakistan
+🇵🇱|polen
+🇵🇲|st. pierre und miquelon
+🇵🇳|pitcairninseln
+🇵🇷|puerto rico
+🇵🇸|palästinensische autonomiegebiete
+🇵🇹|portugal
+🇵🇼|palau
+🇵🇾|paraguay
+🇶🇦|katar
+🇷🇪|réunion
+🇷🇴|rumänien
+🇷🇸|serbien
+🇷🇺|russland
+🇷🇼|ruanda
+🇸🇦|saudi-arabien
+🇸🇧|salomonen|salomon-inseln
+🇸🇨|seychellen
+🇸🇩|sudan
+🇸🇪|schweden
+🇸🇬|singapur
+🇸🇭|st. helena
+🇸🇮|slowenien
+🇸🇰|slowakei
+🇸🇱|sierra leone
+🇸🇲|san marino
+🇸🇳|senegal
+🇸🇴|somalia
+🇸🇷|suriname
+🇸🇸|südsudan
+🇸🇹|são tomé und príncipe
+🇸🇻|el salvador
+🇸🇽|sint maarten
+🇸🇾|syrien
+🇸🇿|eswatini
+🇹🇨|turks- und caicosinseln
+🇹🇩|tschad
+🇹🇫|französische süd- und antarktisgebiete
+🇹🇬|togo
+🇹🇭|thailand
+🇹🇯|tadschikistan
+🇹🇰|tokelau
+🇹🇱|timor-leste|osttimor
+🇹🇲|turkmenistan
+🇹🇳|tunesien
+🇹🇴|tonga
+🇹🇷|türkei
+🇹🇹|trinidad und tobago
+🇹🇻|tuvalu
+🇹🇼|taiwan
+🇹🇿|tansania
+🇺🇦|ukraine
+🇺🇬|uganda
+🇺🇸|vereinigte staaten
+🇺🇾|uruguay
+🇺🇿|usbekistan
+🇻🇦|vatikanstadt
+🇻🇨|st. vincent und die grenadinen
+🇻🇪|venezuela
+🇻🇬|britische jungferninseln
+🇻🇮|amerikanische jungferninseln
+🇻🇳|vietnam
+🇻🇺|vanuatu
+🇼🇫|wallis und futuna
+🇼🇸|samoa
+🇽🇰|kosovo
+🇾🇪|jemen
+🇾🇹|mayotte
+🇿🇦|südafrika
+🇿🇲|sambia
+🇿🇼|simbabwe|zimbabwe
+🏴󠁧󠁢󠁥󠁮󠁧󠁿|england
+🏴󠁧󠁢󠁳󠁣󠁴󠁿|schottland
+🏴󠁧󠁢󠁷󠁬󠁳󠁿|wales

+ 6 - 13
app/assets/license.html

@@ -84,13 +84,6 @@
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
-<h2>android-gif-drawable</h2>
-
-<p>Copyright (c) 2016-2017 Karol Wrótniak, Droids on Roids</p>
-
-<p>Licensed under the MIT License (copy below)</p>
-
-
 <h2>Base32</h2>
 
 <p>Copyright © 2010, Data Base Architects, Inc. All rights reserved.</p>
@@ -223,11 +216,11 @@ SUCH DAMAGE.</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
-<h2>Java implementation of SCrypt</h2>
+<h2>Bouncy Castle – Open-source cryptographic APIs</h2>
 
-<p>Copyright (c) 2013 Will Glozer</p>
+<p>Copyright (c) 2000-2023 The Legion Of The Bouncy Castle Inc. (https://www.bouncycastle.org)</p>
 
-<p>Licensed under the Apache License, version 2.0 (copy below).</p>
+<p>Licensed under the MIT License (copy below).</p>
 
 
 <h2>jnacl</h2>
@@ -289,7 +282,7 @@ POSSIBILITY OF SUCH DAMAGE.</p>
 
 <p>Copyright (c) 2016 UPTech</p>
 
-<p>Licensed under the MIT License (copy below)</p>
+<p>Licensed under the MIT License (copy below).</p>
 
 
 <h2>nv-websocket-client</h2>
@@ -351,7 +344,7 @@ SUCH DAMAGE.</p>
 
 <p>Copyright (c) 2004-2017 QOS.ch All rights reserved.</p>
 
-<p>Licensed under the MIT License (copy below)</p>
+<p>Licensed under the MIT License (copy below).</p>
 
 
 <h2>SQLCipher</h2>
@@ -417,7 +410,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
 
 <p>Copyright (c) 2016 Data Theorem, Inc.</p>
 
-<p>Licensed under the MIT License (copy below)</p>
+<p>Licensed under the MIT License (copy below).</p>
 
 
 <h2>WebRTC</h2>

+ 5 - 3
app/build.gradle

@@ -18,14 +18,14 @@ if (getGradle().getStartParameter().getTaskRequests().toString().contains("Hms")
 // version codes
 
 // Only use the scheme "<major>.<minor>.<patch>" for the app_version
-def app_version = "5.3.1"
+def app_version = "5.3.2"
 
 // beta_suffix with leading dash (e.g. `-beta1`)
 // should be one of (alpha|beta|rc) and an increasing number or empty for a regular release.
 // Note: in nightly builds this will be overwritten with a nightly version "-n12345"
 def beta_suffix = ""
 
-def defaultVersionCode = 956
+def defaultVersionCode = 959
 
 /**
  * Return the git hash, if git is installed.
@@ -595,6 +595,7 @@ android {
             jniDebuggable false
             minifyEnabled true
             shrinkResources false // Caused inconsistencies between local and CI builds
+            vcsInfo.include false // For reproducible builds independent from git history
             proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-project.txt'
             ndk {
                 debugSymbolLevel 'FULL' // 'SYMBOL_TABLE'
@@ -772,7 +773,6 @@ dependencies {
     implementation 'commons-io:commons-io:2.6'
     implementation 'org.apache.commons:commons-text:1.10.0'
     implementation "org.slf4j:slf4j-api:$slf4j_version"
-    implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.28'
     implementation 'com.vanniktech:android-image-cropper:4.5.0'
     implementation 'com.datatheorem.android.trustkit:trustkit:1.1.5'
     implementation 'me.zhanghai.android.fastscroll:library:1.3.0'
@@ -815,6 +815,8 @@ dependencies {
     implementation 'androidx.window:window:1.2.0'
     kapt 'androidx.room:room-compiler:2.6.1'
 
+    implementation 'org.bouncycastle:bcprov-jdk15to18:1.78.1'
+
     implementation 'com.google.android.material:material:1.10.0' // last version before switch to tonal system: https://github.com/material-components/material-components-android/releases/tag/1.11.0
     implementation 'com.google.zxing:core:3.3.3' // zxing 3.4 crashes on API < 24
     implementation 'com.googlecode.libphonenumber:libphonenumber:8.13.23' // make sure to update this in domain's build.gradle as well

+ 0 - 18
app/jni/Android.mk

@@ -11,24 +11,6 @@ LOCAL_PATH       := $(call my-dir)
 
 TARGET_PLATFORM  := android-33
 
-# libscrypt
-
-include $(CLEAR_VARS)
-
-LOCAL_MODULE     := scrypt
-
-LOCAL_SRC_FILES  := $(LOCAL_PATH)/scrypt/c/scrypt_jni.c
-LOCAL_SRC_FILES  += $(LOCAL_PATH)/scrypt/c/crypto_scrypt-nosse.c
-LOCAL_SRC_FILES  += $(LOCAL_PATH)/scrypt/c/sha256.c
-LOCAL_C_INCLUDES := $(LOCAL_PATH)/scrypt/include
-
-LOCAL_CFLAGS     += -DANDROID -DHAVE_CONFIG_H -DANDROID_TARGET_ARCH="$(TARGET_ARCH)"
-LOCAL_LDFLAGS    += -lc -llog
-
-LOCAL_LDFLAGS    += -Wl,--build-id=none  # Reproducible builds
-
-include $(BUILD_SHARED_LIBRARY)
-
 # libnacl
 
 include $(CLEAR_VARS)

+ 0 - 338
app/jni/scrypt/c/crypto_scrypt-nosse.c

@@ -1,338 +0,0 @@
-/*-
- * Copyright 2009 Colin Percival
- * All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions
- * are met:
- * 1. Redistributions of source code must retain the above copyright
- *    notice, this list of conditions and the following disclaimer.
- * 2. Redistributions in binary form must reproduce the above copyright
- *    notice, this list of conditions and the following disclaimer in the
- *    documentation and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
- * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- * ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
- * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
- * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
- * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
- * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
- * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
- * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
- * SUCH DAMAGE.
- *
- * This file was originally written by Colin Percival as part of the Tarsnap
- * online backup system.
- */
-#include "scrypt_platform.h"
-
-#include <sys/types.h>
-#include <sys/mman.h>
-
-#include <errno.h>
-#include <stdint.h>
-#include <stdlib.h>
-#include <string.h>
-
-#include "sha256.h"
-#include "sysendian.h"
-
-#include "crypto_scrypt.h"
-
-static void blkcpy(void *, void *, size_t);
-static void blkxor(void *, void *, size_t);
-static void salsa20_8(uint32_t[16]);
-static void blockmix_salsa8(uint32_t *, uint32_t *, uint32_t *, size_t);
-static uint64_t integerify(void *, size_t);
-static void smix(uint8_t *, size_t, uint64_t, uint32_t *, uint32_t *);
-
-static void
-blkcpy(void * dest, void * src, size_t len)
-{
-	size_t * D = dest;
-	size_t * S = src;
-	size_t L = len / sizeof(size_t);
-	size_t i;
-
-	for (i = 0; i < L; i++)
-		D[i] = S[i];
-}
-
-static void
-blkxor(void * dest, void * src, size_t len)
-{
-	size_t * D = dest;
-	size_t * S = src;
-	size_t L = len / sizeof(size_t);
-	size_t i;
-
-	for (i = 0; i < L; i++)
-		D[i] ^= S[i];
-}
-
-/**
- * salsa20_8(B):
- * Apply the salsa20/8 core to the provided block.
- */
-static void
-salsa20_8(uint32_t B[16])
-{
-	uint32_t x[16];
-	size_t i;
-
-	blkcpy(x, B, 64);
-	for (i = 0; i < 8; i += 2) {
-#define R(a,b) (((a) << (b)) | ((a) >> (32 - (b))))
-		/* Operate on columns. */
-		x[ 4] ^= R(x[ 0]+x[12], 7);  x[ 8] ^= R(x[ 4]+x[ 0], 9);
-		x[12] ^= R(x[ 8]+x[ 4],13);  x[ 0] ^= R(x[12]+x[ 8],18);
-
-		x[ 9] ^= R(x[ 5]+x[ 1], 7);  x[13] ^= R(x[ 9]+x[ 5], 9);
-		x[ 1] ^= R(x[13]+x[ 9],13);  x[ 5] ^= R(x[ 1]+x[13],18);
-
-		x[14] ^= R(x[10]+x[ 6], 7);  x[ 2] ^= R(x[14]+x[10], 9);
-		x[ 6] ^= R(x[ 2]+x[14],13);  x[10] ^= R(x[ 6]+x[ 2],18);
-
-		x[ 3] ^= R(x[15]+x[11], 7);  x[ 7] ^= R(x[ 3]+x[15], 9);
-		x[11] ^= R(x[ 7]+x[ 3],13);  x[15] ^= R(x[11]+x[ 7],18);
-
-		/* Operate on rows. */
-		x[ 1] ^= R(x[ 0]+x[ 3], 7);  x[ 2] ^= R(x[ 1]+x[ 0], 9);
-		x[ 3] ^= R(x[ 2]+x[ 1],13);  x[ 0] ^= R(x[ 3]+x[ 2],18);
-
-		x[ 6] ^= R(x[ 5]+x[ 4], 7);  x[ 7] ^= R(x[ 6]+x[ 5], 9);
-		x[ 4] ^= R(x[ 7]+x[ 6],13);  x[ 5] ^= R(x[ 4]+x[ 7],18);
-
-		x[11] ^= R(x[10]+x[ 9], 7);  x[ 8] ^= R(x[11]+x[10], 9);
-		x[ 9] ^= R(x[ 8]+x[11],13);  x[10] ^= R(x[ 9]+x[ 8],18);
-
-		x[12] ^= R(x[15]+x[14], 7);  x[13] ^= R(x[12]+x[15], 9);
-		x[14] ^= R(x[13]+x[12],13);  x[15] ^= R(x[14]+x[13],18);
-#undef R
-	}
-	for (i = 0; i < 16; i++)
-		B[i] += x[i];
-}
-
-/**
- * blockmix_salsa8(Bin, Bout, X, r):
- * Compute Bout = BlockMix_{salsa20/8, r}(Bin).  The input Bin must be 128r
- * bytes in length; the output Bout must also be the same size.  The
- * temporary space X must be 64 bytes.
- */
-static void
-blockmix_salsa8(uint32_t * Bin, uint32_t * Bout, uint32_t * X, size_t r)
-{
-	size_t i;
-
-	/* 1: X <-- B_{2r - 1} */
-	blkcpy(X, &Bin[(2 * r - 1) * 16], 64);
-
-	/* 2: for i = 0 to 2r - 1 do */
-	for (i = 0; i < 2 * r; i += 2) {
-		/* 3: X <-- H(X \xor B_i) */
-		blkxor(X, &Bin[i * 16], 64);
-		salsa20_8(X);
-
-		/* 4: Y_i <-- X */
-		/* 6: B' <-- (Y_0, Y_2 ... Y_{2r-2}, Y_1, Y_3 ... Y_{2r-1}) */
-		blkcpy(&Bout[i * 8], X, 64);
-
-		/* 3: X <-- H(X \xor B_i) */
-		blkxor(X, &Bin[i * 16 + 16], 64);
-		salsa20_8(X);
-
-		/* 4: Y_i <-- X */
-		/* 6: B' <-- (Y_0, Y_2 ... Y_{2r-2}, Y_1, Y_3 ... Y_{2r-1}) */
-		blkcpy(&Bout[i * 8 + r * 16], X, 64);
-	}
-}
-
-/**
- * integerify(B, r):
- * Return the result of parsing B_{2r-1} as a little-endian integer.
- */
-static uint64_t
-integerify(void * B, size_t r)
-{
-	uint32_t * X = (void *)((uintptr_t)(B) + (2 * r - 1) * 64);
-
-	return (((uint64_t)(X[1]) << 32) + X[0]);
-}
-
-/**
- * smix(B, r, N, V, XY):
- * Compute B = SMix_r(B, N).  The input B must be 128r bytes in length;
- * the temporary storage V must be 128rN bytes in length; the temporary
- * storage XY must be 256r + 64 bytes in length.  The value N must be a
- * power of 2 greater than 1.  The arrays B, V, and XY must be aligned to a
- * multiple of 64 bytes.
- */
-static void
-smix(uint8_t * B, size_t r, uint64_t N, uint32_t * V, uint32_t * XY)
-{
-	uint32_t * X = XY;
-	uint32_t * Y = &XY[32 * r];
-	uint32_t * Z = &XY[64 * r];
-	uint64_t i;
-	uint64_t j;
-	size_t k;
-
-	/* 1: X <-- B */
-	for (k = 0; k < 32 * r; k++)
-		X[k] = le32dec(&B[4 * k]);
-
-	/* 2: for i = 0 to N - 1 do */
-	for (i = 0; i < N; i += 2) {
-		/* 3: V_i <-- X */
-		blkcpy(&V[i * (32 * r)], X, 128 * r);
-
-		/* 4: X <-- H(X) */
-		blockmix_salsa8(X, Y, Z, r);
-
-		/* 3: V_i <-- X */
-		blkcpy(&V[(i + 1) * (32 * r)], Y, 128 * r);
-
-		/* 4: X <-- H(X) */
-		blockmix_salsa8(Y, X, Z, r);
-	}
-
-	/* 6: for i = 0 to N - 1 do */
-	for (i = 0; i < N; i += 2) {
-		/* 7: j <-- Integerify(X) mod N */
-		j = integerify(X, r) & (N - 1);
-
-		/* 8: X <-- H(X \xor V_j) */
-		blkxor(X, &V[j * (32 * r)], 128 * r);
-		blockmix_salsa8(X, Y, Z, r);
-
-		/* 7: j <-- Integerify(X) mod N */
-		j = integerify(Y, r) & (N - 1);
-
-		/* 8: X <-- H(X \xor V_j) */
-		blkxor(Y, &V[j * (32 * r)], 128 * r);
-		blockmix_salsa8(Y, X, Z, r);
-	}
-
-	/* 10: B' <-- X */
-	for (k = 0; k < 32 * r; k++)
-		le32enc(&B[4 * k], X[k]);
-}
-
-/**
- * crypto_scrypt(passwd, passwdlen, salt, saltlen, N, r, p, buf, buflen):
- * Compute scrypt(passwd[0 .. passwdlen - 1], salt[0 .. saltlen - 1], N, r,
- * p, buflen) and write the result into buf.  The parameters r, p, and buflen
- * must satisfy r * p < 2^30 and buflen <= (2^32 - 1) * 32.  The parameter N
- * must be a power of 2 greater than 1.
- *
- * Return 0 on success; or -1 on error.
- */
-int
-crypto_scrypt(const uint8_t * passwd, size_t passwdlen,
-    const uint8_t * salt, size_t saltlen, uint64_t N, uint32_t r, uint32_t p,
-    uint8_t * buf, size_t buflen)
-{
-	void * B0, * V0, * XY0;
-	uint8_t * B;
-	uint32_t * V;
-	uint32_t * XY;
-	uint32_t i;
-
-	/* Sanity-check parameters. */
-#if SIZE_MAX > UINT32_MAX
-	if (buflen > (((uint64_t)(1) << 32) - 1) * 32) {
-		errno = EFBIG;
-		goto err0;
-	}
-#endif
-	if ((uint64_t)(r) * (uint64_t)(p) >= (1 << 30)) {
-		errno = EFBIG;
-		goto err0;
-	}
-	if (((N & (N - 1)) != 0) || (N < 2)) {
-		errno = EINVAL;
-		goto err0;
-	}
-	if ((r > SIZE_MAX / 128 / p) ||
-#if SIZE_MAX / 256 <= UINT32_MAX
-	    (r > SIZE_MAX / 256) ||
-#endif
-	    (N > SIZE_MAX / 128 / r)) {
-		errno = ENOMEM;
-		goto err0;
-	}
-
-	/* Allocate memory. */
-#ifdef HAVE_POSIX_MEMALIGN
-	if ((errno = posix_memalign(&B0, 64, 128 * r * p)) != 0)
-		goto err0;
-	B = (uint8_t *)(B0);
-	if ((errno = posix_memalign(&XY0, 64, 256 * r + 64)) != 0)
-		goto err1;
-	XY = (uint32_t *)(XY0);
-#ifndef MAP_ANON
-	if ((errno = posix_memalign(&V0, 64, 128 * r * N)) != 0)
-		goto err2;
-	V = (uint32_t *)(V0);
-#endif
-#else
-	if ((B0 = malloc(128 * r * p + 63)) == NULL)
-		goto err0;
-	B = (uint8_t *)(((uintptr_t)(B0) + 63) & ~ (uintptr_t)(63));
-	if ((XY0 = malloc(256 * r + 64 + 63)) == NULL)
-		goto err1;
-	XY = (uint32_t *)(((uintptr_t)(XY0) + 63) & ~ (uintptr_t)(63));
-#ifndef MAP_ANON
-	if ((V0 = malloc(128 * r * N + 63)) == NULL)
-		goto err2;
-	V = (uint32_t *)(((uintptr_t)(V0) + 63) & ~ (uintptr_t)(63));
-#endif
-#endif
-#ifdef MAP_ANON
-	if ((V0 = mmap(NULL, 128 * r * N, PROT_READ | PROT_WRITE,
-#ifdef MAP_NOCORE
-	    MAP_ANON | MAP_PRIVATE | MAP_NOCORE,
-#else
-	    MAP_ANON | MAP_PRIVATE,
-#endif
-	    -1, 0)) == MAP_FAILED)
-		goto err2;
-	V = (uint32_t *)(V0);
-#endif
-
-	/* 1: (B_0 ... B_{p-1}) <-- PBKDF2(P, S, 1, p * MFLen) */
-	PBKDF2_SHA256(passwd, passwdlen, salt, saltlen, 1, B, p * 128 * r);
-
-	/* 2: for i = 0 to p - 1 do */
-	for (i = 0; i < p; i++) {
-		/* 3: B_i <-- MF(B_i, N) */
-		smix(&B[i * 128 * r], r, N, V, XY);
-	}
-
-	/* 5: DK <-- PBKDF2(P, B, 1, dkLen) */
-	PBKDF2_SHA256(passwd, passwdlen, B, p * 128 * r, 1, buf, buflen);
-
-	/* Free memory. */
-#ifdef MAP_ANON
-	if (munmap(V0, 128 * r * N))
-		goto err2;
-#else
-	free(V0);
-#endif
-	free(XY0);
-	free(B0);
-
-	/* Success! */
-	return (0);
-
-err2:
-	free(XY0);
-err1:
-	free(B0);
-err0:
-	/* Failure! */
-	return (-1);
-}

+ 0 - 114
app/jni/scrypt/c/scrypt_jni.c

@@ -1,114 +0,0 @@
-// Copyright (C) 2011 - Will Glozer.  All rights reserved.
-
-#include <errno.h>
-#include <stdlib.h>
-#include <inttypes.h>
-
-#include <jni.h>
-#include "crypto_scrypt.h"
-
-#ifdef ANDROID
-
-#include <android/log.h>
-#include <stdint.h>
-
-#define ANDROID_LOG_TAG "ScryptLog"
-#define ALOG(msg, ...) __android_log_print(ANDROID_LOG_VERBOSE, ANDROID_LOG_TAG, msg, ##__VA_ARGS__)
-
-#define STR1(x)  #x
-#define STR(x)  STR1(x)
-
-void log_basic_info();
-
-#endif
-
-jbyteArray JNICALL scryptN(JNIEnv *env, jclass cls, jbyteArray passwd, jbyteArray salt,
-    jint N, jint r, jint p, jint dkLen)
-{
-
-#ifdef ANDROID
-  log_basic_info();
-#endif
-
-    jint Plen = (*env)->GetArrayLength(env, passwd);
-    jint Slen = (*env)->GetArrayLength(env, salt);
-    jbyte *P = (*env)->GetByteArrayElements(env, passwd, NULL);
-    jbyte *S = (*env)->GetByteArrayElements(env, salt,   NULL);
-    uint8_t *buf = malloc(sizeof(uint8_t) * dkLen);
-    jbyteArray DK = NULL;
-
-    if (P == NULL || S == NULL || buf == NULL) goto cleanup;
-
-    if (crypto_scrypt((uint8_t *) P, Plen, (uint8_t *) S, Slen, N, r, p, buf, dkLen)) {
-        jclass e = (*env)->FindClass(env, "java/lang/IllegalArgumentException");
-        char *msg;
-        switch (errno) {
-            case EINVAL:
-                msg = "N must be a power of 2 greater than 1";
-                break;
-            case EFBIG:
-            case ENOMEM:
-                msg = "Insufficient memory available";
-                break;
-            default:
-                msg = "Memory allocation failed";
-        }
-        (*env)->ThrowNew(env, e, msg);
-        goto cleanup;
-    }
-
-    DK = (*env)->NewByteArray(env, dkLen);
-    if (DK == NULL) goto cleanup;
-
-    (*env)->SetByteArrayRegion(env, DK, 0, dkLen, (jbyte *) buf);
-
-  cleanup:
-
-    if (P) (*env)->ReleaseByteArrayElements(env, passwd, P, JNI_ABORT);
-    if (S) (*env)->ReleaseByteArrayElements(env, salt,   S, JNI_ABORT);
-    if (buf) free(buf);
-
-    return DK;
-}
-
-#ifdef ANDROID
-
-char *get_byte_array_summary(JNIEnv *env, jbyteArray jarray) {
-  int len = (*env)->GetArrayLength(env, jarray);
-  jbyte *bytes = (*env)->GetByteArrayElements(env, jarray, NULL);
-
-  static char buff[10240];
-  int i;
-  for (i = 0; i < len; ++i) {
-    buff[i] = bytes[i] % 32 + 'a';
-  }
-  buff[i] = '\0';
-
-  if (bytes) (*env)->ReleaseByteArrayElements(env, jarray, bytes, JNI_ABORT);
-
-  return buff;
-}
-
-void log_basic_info() {
-  ALOG("Basic info for native scrypt run:");
-  ALOG("Native library targeting arch: %s", STR(ANDROID_TARGET_ARCH));
-}
-
-#endif
-
-static const JNINativeMethod methods[] = {
-    { "scryptN", "([B[BIIII)[B", (void *) scryptN }
-};
-
-jint JNI_OnLoad(JavaVM *vm, void *reserved) {
-    JNIEnv *env;
-
-    if ((*vm)->GetEnv(vm, (void **) &env, JNI_VERSION_1_6) != JNI_OK) {
-        return -1;
-    }
-
-    jclass cls = (*env)->FindClass(env, "com/lambdaworks/crypto/SCrypt");
-    int r = (*env)->RegisterNatives(env, cls, methods, 1);
-
-    return (r == JNI_OK) ? JNI_VERSION_1_6 : -1;
-}

+ 0 - 412
app/jni/scrypt/c/sha256.c

@@ -1,412 +0,0 @@
-/*-
- * Copyright 2005,2007,2009 Colin Percival
- * All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions
- * are met:
- * 1. Redistributions of source code must retain the above copyright
- *    notice, this list of conditions and the following disclaimer.
- * 2. Redistributions in binary form must reproduce the above copyright
- *    notice, this list of conditions and the following disclaimer in the
- *    documentation and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
- * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- * ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
- * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
- * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
- * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
- * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
- * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
- * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
- * SUCH DAMAGE.
- */
-#include "scrypt_platform.h"
-
-#include <sys/types.h>
-
-#include <stdint.h>
-#include <string.h>
-
-#include "sysendian.h"
-
-#include "sha256.h"
-
-/*
- * Encode a length len/4 vector of (uint32_t) into a length len vector of
- * (unsigned char) in big-endian form.  Assumes len is a multiple of 4.
- */
-static void
-be32enc_vect(unsigned char *dst, const uint32_t *src, size_t len)
-{
-	size_t i;
-
-	for (i = 0; i < len / 4; i++)
-		be32enc(dst + i * 4, src[i]);
-}
-
-/*
- * Decode a big-endian length len vector of (unsigned char) into a length
- * len/4 vector of (uint32_t).  Assumes len is a multiple of 4.
- */
-static void
-be32dec_vect(uint32_t *dst, const unsigned char *src, size_t len)
-{
-	size_t i;
-
-	for (i = 0; i < len / 4; i++)
-		dst[i] = be32dec(src + i * 4);
-}
-
-/* Elementary functions used by SHA256 */
-#define Ch(x, y, z)	((x & (y ^ z)) ^ z)
-#define Maj(x, y, z)	((x & (y | z)) | (y & z))
-#define SHR(x, n)	(x >> n)
-#define ROTR(x, n)	((x >> n) | (x << (32 - n)))
-#define S0(x)		(ROTR(x, 2) ^ ROTR(x, 13) ^ ROTR(x, 22))
-#define S1(x)		(ROTR(x, 6) ^ ROTR(x, 11) ^ ROTR(x, 25))
-#define s0(x)		(ROTR(x, 7) ^ ROTR(x, 18) ^ SHR(x, 3))
-#define s1(x)		(ROTR(x, 17) ^ ROTR(x, 19) ^ SHR(x, 10))
-
-/* SHA256 round function */
-#define RND(a, b, c, d, e, f, g, h, k)			\
-	t0 = h + S1(e) + Ch(e, f, g) + k;		\
-	t1 = S0(a) + Maj(a, b, c);			\
-	d += t0;					\
-	h  = t0 + t1;
-
-/* Adjusted round function for rotating state */
-#define RNDr(S, W, i, k)			\
-	RND(S[(64 - i) % 8], S[(65 - i) % 8],	\
-	    S[(66 - i) % 8], S[(67 - i) % 8],	\
-	    S[(68 - i) % 8], S[(69 - i) % 8],	\
-	    S[(70 - i) % 8], S[(71 - i) % 8],	\
-	    W[i] + k)
-
-/*
- * SHA256 block compression function.  The 256-bit state is transformed via
- * the 512-bit input block to produce a new state.
- */
-static void
-SHA256_Transform(uint32_t * state, const unsigned char block[64])
-{
-	uint32_t W[64];
-	uint32_t S[8];
-	uint32_t t0, t1;
-	int i;
-
-	/* 1. Prepare message schedule W. */
-	be32dec_vect(W, block, 64);
-	for (i = 16; i < 64; i++)
-		W[i] = s1(W[i - 2]) + W[i - 7] + s0(W[i - 15]) + W[i - 16];
-
-	/* 2. Initialize working variables. */
-	memcpy(S, state, 32);
-
-	/* 3. Mix. */
-	RNDr(S, W, 0, 0x428a2f98);
-	RNDr(S, W, 1, 0x71374491);
-	RNDr(S, W, 2, 0xb5c0fbcf);
-	RNDr(S, W, 3, 0xe9b5dba5);
-	RNDr(S, W, 4, 0x3956c25b);
-	RNDr(S, W, 5, 0x59f111f1);
-	RNDr(S, W, 6, 0x923f82a4);
-	RNDr(S, W, 7, 0xab1c5ed5);
-	RNDr(S, W, 8, 0xd807aa98);
-	RNDr(S, W, 9, 0x12835b01);
-	RNDr(S, W, 10, 0x243185be);
-	RNDr(S, W, 11, 0x550c7dc3);
-	RNDr(S, W, 12, 0x72be5d74);
-	RNDr(S, W, 13, 0x80deb1fe);
-	RNDr(S, W, 14, 0x9bdc06a7);
-	RNDr(S, W, 15, 0xc19bf174);
-	RNDr(S, W, 16, 0xe49b69c1);
-	RNDr(S, W, 17, 0xefbe4786);
-	RNDr(S, W, 18, 0x0fc19dc6);
-	RNDr(S, W, 19, 0x240ca1cc);
-	RNDr(S, W, 20, 0x2de92c6f);
-	RNDr(S, W, 21, 0x4a7484aa);
-	RNDr(S, W, 22, 0x5cb0a9dc);
-	RNDr(S, W, 23, 0x76f988da);
-	RNDr(S, W, 24, 0x983e5152);
-	RNDr(S, W, 25, 0xa831c66d);
-	RNDr(S, W, 26, 0xb00327c8);
-	RNDr(S, W, 27, 0xbf597fc7);
-	RNDr(S, W, 28, 0xc6e00bf3);
-	RNDr(S, W, 29, 0xd5a79147);
-	RNDr(S, W, 30, 0x06ca6351);
-	RNDr(S, W, 31, 0x14292967);
-	RNDr(S, W, 32, 0x27b70a85);
-	RNDr(S, W, 33, 0x2e1b2138);
-	RNDr(S, W, 34, 0x4d2c6dfc);
-	RNDr(S, W, 35, 0x53380d13);
-	RNDr(S, W, 36, 0x650a7354);
-	RNDr(S, W, 37, 0x766a0abb);
-	RNDr(S, W, 38, 0x81c2c92e);
-	RNDr(S, W, 39, 0x92722c85);
-	RNDr(S, W, 40, 0xa2bfe8a1);
-	RNDr(S, W, 41, 0xa81a664b);
-	RNDr(S, W, 42, 0xc24b8b70);
-	RNDr(S, W, 43, 0xc76c51a3);
-	RNDr(S, W, 44, 0xd192e819);
-	RNDr(S, W, 45, 0xd6990624);
-	RNDr(S, W, 46, 0xf40e3585);
-	RNDr(S, W, 47, 0x106aa070);
-	RNDr(S, W, 48, 0x19a4c116);
-	RNDr(S, W, 49, 0x1e376c08);
-	RNDr(S, W, 50, 0x2748774c);
-	RNDr(S, W, 51, 0x34b0bcb5);
-	RNDr(S, W, 52, 0x391c0cb3);
-	RNDr(S, W, 53, 0x4ed8aa4a);
-	RNDr(S, W, 54, 0x5b9cca4f);
-	RNDr(S, W, 55, 0x682e6ff3);
-	RNDr(S, W, 56, 0x748f82ee);
-	RNDr(S, W, 57, 0x78a5636f);
-	RNDr(S, W, 58, 0x84c87814);
-	RNDr(S, W, 59, 0x8cc70208);
-	RNDr(S, W, 60, 0x90befffa);
-	RNDr(S, W, 61, 0xa4506ceb);
-	RNDr(S, W, 62, 0xbef9a3f7);
-	RNDr(S, W, 63, 0xc67178f2);
-
-	/* 4. Mix local working variables into global state. */
-	for (i = 0; i < 8; i++)
-		state[i] += S[i];
-
-	/* Clean the stack. */
-	memset(W, 0, 256);
-	memset(S, 0, 32);
-	t0 = t1 = 0;
-}
-
-static unsigned char PAD[64] = {
-	0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
-	0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
-	0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
-	0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
-};
-
-/* Add padding and terminating bit-count. */
-static void
-SHA256_Pad(SHA256_CTX * ctx)
-{
-	unsigned char len[8];
-	uint32_t r, plen;
-
-	/*
-	 * Convert length to a vector of bytes -- we do this now rather
-	 * than later because the length will change after we pad.
-	 */
-	be32enc_vect(len, ctx->count, 8);
-
-	/* Add 1--64 bytes so that the resulting length is 56 mod 64. */
-	r = (ctx->count[1] >> 3) & 0x3f;
-	plen = (r < 56) ? (56 - r) : (120 - r);
-	SHA256_Update(ctx, PAD, (size_t)plen);
-
-	/* Add the terminating bit-count. */
-	SHA256_Update(ctx, len, 8);
-}
-
-/* SHA-256 initialization.  Begins a SHA-256 operation. */
-void
-SHA256_Init(SHA256_CTX * ctx)
-{
-
-	/* Zero bits processed so far. */
-	ctx->count[0] = ctx->count[1] = 0;
-
-	/* Magic initialization constants. */
-	ctx->state[0] = 0x6A09E667;
-	ctx->state[1] = 0xBB67AE85;
-	ctx->state[2] = 0x3C6EF372;
-	ctx->state[3] = 0xA54FF53A;
-	ctx->state[4] = 0x510E527F;
-	ctx->state[5] = 0x9B05688C;
-	ctx->state[6] = 0x1F83D9AB;
-	ctx->state[7] = 0x5BE0CD19;
-}
-
-/* Add bytes into the hash. */
-void
-SHA256_Update(SHA256_CTX * ctx, const void *in, size_t len)
-{
-	uint32_t bitlen[2];
-	uint32_t r;
-	const unsigned char *src = in;
-
-	/* Number of bytes left in the buffer from previous updates. */
-	r = (ctx->count[1] >> 3) & 0x3f;
-
-	/* Convert the length into a number of bits. */
-	bitlen[1] = ((uint32_t)len) << 3;
-	bitlen[0] = (uint32_t)(len >> 29);
-
-	/* Update number of bits. */
-	if ((ctx->count[1] += bitlen[1]) < bitlen[1])
-		ctx->count[0]++;
-	ctx->count[0] += bitlen[0];
-
-	/* Handle the case where we don't need to perform any transforms. */
-	if (len < 64 - r) {
-		memcpy(&ctx->buf[r], src, len);
-		return;
-	}
-
-	/* Finish the current block. */
-	memcpy(&ctx->buf[r], src, 64 - r);
-	SHA256_Transform(ctx->state, ctx->buf);
-	src += 64 - r;
-	len -= 64 - r;
-
-	/* Perform complete blocks. */
-	while (len >= 64) {
-		SHA256_Transform(ctx->state, src);
-		src += 64;
-		len -= 64;
-	}
-
-	/* Copy left over data into buffer. */
-	memcpy(ctx->buf, src, len);
-}
-
-/*
- * SHA-256 finalization.  Pads the input data, exports the hash value,
- * and clears the context state.
- */
-void
-SHA256_Final(unsigned char digest[32], SHA256_CTX * ctx)
-{
-
-	/* Add padding. */
-	SHA256_Pad(ctx);
-
-	/* Write the hash. */
-	be32enc_vect(digest, ctx->state, 32);
-
-	/* Clear the context state. */
-	memset((void *)ctx, 0, sizeof(*ctx));
-}
-
-/* Initialize an HMAC-SHA256 operation with the given key. */
-void
-HMAC_SHA256_Init(HMAC_SHA256_CTX * ctx, const void * _K, size_t Klen)
-{
-	unsigned char pad[64];
-	unsigned char khash[32];
-	const unsigned char * K = _K;
-	size_t i;
-
-	/* If Klen > 64, the key is really SHA256(K). */
-	if (Klen > 64) {
-		SHA256_Init(&ctx->ictx);
-		SHA256_Update(&ctx->ictx, K, Klen);
-		SHA256_Final(khash, &ctx->ictx);
-		K = khash;
-		Klen = 32;
-	}
-
-	/* Inner SHA256 operation is SHA256(K xor [block of 0x36] || data). */
-	SHA256_Init(&ctx->ictx);
-	memset(pad, 0x36, 64);
-	for (i = 0; i < Klen; i++)
-		pad[i] ^= K[i];
-	SHA256_Update(&ctx->ictx, pad, 64);
-
-	/* Outer SHA256 operation is SHA256(K xor [block of 0x5c] || hash). */
-	SHA256_Init(&ctx->octx);
-	memset(pad, 0x5c, 64);
-	for (i = 0; i < Klen; i++)
-		pad[i] ^= K[i];
-	SHA256_Update(&ctx->octx, pad, 64);
-
-	/* Clean the stack. */
-	memset(khash, 0, 32);
-}
-
-/* Add bytes to the HMAC-SHA256 operation. */
-void
-HMAC_SHA256_Update(HMAC_SHA256_CTX * ctx, const void *in, size_t len)
-{
-
-	/* Feed data to the inner SHA256 operation. */
-	SHA256_Update(&ctx->ictx, in, len);
-}
-
-/* Finish an HMAC-SHA256 operation. */
-void
-HMAC_SHA256_Final(unsigned char digest[32], HMAC_SHA256_CTX * ctx)
-{
-	unsigned char ihash[32];
-
-	/* Finish the inner SHA256 operation. */
-	SHA256_Final(ihash, &ctx->ictx);
-
-	/* Feed the inner hash to the outer SHA256 operation. */
-	SHA256_Update(&ctx->octx, ihash, 32);
-
-	/* Finish the outer SHA256 operation. */
-	SHA256_Final(digest, &ctx->octx);
-
-	/* Clean the stack. */
-	memset(ihash, 0, 32);
-}
-
-/**
- * PBKDF2_SHA256(passwd, passwdlen, salt, saltlen, c, buf, dkLen):
- * Compute PBKDF2(passwd, salt, c, dkLen) using HMAC-SHA256 as the PRF, and
- * write the output to buf.  The value dkLen must be at most 32 * (2^32 - 1).
- */
-void
-PBKDF2_SHA256(const uint8_t * passwd, size_t passwdlen, const uint8_t * salt,
-    size_t saltlen, uint64_t c, uint8_t * buf, size_t dkLen)
-{
-	HMAC_SHA256_CTX PShctx, hctx;
-	size_t i;
-	uint8_t ivec[4];
-	uint8_t U[32];
-	uint8_t T[32];
-	uint64_t j;
-	int k;
-	size_t clen;
-
-	/* Compute HMAC state after processing P and S. */
-	HMAC_SHA256_Init(&PShctx, passwd, passwdlen);
-	HMAC_SHA256_Update(&PShctx, salt, saltlen);
-
-	/* Iterate through the blocks. */
-	for (i = 0; i * 32 < dkLen; i++) {
-		/* Generate INT(i + 1). */
-		be32enc(ivec, (uint32_t)(i + 1));
-
-		/* Compute U_1 = PRF(P, S || INT(i)). */
-		memcpy(&hctx, &PShctx, sizeof(HMAC_SHA256_CTX));
-		HMAC_SHA256_Update(&hctx, ivec, 4);
-		HMAC_SHA256_Final(U, &hctx);
-
-		/* T_i = U_1 ... */
-		memcpy(T, U, 32);
-
-		for (j = 2; j <= c; j++) {
-			/* Compute U_j. */
-			HMAC_SHA256_Init(&hctx, passwd, passwdlen);
-			HMAC_SHA256_Update(&hctx, U, 32);
-			HMAC_SHA256_Final(U, &hctx);
-
-			/* ... xor U_j ... */
-			for (k = 0; k < 32; k++)
-				T[k] ^= U[k];
-		}
-
-		/* Copy as many bytes as necessary into buf. */
-		clen = dkLen - i * 32;
-		if (clen > 32)
-			clen = 32;
-		memcpy(&buf[i * 32], T, clen);
-	}
-
-	/* Clean PShctx, since we never called _Final on it. */
-	memset(&PShctx, 0, sizeof(HMAC_SHA256_CTX));
-}

+ 0 - 10
app/jni/scrypt/include/config.h

@@ -1,10 +0,0 @@
-#define HAVE_DECL_BE64ENC 0
-#define HAVE_MMAP 1
-
-#ifndef __ANDROID__
-#define HAVE_POSIX_MEMALIGN 1
-#endif
-
-#ifdef __ANDROID__
-#include <sys/limits.h>
-#endif

+ 0 - 46
app/jni/scrypt/include/crypto_scrypt.h

@@ -1,46 +0,0 @@
-/*-
- * Copyright 2009 Colin Percival
- * All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions
- * are met:
- * 1. Redistributions of source code must retain the above copyright
- *    notice, this list of conditions and the following disclaimer.
- * 2. Redistributions in binary form must reproduce the above copyright
- *    notice, this list of conditions and the following disclaimer in the
- *    documentation and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
- * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- * ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
- * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
- * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
- * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
- * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
- * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
- * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
- * SUCH DAMAGE.
- *
- * This file was originally written by Colin Percival as part of the Tarsnap
- * online backup system.
- */
-#ifndef _CRYPTO_SCRYPT_H_
-#define _CRYPTO_SCRYPT_H_
-
-#include <stdint.h>
-
-/**
- * crypto_scrypt(passwd, passwdlen, salt, saltlen, N, r, p, buf, buflen):
- * Compute scrypt(passwd[0 .. passwdlen - 1], salt[0 .. saltlen - 1], N, r,
- * p, buflen) and write the result into buf.  The parameters r, p, and buflen
- * must satisfy r * p < 2^30 and buflen <= (2^32 - 1) * 32.  The parameter N
- * must be a power of 2 greater than 1.
- *
- * Return 0 on success; or -1 on error.
- */
-int crypto_scrypt(const uint8_t *, size_t, const uint8_t *, size_t, uint64_t,
-    uint32_t, uint32_t, uint8_t *, size_t);
-
-#endif /* !_CRYPTO_SCRYPT_H_ */

+ 0 - 12
app/jni/scrypt/include/scrypt_platform.h

@@ -1,12 +0,0 @@
-#ifndef _SCRYPT_PLATFORM_H_
-#define	_SCRYPT_PLATFORM_H_
-
-#if defined(CONFIG_H_FILE)
-#include CONFIG_H_FILE
-#elif defined(HAVE_CONFIG_H)
-#include "config.h"
-#else
-#error Need either CONFIG_H_FILE or HAVE_CONFIG_H defined.
-#endif
-
-#endif /* !_SCRYPT_PLATFORM_H_ */

+ 0 - 62
app/jni/scrypt/include/sha256.h

@@ -1,62 +0,0 @@
-/*-
- * Copyright 2005,2007,2009 Colin Percival
- * All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions
- * are met:
- * 1. Redistributions of source code must retain the above copyright
- *    notice, this list of conditions and the following disclaimer.
- * 2. Redistributions in binary form must reproduce the above copyright
- *    notice, this list of conditions and the following disclaimer in the
- *    documentation and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
- * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- * ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
- * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
- * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
- * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
- * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
- * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
- * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
- * SUCH DAMAGE.
- *
- * $FreeBSD: src/lib/libmd/sha256.h,v 1.2 2006/01/17 15:35:56 phk Exp $
- */
-
-#ifndef _SHA256_H_
-#define _SHA256_H_
-
-#include <sys/types.h>
-
-#include <stdint.h>
-
-typedef struct SHA256Context {
-	uint32_t state[8];
-	uint32_t count[2];
-	unsigned char buf[64];
-} SHA256_CTX;
-
-typedef struct HMAC_SHA256Context {
-	SHA256_CTX ictx;
-	SHA256_CTX octx;
-} HMAC_SHA256_CTX;
-
-void	SHA256_Init(SHA256_CTX *);
-void	SHA256_Update(SHA256_CTX *, const void *, size_t);
-void	SHA256_Final(unsigned char [32], SHA256_CTX *);
-void	HMAC_SHA256_Init(HMAC_SHA256_CTX *, const void *, size_t);
-void	HMAC_SHA256_Update(HMAC_SHA256_CTX *, const void *, size_t);
-void	HMAC_SHA256_Final(unsigned char [32], HMAC_SHA256_CTX *);
-
-/**
- * PBKDF2_SHA256(passwd, passwdlen, salt, saltlen, c, buf, dkLen):
- * Compute PBKDF2(passwd, salt, c, dkLen) using HMAC-SHA256 as the PRF, and
- * write the output to buf.  The value dkLen must be at most 32 * (2^32 - 1).
- */
-void	PBKDF2_SHA256(const uint8_t *, size_t, const uint8_t *, size_t,
-    uint64_t, uint8_t *, size_t);
-
-#endif /* !_SHA256_H_ */

+ 0 - 140
app/jni/scrypt/include/sysendian.h

@@ -1,140 +0,0 @@
-/*-
- * Copyright 2007-2009 Colin Percival
- * All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions
- * are met:
- * 1. Redistributions of source code must retain the above copyright
- *    notice, this list of conditions and the following disclaimer.
- * 2. Redistributions in binary form must reproduce the above copyright
- *    notice, this list of conditions and the following disclaimer in the
- *    documentation and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
- * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- * ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
- * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
- * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
- * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
- * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
- * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
- * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
- * SUCH DAMAGE.
- *
- * This file was originally written by Colin Percival as part of the Tarsnap
- * online backup system.
- */
-#ifndef _SYSENDIAN_H_
-#define _SYSENDIAN_H_
-
-#include "scrypt_platform.h"
-
-/* If we don't have be64enc, the <sys/endian.h> we have isn't usable. */
-#if !HAVE_DECL_BE64ENC
-#undef HAVE_SYS_ENDIAN_H
-#endif
-
-#ifdef HAVE_SYS_ENDIAN_H
-
-#include <sys/endian.h>
-
-#else
-
-#include <stdint.h>
-
-static inline uint32_t
-be32dec(const void *pp)
-{
-	const uint8_t *p = (uint8_t const *)pp;
-
-	return ((uint32_t)(p[3]) + ((uint32_t)(p[2]) << 8) +
-	    ((uint32_t)(p[1]) << 16) + ((uint32_t)(p[0]) << 24));
-}
-
-static inline void
-be32enc(void *pp, uint32_t x)
-{
-	uint8_t * p = (uint8_t *)pp;
-
-	p[3] = x & 0xff;
-	p[2] = (x >> 8) & 0xff;
-	p[1] = (x >> 16) & 0xff;
-	p[0] = (x >> 24) & 0xff;
-}
-
-static inline uint64_t
-be64dec(const void *pp)
-{
-	const uint8_t *p = (uint8_t const *)pp;
-
-	return ((uint64_t)(p[7]) + ((uint64_t)(p[6]) << 8) +
-	    ((uint64_t)(p[5]) << 16) + ((uint64_t)(p[4]) << 24) +
-	    ((uint64_t)(p[3]) << 32) + ((uint64_t)(p[2]) << 40) +
-	    ((uint64_t)(p[1]) << 48) + ((uint64_t)(p[0]) << 56));
-}
-
-static inline void
-be64enc(void *pp, uint64_t x)
-{
-	uint8_t * p = (uint8_t *)pp;
-
-	p[7] = x & 0xff;
-	p[6] = (x >> 8) & 0xff;
-	p[5] = (x >> 16) & 0xff;
-	p[4] = (x >> 24) & 0xff;
-	p[3] = (x >> 32) & 0xff;
-	p[2] = (x >> 40) & 0xff;
-	p[1] = (x >> 48) & 0xff;
-	p[0] = (x >> 56) & 0xff;
-}
-
-static inline uint32_t
-le32dec(const void *pp)
-{
-	const uint8_t *p = (uint8_t const *)pp;
-
-	return ((uint32_t)(p[0]) + ((uint32_t)(p[1]) << 8) +
-	    ((uint32_t)(p[2]) << 16) + ((uint32_t)(p[3]) << 24));
-}
-
-static inline void
-le32enc(void *pp, uint32_t x)
-{
-	uint8_t * p = (uint8_t *)pp;
-
-	p[0] = x & 0xff;
-	p[1] = (x >> 8) & 0xff;
-	p[2] = (x >> 16) & 0xff;
-	p[3] = (x >> 24) & 0xff;
-}
-
-static inline uint64_t
-le64dec(const void *pp)
-{
-	const uint8_t *p = (uint8_t const *)pp;
-
-	return ((uint64_t)(p[0]) + ((uint64_t)(p[1]) << 8) +
-	    ((uint64_t)(p[2]) << 16) + ((uint64_t)(p[3]) << 24) +
-	    ((uint64_t)(p[4]) << 32) + ((uint64_t)(p[5]) << 40) +
-	    ((uint64_t)(p[6]) << 48) + ((uint64_t)(p[7]) << 56));
-}
-
-static inline void
-le64enc(void *pp, uint64_t x)
-{
-	uint8_t * p = (uint8_t *)pp;
-
-	p[0] = x & 0xff;
-	p[1] = (x >> 8) & 0xff;
-	p[2] = (x >> 16) & 0xff;
-	p[3] = (x >> 24) & 0xff;
-	p[4] = (x >> 32) & 0xff;
-	p[5] = (x >> 40) & 0xff;
-	p[6] = (x >> 48) & 0xff;
-	p[7] = (x >> 56) & 0xff;
-}
-#endif /* !HAVE_SYS_ENDIAN_H */
-
-#endif /* !_SYSENDIAN_H_ */

+ 6 - 13
app/src/foss_based/assets/license.html

@@ -84,13 +84,6 @@
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
-<h2>android-gif-drawable</h2>
-
-<p>Copyright (c) 2016-2017 Karol Wrótniak, Droids on Roids</p>
-
-<p>Licensed under the MIT License (copy below)</p>
-
-
 <h2>Base32</h2>
 
 <p>Copyright © 2010, Data Base Architects, Inc. All rights reserved.</p>
@@ -195,11 +188,11 @@ SUCH DAMAGE.</p>
 <p>Licensed under the Apache License, version 2.0 (copy below).</p>
 
 
-<h2>Java implementation of SCrypt</h2>
+<h2>Bouncy Castle – Open-source cryptographic APIs</h2>
 
-<p>Copyright (c) 2013 Will Glozer</p>
+<p>Copyright (c) 2000-2023 The Legion Of The Bouncy Castle Inc. (https://www.bouncycastle.org)</p>
 
-<p>Licensed under the Apache License, version 2.0 (copy below).</p>
+<p>Licensed under the MIT License (copy below).</p>
 
 
 <h2>jnacl</h2>
@@ -255,7 +248,7 @@ POSSIBILITY OF SUCH DAMAGE.</p>
 
 <p>Copyright (c) 2016 UPTech</p>
 
-<p>Licensed under the MIT License (copy below)</p>
+<p>Licensed under the MIT License (copy below).</p>
 
 
 <h2>nv-websocket-client</h2>
@@ -317,7 +310,7 @@ SUCH DAMAGE.</p>
 
 <p>Copyright (c) 2004-2017 QOS.ch All rights reserved.</p>
 
-<p>Licensed under the MIT License (copy below)</p>
+<p>Licensed under the MIT License (copy below).</p>
 
 
 <h2>SQLCipher</h2>
@@ -383,7 +376,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
 
 <p>Copyright (c) 2016 Data Theorem, Inc.</p>
 
-<p>Licensed under the MIT License (copy below)</p>
+<p>Licensed under the MIT License (copy below).</p>
 
 
 <h2>WebRTC</h2>

+ 1 - 1
app/src/google_services_based/java/ch/threema/app/services/VoiceActionService.java

@@ -63,7 +63,7 @@ public class VoiceActionService extends SearchActionVerificationClientService {
 
 	@Override
 	public void performAction(Intent intent, boolean isVerified, Bundle options) {
-		logger.debug(String.format("performAction: intent - %s, isVerified - %s", intent, isVerified));
+		logger.debug("performAction: intent - {}, isVerified - {}", intent, isVerified);
 
 		this.instantiate();
 

+ 2 - 0
app/src/libre/play/listings/de/full-description.txt

@@ -1,3 +1,5 @@
+HINWEIS: Für Threema Libre wird ein Lizenzcode benötigt. Dieser kann unter https://shop.threema.ch gekauft werden.
+
 Threema ist der weltweit meistverkaufte sichere Messenger und schützt Ihre Daten vor dem Zugriff durch Hacker, Unternehmen und Regierungen. Der Dienst kann völlig anonym (ohne Angabe einer Telefonnummer oder E-Mail-Adresse) verwendet werden. Threema ist Open Source und bietet alle Funktionen, die man von einer modernen Instant Messaging-App erwartet. Es lassen sich Ende-zu-Ende-verschlüsselte Sprach-, Video- und Gruppenanrufe durchführen, und die Desktop-App sowie der Web-Client erlauben, Threema bequem vom PC aus zu nutzen.
 
 Im Gegensatz zur Version auf Google Play enthält Threema Libre keine proprietären Bibliotheken / Komponenten (wie Google Play Services).

+ 2 - 0
app/src/libre/play/listings/en-US/full-description.txt

@@ -1,3 +1,5 @@
+NOTE: A Threema Shop license is required, please visit: https://shop.threema.ch to get one.
+
 Threema is the world’s best-selling secure messenger and keeps your data out of the hands of hackers, corporations, and governments. The service can be used completely anonymously. Threema is open source and offers every feature one would expect from a state-of-the-art instant messenger. The app also allows you to make end-to-end encrypted voice, video, and group calls. Using the desktop app and the web client, you can also use Threema from your desktop.
 
 In contrast to the version distributed via Google Play, Threema Libre contains no proprietary libraries / components (like Google Play Services).

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

@@ -177,12 +177,12 @@
 		android:allowAudioPlaybackCapture="false"
 		android:appCategory="social"
 		android:hasFragileUserData="true"
-		tools:replace="android:supportsRtl,android:allowBackup"
 		android:dataExtractionRules="@xml/data_extraction_rules"
 		android:enableOnBackInvokedCallback="true"
-        android:localeConfig="@xml/locales_config">
+        android:localeConfig="@xml/locales_config"
+        tools:replace="android:supportsRtl,android:allowBackup,android:dataExtractionRules">
 		<!-- 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. -->
+		override rtl or backup support. -->
 		<meta-data
 			android:name="android.max_aspect"
 			android:value="2.5"/>

+ 21 - 36
app/src/main/java/ch/threema/app/ThreemaApplication.java

@@ -649,52 +649,37 @@ public class ThreemaApplication extends Application implements DefaultLifecycleO
 
 	@Override
 	public void onLowMemory() {
-		super.onLowMemory();
-
 		logger.info("*** App is low on memory");
+
+		super.onLowMemory();
+		try {
+			if (serviceManager != null) {
+				serviceManager.getAvatarCacheService().clear();
+			}
+		} catch (Exception e) {
+			logger.error("Exception", e);
+		}
 	}
 
 	@SuppressLint("SwitchIntDef")
 	@Override
 	public void onTrimMemory(int level) {
+		logger.info("onTrimMemory (level={})", level);
 
 		super.onTrimMemory(level);
 
-		switch (level) {
-			case TRIM_MEMORY_RUNNING_MODERATE:
-				logger.trace("onTrimMemory (level={})", level);
-				break;
-			case TRIM_MEMORY_UI_HIDDEN:
-				logger.debug("onTrimMemory (level={}, ui hidden)", level);
-				/* fallthrough */
-			default:
-				if (level != TRIM_MEMORY_UI_HIDDEN) { // See above
-					logger.info("onTrimMemory (level={})", level);
-				}
-
-				/* save our master key now if necessary, as we may get killed and if the user was still in the
-			     * initial setup procedure, this can lead to trouble as the database may already be there
-			     * but we may no longer be able to access it due to missing master key
-				 */
-				try {
-					if (getMasterKey() != null && !getMasterKey().isProtected()) {
-						if (serviceManager != null && serviceManager.getPreferenceService().getWizardRunning()) {
-							getMasterKey().setPassphrase(null);
-						}
-					}
-				} catch (Exception e) {
-					logger.error("Exception", e);
-				}
-
-				try {
-					if (serviceManager != null) {
-						serviceManager.getAvatarCacheService().clear();
-					}
-				} catch (Exception e) {
-					logger.error("Exception", e);
+		/* save our master key now if necessary, as we may get killed and if the user was still in the
+	     * initial setup procedure, this can lead to trouble as the database may already be there
+	     * but we may no longer be able to access it due to missing master key
+		 */
+		try {
+			if (getMasterKey() != null && !getMasterKey().isProtected()) {
+				if (serviceManager != null && serviceManager.getPreferenceService().getWizardRunning()) {
+					getMasterKey().setPassphrase(null);
 				}
-
-				break;
+			}
+		} catch (Exception e) {
+			logger.error("Exception", e);
 		}
 	}
 

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

@@ -82,6 +82,7 @@ public class EnterSerialActivity extends ThreemaActivity {
 	private LicenseService licenseService;
 	private PreferenceService preferenceService;
 
+	@Override
 	public void onCreate(Bundle savedInstanceState) {
 		super.onCreate(savedInstanceState);
 
@@ -98,6 +99,7 @@ public class EnterSerialActivity extends ThreemaActivity {
 			// Hide keyboard to make error message visible on low resolution displays
 			EditTextUtil.hideSoftKeyboard(this.licenseKeyOrUsernameText);
 			Toast.makeText(this, "Service Manager not available", Toast.LENGTH_LONG).show();
+			finish();
 			return;
 		}
 

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

@@ -77,11 +77,14 @@ import java.io.File;
 import java.lang.ref.WeakReference;
 import java.util.Arrays;
 import java.util.Date;
+import java.util.HashSet;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Locale;
 import java.util.Objects;
+import java.util.Set;
 import java.util.concurrent.RejectedExecutionException;
+import java.util.stream.Collectors;
 
 import ch.threema.app.BuildFlavor;
 import ch.threema.app.R;
@@ -167,6 +170,7 @@ import ch.threema.storage.models.AbstractMessageModel;
 import ch.threema.storage.models.ContactModel;
 import ch.threema.storage.models.ConversationModel;
 import ch.threema.storage.models.MessageState;
+import ch.threema.storage.models.TagModel;
 
 public class HomeActivity extends ThreemaAppCompatActivity implements
 	SMSVerificationDialog.SMSVerificationDialogCallback,
@@ -322,7 +326,33 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 			}
 
 			if (conversationTagService != null) {
-				unread += conversationTagService.getCount(conversationTagService.getTagModel(FIXED_TAG_UNREAD));
+				TagModel unreadTagModel = conversationTagService.getTagModel(FIXED_TAG_UNREAD);
+				if (unreadTagModel == null) {
+					logger.error("Unread tag model is null");
+					return unread;
+				}
+
+				// First check whether there are some conversations that are marked as unread. This
+				// check is expected to be fast, as usually there are not many chats that are marked
+				// as unread.
+				if (conversationTagService.getCount(unreadTagModel) > 0) {
+					// In case there is at least one unread tag, we create a set of all possible
+					// conversation uids to efficiently check that the unread tags are valid.
+					Set<String> shownConversationUids = conversationService.getAll(false)
+						.stream()
+						.map(ConversationModel::getUid)
+						.collect(Collectors.toSet());
+
+					List<String> unreadUids = conversationTagService.getConversationUidsByTag(unreadTagModel);
+					for (String unreadUid : unreadUids) {
+						if (shownConversationUids.contains(unreadUid)) {
+							unread++;
+						} else {
+							logger.warn("Conversation '{}' is marked as unread but not shown. Deleting the unread flag.", unreadUid);
+							conversationTagService.removeTag(unreadUid, unreadTagModel);
+						}
+					}
+				}
 			}
 
 			return unread;
@@ -1912,7 +1942,7 @@ public class HomeActivity extends ThreemaAppCompatActivity implements
 						new Thread(() -> {
 							try {
 								MessageReceiver receiver = contactService.createReceiver(newContactModel);
-								if (!getResources().getConfiguration().locale.getLanguage().startsWith("de")) {
+								if (!getResources().getConfiguration().locale.getLanguage().startsWith("de") && !getResources().getConfiguration().locale.getLanguage().startsWith("gsw")) {
 									Thread.sleep(1000);
 									messageService.sendText("en", receiver);
 									Thread.sleep(500);

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

@@ -71,7 +71,6 @@ import ch.threema.app.dialogs.ExpandableTextEntryDialog;
 import ch.threema.app.emojis.EmojiMarkupUtil;
 import ch.threema.app.fragments.mediaviews.AudioViewFragment;
 import ch.threema.app.fragments.mediaviews.FileViewFragment;
-import ch.threema.app.fragments.mediaviews.GifViewFragment;
 import ch.threema.app.fragments.mediaviews.ImageViewFragment;
 import ch.threema.app.fragments.mediaviews.MediaPlayerViewFragment;
 import ch.threema.app.fragments.mediaviews.MediaViewFragment;
@@ -719,9 +718,7 @@ public class MediaViewerActivity extends ThreemaToolbarActivity implements
 						break;
 					case FILE:
 						String mimeType = messageModel.getFileData().getMimeType();
-						if (MimeUtil.isGifFile(mimeType)) {
-							f = new GifViewFragment();
-						} else if (MimeUtil.isSupportedImageFile(mimeType)) {
+						if (MimeUtil.isSupportedImageFile(mimeType)) {
 							f = new ImageViewFragment();
 						} else if (MimeUtil.isVideoFile(mimeType)) {
 							f = new VideoViewFragment();

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

@@ -23,9 +23,7 @@ package ch.threema.app.adapters;
 
 import static ch.threema.domain.protocol.csp.messages.file.FileData.RENDERING_DEFAULT;
 
-import android.animation.LayoutTransition;
 import android.content.Context;
-import android.os.Build;
 import android.text.TextUtils;
 import android.util.SparseIntArray;
 import android.view.LayoutInflater;
@@ -57,7 +55,6 @@ import java.util.List;
 import java.util.Map;
 
 import ch.threema.app.R;
-import ch.threema.app.adapters.decorators.AnimGifChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.AudioChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.BallotChatAdapterDecorator;
 import ch.threema.app.adapters.decorators.ChatAdapterDecorator;
@@ -144,22 +141,16 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 		TYPE_FILE_RECV,
 		TYPE_BALLOT_SEND,
 		TYPE_BALLOT_RECV,
-		TYPE_ANIMGIF_SEND,
-		TYPE_ANIMGIF_RECV,
 		TYPE_TEXT_QUOTE_SEND,
 		TYPE_TEXT_QUOTE_RECV,
 		TYPE_STATUS_DATA_SEND,
 		TYPE_STATUS_DATA_RECV,
 		TYPE_DATE_SEPARATOR,
-		TYPE_FILE_MEDIA_SEND,
-		TYPE_FILE_MEDIA_RECV,
 		TYPE_FILE_VIDEO_SEND,
 		TYPE_GROUP_CALL_STATUS,
 		TYPE_FORWARD_SECURITY_STATUS,
-		TYPE_IMAGE_ANIMATED_SEND,
-		TYPE_IMAGE_ANIMATED_RECV
 	})
-	public @interface ItemType {}
+	public @interface ItemLayoutType {}
 
 	public static final int TYPE_SEND = 0;
 	public static final int TYPE_RECV = 1;
@@ -175,23 +166,17 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 	public static final int TYPE_FILE_RECV = 11;
 	public static final int TYPE_BALLOT_SEND = 12;
 	public static final int TYPE_BALLOT_RECV = 13;
-	public static final int TYPE_ANIMGIF_SEND = 14;
-	public static final int TYPE_ANIMGIF_RECV = 15;
-	public static final int TYPE_TEXT_QUOTE_SEND = 16;
-	public static final int TYPE_TEXT_QUOTE_RECV = 17;
-	public static final int TYPE_STATUS_DATA_SEND = 18;
-	public static final int TYPE_STATUS_DATA_RECV = 19;
-	public static final int TYPE_DATE_SEPARATOR = 20;
-	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;
-	public static final int TYPE_IMAGE_ANIMATED_SEND = 26;
-	public static final int TYPE_IMAGE_ANIMATED_RECV = 27;
+	public static final int TYPE_TEXT_QUOTE_SEND = 14;
+	public static final int TYPE_TEXT_QUOTE_RECV = 15;
+	public static final int TYPE_STATUS_DATA_SEND = 16;
+	public static final int TYPE_STATUS_DATA_RECV = 17;
+	public static final int TYPE_DATE_SEPARATOR = 18;
+	public static final int TYPE_FILE_VIDEO_SEND = 19;
+	public static final int TYPE_GROUP_CALL_STATUS = 20;
+	public static final int TYPE_FORWARD_SECURITY_STATUS = 21;
 
 	// don't forget to update this after adding new types:
-	private static final int TYPE_MAX_COUNT = TYPE_IMAGE_ANIMATED_RECV + 1;
+	private static final int TYPE_MAX_COUNT = TYPE_FORWARD_SECURITY_STATUS + 1;
 
 	private OnClickListener onClickListener;
 	private Map<String, Integer> identityColors = null;
@@ -339,7 +324,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 	}
 
 	@Override
-	public @ItemType int getItemViewType(int position) {
+	public @ItemLayoutType int getItemViewType(int position) {
 		if (position < values.size()) {
 			final AbstractMessageModel m = this.getItem(position);
 			return this.getItemType(m);
@@ -358,7 +343,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 		return null;
 	}
 
-	private @ItemType int getItemType(AbstractMessageModel m) {
+	private @ItemLayoutType int getItemType(AbstractMessageModel m) {
 		if(m != null) {
 			if(m.isStatusMessage()) {
 				// Special handling for data status messages
@@ -388,19 +373,13 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 					case FILE:
 						String mimeType = m.getFileData().getMimeType();
 						int renderingType = m.getFileData().getRenderingType();
-						if (MimeUtil.isGifFile(mimeType)) {
-							return o ? TYPE_ANIMGIF_SEND : TYPE_ANIMGIF_RECV;
-						} else if (MimeUtil.isAudioFile(mimeType) && renderingType == FileData.RENDERING_MEDIA) {
+						if (MimeUtil.isAudioFile(mimeType) && renderingType == FileData.RENDERING_MEDIA) {
 							return o ? TYPE_AUDIO_SEND : TYPE_AUDIO_RECV;
 						} else if (renderingType == FileData.RENDERING_MEDIA || renderingType == FileData.RENDERING_STICKER) {
 							if (MimeUtil.isSupportedImageFile(mimeType)) {
-								if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && ConfigUtils.isSupportedAnimatedImageFormat(mimeType)) {
-									return o ? TYPE_IMAGE_ANIMATED_SEND : TYPE_IMAGE_ANIMATED_RECV;
-								} else {
-									return o ? TYPE_FILE_MEDIA_SEND : TYPE_FILE_MEDIA_RECV;
-								}
+								return o ? TYPE_MEDIA_SEND : TYPE_MEDIA_RECV;
 							} else if (MimeUtil.isVideoFile(mimeType)) {
-								return o ? TYPE_FILE_VIDEO_SEND : TYPE_FILE_MEDIA_RECV;
+								return o ? TYPE_FILE_VIDEO_SEND : TYPE_MEDIA_RECV;
 							}
 						}
 						return o ? TYPE_FILE_SEND : TYPE_FILE_RECV;
@@ -423,7 +402,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 		return TYPE_RECV;
 	}
 
-	private @LayoutRes int getLayoutByItemType(@ItemType int itemTypeId) {
+	private @LayoutRes int getLayoutByItemType(@ItemLayoutType int itemTypeId) {
 		switch (itemTypeId) {
 			case TYPE_SEND:
 				return R.layout.conversation_list_item_send;
@@ -435,12 +414,8 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 			case TYPE_FIRST_UNREAD:
 				return R.layout.conversation_list_item_unread;
 			case TYPE_MEDIA_SEND:
-			case TYPE_FILE_MEDIA_SEND:
-			case TYPE_IMAGE_ANIMATED_SEND:
 				return R.layout.conversation_list_item_media_send;
 			case TYPE_MEDIA_RECV:
-			case TYPE_FILE_MEDIA_RECV:
-			case TYPE_IMAGE_ANIMATED_RECV:
 				return R.layout.conversation_list_item_media_recv;
 			case TYPE_FILE_VIDEO_SEND:
 				return R.layout.conversation_list_item_video_send;
@@ -460,10 +435,6 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 				return R.layout.conversation_list_item_ballot_send;
 			case TYPE_BALLOT_RECV:
 				return R.layout.conversation_list_item_ballot_recv;
-			case TYPE_ANIMGIF_SEND:
-				return R.layout.conversation_list_item_animgif_send;
-			case TYPE_ANIMGIF_RECV:
-				return R.layout.conversation_list_item_animgif_recv;
 			case TYPE_TEXT_QUOTE_SEND:
 				return R.layout.conversation_list_item_quote_send;
 			case TYPE_TEXT_QUOTE_RECV:
@@ -495,7 +466,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 		final AbstractMessageModel messageModel = values.get(position);
 		MessageType messageType = messageModel.getType();
 
-		@ItemType int itemType = this.getItemType(messageModel);
+		@ItemLayoutType int itemType = this.getItemType(messageModel);
 		int itemLayout = this.getLayoutByItemType(itemType);
 
 		if (messageModel.isStatusMessage() && messageModel instanceof FirstUnreadMessageModel) {
@@ -599,16 +570,14 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 					decorator = new BallotChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
 					break;
 				case FILE:
-					if (MimeUtil.isGifFile(messageModel.getFileData().getMimeType())) {
-						decorator = new AnimGifChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-					} else if (MimeUtil.isVideoFile(messageModel.getFileData().getMimeType()) &&
+					if (MimeUtil.isVideoFile(messageModel.getFileData().getMimeType()) &&
 						(messageModel.getFileData().getRenderingType() == FileData.RENDERING_MEDIA ||
 							messageModel.getFileData().getRenderingType() == FileData.RENDERING_STICKER)) {
 						decorator = new VideoChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
 					} else if (MimeUtil.isAudioFile(messageModel.getFileData().getMimeType()) &&
 						messageModel.getFileData().getRenderingType() == FileData.RENDERING_MEDIA) {
 						decorator = new AudioChatAdapterDecorator(this.context, messageModel, this.decoratorHelper);
-					} else if (ConfigUtils.isSupportedAnimatedImageFormat(messageModel.getFileData().getMimeType()) &&
+					} else if (MimeUtil.isAnimatedImageFormat(messageModel.getFileData().getMimeType()) &&
 						(messageModel.getFileData().getRenderingType() == FileData.RENDERING_MEDIA ||
 							messageModel.getFileData().getRenderingType() == FileData.RENDERING_STICKER)) {
 						decorator = new AnimatedImageDrawableDecorator(this.context, messageModel, this.decoratorHelper);
@@ -688,7 +657,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 	 * @param itemType The Type the item is representing
 	 * @return true if it's the first item in a group, false if it's a consecutive iitem
 	 */
-	private boolean adjustMarginsForMessageGrouping(ComposeMessageHolder holder, View itemView, @ItemType int itemType, @NonNull AbstractMessageModel currentItem) {
+	private boolean adjustMarginsForMessageGrouping(ComposeMessageHolder holder, View itemView, @ItemLayoutType int itemType, @NonNull AbstractMessageModel currentItem) {
 		boolean isFirstItemInGroup = true, hasPreviousItem = false, hasNextItem = false;
 
 		if (itemView != null) {
@@ -777,7 +746,7 @@ public class ComposeMessageAdapter extends ArrayAdapter<AbstractMessageModel> {
 	 * @param itemType Type to check
 	 * @return true if it's a user-generated message, false otherwise
 	 */
-	private boolean isUserMessage(@ItemType int itemType) {
+	private boolean isUserMessage(@ItemLayoutType int itemType) {
 		return (itemType != TYPE_STATUS &&
 			itemType != TYPE_FIRST_UNREAD &&
 			itemType != TYPE_DATE_SEPARATOR &&

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

@@ -43,6 +43,7 @@ import ch.threema.app.services.FileService
 import ch.threema.app.ui.CheckableFrameLayout
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.IconUtil
+import ch.threema.app.utils.MimeUtil
 import ch.threema.app.utils.StringConversionUtil
 import ch.threema.storage.models.AbstractMessageModel
 import ch.threema.storage.models.MessageType
@@ -166,7 +167,7 @@ class MediaGalleryAdapter(
                                 holder.animatedFormatLabelContainer?.visibility = View.VISIBLE
                                 holder.animatedFormatLabelIconView?.setImageResource(R.drawable.ic_gif_24dp)
                                 holder.animatedFormatLabelIconView?.contentDescription = context.getString(R.string.attach_gif)
-                            } else if (messageModel.messageContentsType == MessageContentsType.IMAGE && ConfigUtils.isSupportedAnimatedImageFormat(messageModel.fileData.mimeType)) {
+                            } else if (messageModel.messageContentsType == MessageContentsType.IMAGE && MimeUtil.isAnimatedImageFormat(messageModel.fileData.mimeType)) {
                                 holder.animatedFormatLabelContainer?.visibility = View.VISIBLE
                                 holder.animatedFormatLabelIconView?.setImageResource(R.drawable.ic_webp)
                                 holder.animatedFormatLabelIconView?.contentDescription = "WebP"

+ 10 - 7
app/src/main/java/ch/threema/app/adapters/SendMediaPreviewAdapter.kt

@@ -266,14 +266,17 @@ class SendMediaPreviewAdapter(
             } else {
                 durationView.visibility = View.GONE
             }
-        } else if (item.type == TYPE_GIF) {
-            holder.qualifierView.visibility = View.VISIBLE
-            imageView.setImageResource(R.drawable.ic_gif_24dp)
-            holder.qualifierView.findViewById<View>(R.id.video_duration_text).visibility = View.GONE
-        } else if (item.type == TYPE_IMAGE_ANIMATED && MimeUtil.isWebPFile(item.mimeType)) {
-            holder.qualifierView.visibility = View.VISIBLE
-            imageView.setImageResource(R.drawable.ic_webp)
+        } else if (item.type == TYPE_IMAGE_ANIMATED) {
             holder.qualifierView.findViewById<View>(R.id.video_duration_text).visibility = View.GONE
+            if (MimeUtil.isWebPFile(item.mimeType)) {
+                holder.qualifierView.visibility = View.VISIBLE
+                imageView.setImageResource(R.drawable.ic_webp)
+            } else if (MimeUtil.isGifFile(item.mimeType)) {
+                holder.qualifierView.visibility = View.VISIBLE
+                imageView.setImageResource(R.drawable.ic_gif_24dp)
+            } else {
+                holder.qualifierView.visibility = View.GONE
+            }
         } else {
             holder.qualifierView.visibility = View.GONE
         }

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

@@ -1,266 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2014-2024 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.adapters.decorators;
-
-import android.app.Activity;
-import android.content.Context;
-import android.graphics.Bitmap;
-import android.view.View;
-import android.widget.Toast;
-
-import org.slf4j.Logger;
-
-import java.io.File;
-
-import ch.threema.app.services.MessageServiceImpl;
-import ch.threema.app.services.messageplayer.GifMessagePlayer;
-import ch.threema.app.services.messageplayer.MessagePlayer;
-import ch.threema.app.ui.ControllerView;
-import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
-import ch.threema.app.utils.FileUtil;
-import ch.threema.app.utils.ImageViewUtil;
-import ch.threema.app.utils.RuntimeUtil;
-import ch.threema.app.utils.TestUtil;
-import ch.threema.base.utils.LoggingUtil;
-import ch.threema.domain.protocol.csp.messages.file.FileData;
-import ch.threema.storage.models.AbstractMessageModel;
-import ch.threema.storage.models.MessageState;
-import ch.threema.storage.models.data.media.FileDataModel;
-
-public class AnimGifChatAdapterDecorator extends ChatAdapterDecorator {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("AnimGifChatAdapterDecorator");
-
-	private static final String LISTENER_TAG = "decorator";
-	private GifMessagePlayer gifMessagePlayer;
-
-	public AnimGifChatAdapterDecorator(Context context, AbstractMessageModel messageModel, Helper decoratorHelper) {
-		super(context, messageModel, decoratorHelper);
-	}
-
-	@Override
-	protected void configureChatMessage(final ComposeMessageHolder holder, final int position) {
-		super.configureChatMessage(holder, position);
-
-		final long fileSize;
-
-		logger.debug("configureChatMessage - position " + position);
-
-		gifMessagePlayer = (GifMessagePlayer) getMessagePlayerService().createPlayer(getMessageModel(), (Activity) getContext(), helper.getMessageReceiver(), null);
-		holder.messagePlayer = gifMessagePlayer;
-
-		/*
-		 * setup click listeners
-		 */
-		if (holder.controller != null) {
-			holder.controller.setOnClickListener(v -> {
-				int status = holder.controller.getStatus();
-
-				switch (status) {
-					case ControllerView.STATUS_READY_TO_RETRY:
-						propagateControllerRetryClickToParent();
-						break;
-					case ControllerView.STATUS_READY_TO_PLAY:
-					case ControllerView.STATUS_READY_TO_DOWNLOAD:
-						gifMessagePlayer.open();
-						break;
-					case ControllerView.STATUS_PROGRESSING:
-						if (getMessageModel().isOutbox() && (getMessageModel().getState() == MessageState.TRANSCODING ||
-							getMessageModel().getState() == MessageState.PENDING ||
-							getMessageModel().getState() == MessageState.SENDING ||
-							getMessageModel().getState() == MessageState.UPLOADING)) {
-							getMessageService().remove(getMessageModel());
-						} else {
-							gifMessagePlayer.cancel();
-						}
-						break;
-					default:
-						// no action taken for other statuses
-						break;
-				}
-			});
-		}
-		setOnClickListener(v -> {
-			if (!isInChoiceMode()) {
-				if ((!getPreferenceService().isAnimationAutoplay() ||
-					holder.controller.getStatus() == ControllerView.STATUS_READY_TO_DOWNLOAD)) {
-					gifMessagePlayer.open();
-				}
-				if (getPreferenceService().isAnimationAutoplay() && holder.controller.getStatus() == ControllerView.STATUS_NONE) {
-					gifMessagePlayer.openInExternalPlayer();
-				}
-			}
-		}, holder.messageBlockView);
-
-		/*
-		 * get thumbnail
-		 */
-		Bitmap thumbnail;
-		try {
-			thumbnail = getFileService().getMessageThumbnailBitmap(getMessageModel(),
-				getThumbnailCache());
-		} catch (Exception e) {
-			logger.error("Exception", e);
-			thumbnail = null;
-		}
-
-		final FileDataModel fileData = getMessageModel().getFileData();
-		fileSize = fileData.getFileSize();
-
-		ImageViewUtil.showRoundedBitmapOrImagePlaceholder(
-			getContext(),
-			holder.contentView,
-			holder.attachmentImage,
-			thumbnail,
-			getThumbnailWidth()
-		);
-		holder.bodyTextView.setWidth(getThumbnailWidth());
-
-		if (holder.attachmentImage != null) {
-			holder.attachmentImage.invalidate();
-		}
-		if (fileData.getRenderingType() == FileData.RENDERING_STICKER) {
-			setStickerBackground(holder);
-		} else {
-			setDefaultBackground(holder);
-		}
-
-		configureBodyText(holder, fileData.getCaption());
-
-		RuntimeUtil.runOnUiThread(() -> setControllerState(holder, fileData, fileSize));
-
-		setDatePrefix(FileUtil.getFileMessageDatePrefix(getContext(), getMessageModel(), "GIF"));
-
-		gifMessagePlayer
-				.attachContainer(holder.attachmentImage)
-				// decryption
-				.addListener(LISTENER_TAG, new MessagePlayer.DecryptionListener() {
-					@Override
-					public void onStart(AbstractMessageModel messageModel) {
-						RuntimeUtil.runOnUiThread(() -> {
-							if (!helper.getPreferenceService().isAnimationAutoplay()) {
-								holder.controller.setProgressing();
-							}
-						});
-					}
-
-					@Override
-					public void onEnd(final AbstractMessageModel messageModel, final boolean success, final String message, final File decryptedFile) {
-						RuntimeUtil.runOnUiThread(() -> {
-							holder.controller.setNeutral();
-							if (success) {
-								if (helper.getPreferenceService().isAnimationAutoplay()) {
-									holder.controller.setVisibility(View.INVISIBLE);
-								} else {
-									setControllerState(holder, messageModel.getFileData(), messageModel.getFileData().getFileSize());
-								}
-							} else {
-								holder.controller.setVisibility(View.GONE);
-								if (!TestUtil.empty(message)) {
-									Toast.makeText(getContext(), message, Toast.LENGTH_LONG).show();
-								}
-							}
-						});
-					}
-				})
-				// download listener
-				.addListener(LISTENER_TAG, new MessagePlayer.DownloadListener() {
-					@Override
-					public void onStart(AbstractMessageModel messageModel) {
-						RuntimeUtil.runOnUiThread(() -> holder.controller.setProgressingDeterminate(100));
-					}
-
-					@Override
-					public void onStatusUpdate(AbstractMessageModel messageModel, final int progress) {
-						RuntimeUtil.runOnUiThread(() -> holder.controller.setProgress(progress));
-					}
-
-					@Override
-					public void onEnd(AbstractMessageModel messageModel, final boolean success, final String message) {
-						//hide progressbar
-						RuntimeUtil.runOnUiThread(() -> {
-							// report error
-							if (success) {
-								holder.controller.setPlay();
-							} else {
-								holder.controller.setReadyToDownload();
-								if (!TestUtil.empty(message)) {
-									Toast.makeText(getContext(), message, Toast.LENGTH_LONG).show();
-								}
-							}
-						});
-					}
-				});
-
-	}
-
-	private void setControllerState(ComposeMessageHolder holder, FileDataModel fileData, long fileSize) {
-		if (getMessageModel().isOutbox()) {
-			// outgoing message
-			switch (getMessageModel().getState()) {
-				case TRANSCODING:
-					holder.controller.setTranscoding();
-					break;
-				case PENDING:
-				case SENDING:
-					holder.controller.setProgressing();
-					break;
-				case SENDFAILED:
-				case FS_KEY_MISMATCH:
-					holder.controller.setRetry();
-					break;
-				default:
-					setAutoplay(fileData, fileSize, holder);
-			}
-		} else {
-			// incoming message
-			if (getMessageModel() != null && getMessageModel().getState() == MessageState.PENDING) {
-				if (fileData.isDownloaded()) {
-					holder.controller.setProgressing();
-				} else {
-					holder.controller.setProgressingDeterminate(100);
-				}
-			} else {
-				setAutoplay(fileData, fileSize, holder);
-			}
-		}
-	}
-
-	private void setAutoplay(FileDataModel fileData, long fileSize, ComposeMessageHolder holder) {
-		logger.debug("setAutoPlay holder position " + holder.position);
-
-		if (fileData.isDownloaded()) {
-			if (helper.getPreferenceService().isAnimationAutoplay() && gifMessagePlayer != null) {
-				gifMessagePlayer.autoPlay();
-				holder.controller.setVisibility(View.INVISIBLE);
-			} else {
-				holder.controller.setPlay();
-			}
-		} else {
-			if (helper.getPreferenceService().isAnimationAutoplay() && gifMessagePlayer != null && fileSize < MessageServiceImpl.FILE_AUTO_DOWNLOAD_MAX_SIZE_ISO) {
-				gifMessagePlayer.autoPlay();
-				holder.controller.setVisibility(View.INVISIBLE);
-			} else {
-				holder.controller.setReadyToDownload();
-			}
-		}
-	}
-}

+ 33 - 12
app/src/main/java/ch/threema/app/adapters/decorators/AnimatedImageDrawableDecorator.java

@@ -27,19 +27,22 @@ import android.content.Context;
 import android.graphics.Bitmap;
 import android.os.Build;
 import android.view.View;
+import android.view.ViewGroup;
 import android.widget.Toast;
 
+import com.bumptech.glide.Glide;
+
 import org.slf4j.Logger;
 
 import java.io.File;
 
+import ch.threema.app.R;
 import ch.threema.app.services.MessageServiceImpl;
 import ch.threema.app.services.messageplayer.MessagePlayer;
 import ch.threema.app.services.messageplayer.AnimatedImageDrawableMessagePlayer;
 import ch.threema.app.ui.ControllerView;
 import ch.threema.app.ui.listitemholder.ComposeMessageHolder;
 import ch.threema.app.utils.FileUtil;
-import ch.threema.app.utils.ImageViewUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.utils.LoggingUtil;
@@ -49,10 +52,8 @@ import ch.threema.storage.models.MessageState;
 import ch.threema.storage.models.data.media.FileDataModel;
 
 /**
- * A decorator for animated image formats natively supported by AnimatedImageDrawable
- * Currently, this is limited to WebP
+ * A decorator for animated image formats natively supported by AnimatedImageDrawable and/or by Glide
  */
-@TargetApi(Build.VERSION_CODES.P)
 public class AnimatedImageDrawableDecorator extends ChatAdapterDecorator {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("AnimatedImageDrawableDecorator");
 
@@ -129,17 +130,37 @@ public class AnimatedImageDrawableDecorator extends ChatAdapterDecorator {
 			thumbnail = null;
 		}
 
+
 		final FileDataModel fileData = getMessageModel().getFileData();
 		fileSize = fileData.getFileSize();
 
-		ImageViewUtil.showBitmapOrImagePlaceholder(
-			getContext(),
-			holder.contentView,
-			holder.attachmentImage,
-			thumbnail,
-			getThumbnailWidth()
-		);
-		holder.bodyTextView.setWidth(getThumbnailWidth());
+		int width = getThumbnailWidth();
+		int height;
+		if (thumbnail != null) {
+			height = (int) ((float) thumbnail.getHeight() * getThumbnailWidth() / thumbnail.getWidth());
+		} else {
+			height = ViewGroup.LayoutParams.WRAP_CONTENT;
+		}
+
+		ViewGroup.LayoutParams params = holder.contentView.getLayoutParams();
+		params.width = width;
+		params.height = height;
+		holder.contentView.setLayoutParams(params);
+
+		params = holder.attachmentImage.getLayoutParams();
+		params.width = width;
+		params.height = height;
+		holder.attachmentImage.setLayoutParams(params);
+		holder.attachmentImage.setVisibility(View.VISIBLE);
+
+		Glide.with(getContext())
+			.load(thumbnail)
+			.optionalFitCenter()
+			.override(width, height)
+			.error(R.drawable.ic_image_outline)
+			.into(holder.attachmentImage);
+
+		holder.bodyTextView.setWidth(width);
 
 		if (holder.attachmentImage != null) {
 			holder.attachmentImage.invalidate();

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

@@ -468,10 +468,6 @@ abstract public class ChatAdapterDecorator extends AdapterDecorator {
 		return helper.getPreferenceService();
 	}
 
-	protected LicenseService getLicenseService() {
-		return helper.getLicenseService();
-	}
-
 	protected UserService getUserService() {
 		return helper.getUserService();
 	}

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

@@ -24,7 +24,9 @@ package ch.threema.app.adapters.decorators;
 import android.annotation.SuppressLint;
 import android.content.Context;
 import android.content.Intent;
+import android.text.SpannableString;
 import android.text.method.LinkMovementMethod;
+import android.text.style.ClickableSpan;
 import android.view.View;
 import android.widget.TextView;
 
@@ -76,6 +78,8 @@ public class TextChatAdapterDecorator extends ChatAdapterDecorator {
 				holder.bodyTextView.setText(formatTextString(messageText, this.filterString, helper.getMaxBubbleTextLength() + 8));
 			}
 
+			boolean isTruncated = false;
+
 			if (holder.readOnContainer != null) {
 				if (messageText != null && messageText.length() > helper.getMaxBubbleTextLength()) {
 					holder.readOnContainer.setVisibility(View.VISIBLE);
@@ -90,6 +94,7 @@ public class TextChatAdapterDecorator extends ChatAdapterDecorator {
 						IntentDataUtil.append(this.getMessageModel(), intent);
 						helper.getFragment().startActivity(intent);
 					});
+					isTruncated = true;
 				} else {
 					holder.readOnContainer.setVisibility(View.GONE);
 					holder.readOnButton.setOnClickListener(null);
@@ -103,6 +108,19 @@ public class TextChatAdapterDecorator extends ChatAdapterDecorator {
 				true,
 				actionModeStatus.getActionModeEnabled(),
 				onClickElement);
+
+			// remove any clickable link span at the end of truncated text as the link may not be complete
+			if (isTruncated && holder.bodyTextView.getText() instanceof SpannableString) {
+				SpannableString buffer = (SpannableString) holder.bodyTextView.getText();
+				if (buffer != null) {
+					int lastCharOffset = buffer.length() - 1;
+					ClickableSpan[] link = buffer.getSpans(lastCharOffset, lastCharOffset, ClickableSpan.class);
+					if (link.length > 0) {
+						// we found a clickable span at the end of the truncated text, remove it
+						buffer.removeSpan(link[link.length - 1]);
+					}
+				}
+			}
 		}
 
 		RuntimeUtil.runOnUiThread(() -> setupResendStatus(holder));

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

@@ -140,7 +140,7 @@ public class EmojiMarkupUtil {
 				}
 			}
 
-			if (results.size() > 0) {
+			if (!results.isEmpty()) {
 				int scaleFactor = singleScale && ConfigUtils.isBiggerSingleEmojis(context) && !containsRegularText && results.size() <= LARGE_EMOJI_THRESHOLD ? LARGE_EMOJI_SCALE_FACTOR : 1;
 
 				if (ConfigUtils.isDefaultEmojiStyle()) {

+ 19 - 26
app/src/main/java/ch/threema/app/fragments/BigMediaFragment.kt

@@ -42,7 +42,6 @@ import com.bumptech.glide.Glide
 import com.bumptech.glide.load.resource.bitmap.Rotate
 import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
 import com.google.android.material.progressindicator.CircularProgressIndicator
-import pl.droidsonroids.gif.GifImageView
 
 private val logger = LoggingUtil.getThreemaLogger("BigMediaFragment")
 
@@ -52,7 +51,6 @@ class BigMediaFragment : Fragment() {
     private var viewPager: ViewPager2? = null
     private lateinit var bigFileView: BigFileView
     private lateinit var bigImageView: ImageView
-    private lateinit var bigGifImageView: GifImageView
     private var videoEditView: VideoEditView? = null
     private lateinit var bigProgressBar: CircularProgressIndicator
     private var bottomElemHeight: Int = 0
@@ -75,7 +73,6 @@ class BigMediaFragment : Fragment() {
         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)
         }
@@ -132,7 +129,7 @@ class BigMediaFragment : Fragment() {
 
 
         when (item.type) {
-            MediaItem.TYPE_IMAGE, MediaItem.TYPE_IMAGE_CAM, MediaItem.TYPE_GIF, MediaItem.TYPE_IMAGE_ANIMATED -> {
+            MediaItem.TYPE_IMAGE, MediaItem.TYPE_IMAGE_CAM, MediaItem.TYPE_IMAGE_ANIMATED -> {
                 showBigImage(item)
             }
             MediaItem.TYPE_VIDEO, MediaItem.TYPE_VIDEO_CAM -> {
@@ -166,7 +163,6 @@ class BigMediaFragment : Fragment() {
     }
 
     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
@@ -177,7 +173,6 @@ class BigMediaFragment : Fragment() {
     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(timelineDragListener)
         this.videoEditView?.doOnLayout {
@@ -190,27 +185,25 @@ class BigMediaFragment : Fragment() {
         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)
-            }
+        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
+
+        if (item.type == MediaItem.TYPE_IMAGE_ANIMATED) {
+            Glide.with(this)
+                .load(item.uri)
+                .transition(DrawableTransitionOptions.withCrossFade())
+                .optionalFitCenter()
+                .error(R.drawable.ic_baseline_broken_image_200)
+                .into(bigImageView)
         } 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)
+            Glide.with(context ?: return)
+                .load(item.uri)
                 .skipMemoryCache(true)
                 .transition(DrawableTransitionOptions.withCrossFade())
                 .optionalFitCenter()

+ 151 - 95
app/src/main/java/ch/threema/app/fragments/ComposeMessageFragment.java

@@ -201,7 +201,6 @@ import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.mediaattacher.MediaAttachActivity;
 import ch.threema.app.mediaattacher.MediaFilterQuery;
-import ch.threema.app.messagereceiver.ContactMessageReceiver;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.routines.ReadMessagesRoutine;
 import ch.threema.app.services.ContactService;
@@ -361,6 +360,7 @@ public class ComposeMessageFragment extends Fragment implements
 
 	private AudioManager audioManager;
 	private ConversationListView convListView;
+	private FrameLayout historyParent;
 	private @Nullable ComposeMessageAdapter composeMessageAdapter;
 	private View isTypingView;
 
@@ -918,9 +918,7 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 
 		@Override
-		public void onRemoved(BallotModel ballotModel) {
-
-		}
+		public void onRemoved(BallotModel ballotModel) { }
 
 		@Override
 		public boolean handle(BallotModel ballotModel) {
@@ -928,6 +926,39 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 	};
 
+	private final QuotePopup.QuotePopupListener quotePopupListener = new QuotePopup.QuotePopupListener() {
+		@Override
+		public void onHeightSet(int height) {
+			if (historyParent != null) {
+				historyParent.postDelayed(() ->
+					historyParent.setPadding(
+					historyParent.getPaddingLeft(),
+					historyParent.getPaddingTop(),
+					historyParent.getPaddingRight(),
+					height), 30);
+			}
+		}
+
+		@Override
+		public void onDismiss() {
+			if (historyParent != null) {
+				historyParent.postDelayed(() ->
+					historyParent.setPadding(
+                    historyParent.getPaddingLeft(),
+                    historyParent.getPaddingTop(),
+                    historyParent.getPaddingRight(),
+                    0), 70);
+			}
+		}
+
+		@Override
+		public void onPostVisibilityChange() {
+			if (messageText != null) {
+				updateSendButton(messageText.getText());
+				updateCameraButton();
+			}
+		}
+	};
 
 	@SuppressLint("StaticFieldLeak")
 	@Override
@@ -1049,6 +1080,8 @@ public class ComposeMessageFragment extends Fragment implements
 				this.convListView.setPadding(0, 0, 0, 0);
 			}
 
+			this.historyParent = fragmentView.findViewById(R.id.history_parent);
+
 			this.listViewTop = this.convListView.getPaddingTop();
 			this.swipeRefreshLayout = fragmentView.findViewById(R.id.ptr_layout);
 			this.swipeRefreshLayout.setOnRefreshListener(this);
@@ -1381,15 +1414,17 @@ public class ComposeMessageFragment extends Fragment implements
 					DISPATCH_MODE_STOP
 				)
 			);
-			ViewCompat.setWindowInsetsAnimationCallback(
-				emojiPicker,
-				new TranslateDeferringInsetsAnimationCallback(
+			if (emojiPicker != null) {
+				ViewCompat.setWindowInsetsAnimationCallback(
 					emojiPicker,
-					WindowInsetsCompat.Type.systemBars(),
-					WindowInsetsCompat.Type.ime(),
-					DISPATCH_MODE_STOP
-				)
-			);
+					new TranslateDeferringInsetsAnimationCallback(
+						emojiPicker,
+						WindowInsetsCompat.Type.systemBars(),
+						WindowInsetsCompat.Type.ime(),
+						DISPATCH_MODE_STOP
+					)
+				);
+			}
 		} catch (NullPointerException e) {
 			logger.error("Exception", e);
 		}
@@ -1861,10 +1896,12 @@ public class ComposeMessageFragment extends Fragment implements
 	}
 
 	private void updateCameraButton() {
-		if (cameraButton == null || messageText == null) {
+		if (cameraButton == null || attachButton == null || messageText == null) {
 			return;
 		}
 
+		boolean isCameraPermissionGranted = true;
+
 		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
 			ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
 
@@ -1874,31 +1911,62 @@ public class ComposeMessageFragment extends Fragment implements
 			// we hide the camera button only in case a)
 			if (!shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) && preferenceService.getCameraPermissionRequestShown()) {
 				cameraButton.setVisibility(View.GONE);
-				fixMessageTextPadding(View.GONE);
-				return;
+				isCameraPermissionGranted = false;
 			}
 		}
 
-		int visibility = messageText.getText() == null ||
-						messageText.getText().length() == 0 ?
-						View.VISIBLE : View.GONE;
+		final int attachButtonVisibility = isQuotePopupShown() ?
+			View.GONE : View.VISIBLE;
 
-		if (cameraButton.getVisibility() != visibility) {
-			Transition transition = new Slide(Gravity.RIGHT);
-			transition.setDuration(150);
-			transition.setInterpolator(new LinearInterpolator());
-			transition.addTarget(R.id.camera_button);
+		final int cameraButtonVisibility =
+			(messageText.getText() == null ||
+				messageText.getText().length() == 0) &&
+				!isQuotePopupShown() &&
+				isCameraPermissionGranted ?
+				View.VISIBLE : View.GONE;
 
-			TransitionManager.beginDelayedTransition((ViewGroup) cameraButton.getParent(), transition);
-			cameraButton.setVisibility(visibility);
+		final boolean attachButtonVisibilityChange = attachButton.getVisibility() != attachButtonVisibility;
+		final boolean cameraButtonVisibilityChange = cameraButton.getVisibility() != cameraButtonVisibility;
 
-			fixMessageTextPadding(visibility);
+		if (cameraButtonVisibilityChange) {
+			Transition cameraTransition = new Slide(Gravity.RIGHT);
+			cameraTransition.setStartDelay(cameraButtonVisibility == View.VISIBLE && attachButtonVisibilityChange ? 100 : 0);
+			cameraTransition.setDuration(120);
+			cameraTransition.setInterpolator(new LinearInterpolator());
+			cameraTransition.addTarget(R.id.camera_button);
+			TransitionManager.beginDelayedTransition((ViewGroup) cameraButton.getParent(), cameraTransition);
+			cameraButton.setVisibility(cameraButtonVisibility);
 		}
+
+		if (attachButtonVisibilityChange) {
+			Transition attachTransition = new Slide(Gravity.RIGHT);
+			attachTransition.setStartDelay(attachButtonVisibility == View.VISIBLE ? 0 : 100);
+			attachTransition.setDuration(120);
+			attachTransition.setInterpolator(new LinearInterpolator());
+			attachTransition.addTarget(R.id.attach_button);
+			TransitionManager.beginDelayedTransition((ViewGroup) attachButton.getParent(), attachTransition);
+			attachButton.setVisibility(attachButtonVisibility);
+		}
+
+		messageText.postDelayed(() -> fixMessageTextPadding(cameraButtonVisibility, attachButtonVisibility), 50);
 	}
 
-	private void fixMessageTextPadding(int visibility) {
-		int marginRight = getResources().getDimensionPixelSize(visibility == View.VISIBLE ? R.dimen.emoji_and_photo_button_width : R.dimen.emoji_button_width);
-		messageText.setPadding(messageText.getPaddingLeft(), messageText.getPaddingTop(), marginRight, messageText.getPaddingBottom());
+	private void fixMessageTextPadding(int cameraButtonVisibility, int attachButtonVisibility) {
+		if (isAdded()) {
+			int marginRight = ThreemaApplication.getAppContext().getResources().getDimensionPixelSize(R.dimen.emoji_and_photo_button_width);
+
+			if (cameraButtonVisibility != View.VISIBLE) {
+				marginRight -= getResources().getDimensionPixelSize(R.dimen.emoji_button_width);
+			}
+
+			if (attachButtonVisibility != View.VISIBLE) {
+				marginRight -= getResources().getDimensionPixelSize(R.dimen.emoji_button_width);
+			}
+
+			marginRight = Math.max(marginRight, getResources().getDimensionPixelSize(R.dimen.no_emoji_button_padding_left));
+
+			messageText.setPadding(messageText.getPaddingLeft(), messageText.getPaddingTop(), marginRight, messageText.getPaddingBottom());
+		}
 	}
 
 	private void updateSendButton(CharSequence s) {
@@ -2475,14 +2543,6 @@ public class ComposeMessageFragment extends Fragment implements
 					}
 				}
 				deleteableMessages.clear();
-
-				if (messageReceiver != null) {
-					if (messageReceiver.getMessagesCount() <= 0 && messageReceiver instanceof ContactMessageReceiver) {
-						conversationService.empty(messageReceiver);
-					} else {
-						conversationService.refresh(messageReceiver);
-					}
-				}
 			}
 		}
 	}
@@ -3398,7 +3458,7 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 
 		if (!TestUtil.empty(this.messageText.getText())) {
-			sendTextMessage();
+			prepareSendTextMessage();
 		} else {
 			if (ConfigUtils.requestAudioPermissions(requireActivity(), this, PERMISSION_REQUEST_ATTACH_VOICE_MESSAGE)) {
 				attachVoiceMessage();
@@ -3406,7 +3466,7 @@ public class ComposeMessageFragment extends Fragment implements
 		}
 	}
 
-	private void sendTextMessage() {
+	private void prepareSendTextMessage() {
 		final CharSequence message;
 
 		if (isQuotePopupShown()) {
@@ -3417,65 +3477,67 @@ public class ComposeMessageFragment extends Fragment implements
 				quoteInfo.getQuoteText(),
 				quoteInfo.getMessageModel()
 			);
-			// Close quote mode and then scroll to bottom. Note that the scrolling is needed because
-			// scrolling down will be interrupted when the quote panel's visibility is set to gone.
-			// Therefore we may need to initiate a scroll down again after the panel has been set to
-			// gone.
-			dismissQuotePopup(() -> scrollList(Integer.MAX_VALUE));
+
+			messageText.postDelayed(this::dismissQuotePopup, 500);
 		} else {
 			message = this.messageText.getText();
 		}
 
 		if (!TestUtil.empty(message)) {
-			// block send button to avoid double posting
-			this.messageText.setText("");
+			sendTextMessage(message);
+		} else {
+			logger.warn("Message text is empty");
+		}
+	}
 
-			if (typingIndicatorTextWatcher != null) {
-				messageText.removeTextChangedListener(typingIndicatorTextWatcher);
-			}
+	private void sendTextMessage(CharSequence message) {
+		// block send button to avoid double posting
+		this.messageText.setText("");
 
-			if (typingIndicatorTextWatcher != null) {
-				messageText.addTextChangedListener(typingIndicatorTextWatcher);
-			}
+		if (typingIndicatorTextWatcher != null) {
+			messageText.removeTextChangedListener(typingIndicatorTextWatcher);
+		}
 
-			//send stopped typing message
-			if (typingIndicatorTextWatcher != null) {
-				typingIndicatorTextWatcher.stopTyping();
-			}
+		if (typingIndicatorTextWatcher != null) {
+			messageText.addTextChangedListener(typingIndicatorTextWatcher);
+		}
 
-			new Thread(() -> TextMessageSendAction.getInstance()
-				.sendTextMessage(new MessageReceiver[]{messageReceiver}, message.toString(), new SendAction.ActionHandler() {
-					@Override
-					public void onError(final String errorMessage) {
-						RuntimeUtil.runOnUiThread(() -> {
-							LongToast.makeText(getActivity(), errorMessage, Toast.LENGTH_LONG).show();
-							if (!TestUtil.empty(message)) {
-								messageText.setText(message);
-								messageText.setSelection(messageText.length());
-							}
-						});
-					}
+		//send stopped typing message
+		if (typingIndicatorTextWatcher != null) {
+			typingIndicatorTextWatcher.stopTyping();
+		}
 
-					@Override
-					public void onWarning(String warning, boolean continueAction) {
-					}
+		new Thread(() -> TextMessageSendAction.getInstance()
+			.sendTextMessage(new MessageReceiver[]{messageReceiver}, message.toString(), new SendAction.ActionHandler() {
+				@Override
+				public void onError(final String errorMessage) {
+					RuntimeUtil.runOnUiThread(() -> {
+						LongToast.makeText(getActivity(), errorMessage, Toast.LENGTH_LONG).show();
+						if (!TestUtil.empty(message)) {
+							messageText.setText(message);
+							messageText.setSelection(messageText.length());
+						}
+					});
+				}
 
-					@Override
-					public void onProgress(final int progress, final int total) {
-					}
+				@Override
+				public void onWarning(String warning, boolean continueAction) {
+				}
 
-					@Override
-					public void onCompleted() {
-						RuntimeUtil.runOnUiThread(() -> {
-							scrollList(Integer.MAX_VALUE);
-							if (ConfigUtils.isTabletLayout()) {
-								// remove draft right now to make sure conversations pane is updated
-								ThreemaApplication.putMessageDraft(messageReceiver.getUniqueIdString(), "", null);
-							}
-						});
-					}
-				})).start();
-		}
+				@Override
+				public void onProgress(final int progress, final int total) {
+				}
+
+				@Override
+				public void onCompleted() {
+					RuntimeUtil.runOnUiThread(() -> {
+						if (ConfigUtils.isTabletLayout()) {
+							// remove draft right now to make sure conversations pane is updated
+							ThreemaApplication.putMessageDraft(messageReceiver.getUniqueIdString(), "", null);
+						}
+					});
+				}
+			})).start();
 	}
 
 	private void attachVoiceMessage() {
@@ -3657,10 +3719,10 @@ public class ComposeMessageFragment extends Fragment implements
 
 		if (activity.isSoftKeyboardOpen() || isEmojiPickerShown()) {
 			messageText.requestFocus();
-			quotePopup.show(activity, messageText, textInputLayout, quotedMessageModel, identity, sidebarColor);
+			quotePopup.show(activity, messageText, textInputLayout, quotedMessageModel, identity, sidebarColor, quotePopupListener);
 		} else {
 			EditTextUtil.focusWindowAndShowSoftKeyboard(messageText);
-			messageText.postDelayed(() -> quotePopup.show(activity, messageText, textInputLayout, quotedMessageModel, identity, sidebarColor), 550);
+			messageText.postDelayed(() -> quotePopup.show(activity, messageText, textInputLayout, quotedMessageModel, identity, sidebarColor, quotePopupListener), 550);
 		}
 	}
 
@@ -4449,13 +4511,7 @@ public class ComposeMessageFragment extends Fragment implements
 				mode.finish();
 			} else if (id == R.id.menu_message_quote) {
 				showQuotePopup(null);
-
-/*				if (activity.isSoftKeyboardOpen() || isEmojiPickerShown()) {
-					showQuotePopup(null, () -> RuntimeUtil.runOnUiThread(() -> messageText.requestFocus()), 0);
-				} else {
-					showQuotePopup(null, () -> RuntimeUtil.runOnUiThread(() -> EditTextUtil.focusWindowAndShowSoftKeyboard(messageText)), 500);
-				}
-*/				mode.finish();
+				mode.finish();
 			} else if (id == R.id.menu_show_text) {
 				showTextChatBubble(selectedMessages.get(0));
 				mode.finish();

+ 2 - 0
app/src/main/java/ch/threema/app/fragments/ContactsSectionFragment.java

@@ -1082,6 +1082,8 @@ public class ContactsSectionFragment
 
 		if (this.preferenceService.isSyncContacts() && ConfigUtils.requestContactPermissions(getActivity(), this, PERMISSION_REQUEST_REFRESH_CONTACTS)) {
 			if (this.synchronizeContactsService != null) {
+				// we force a contact sync even if the grace time has not yet been reached
+				preferenceService.setTimeOfLastContactSync(0L);
 				synchronizeContactsService.instantiateSynchronizationAndRun();
 			}
 		}

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

@@ -619,7 +619,7 @@ public class MessageSectionFragment extends MainFragment
 	};
 
 	private void showConversation(ConversationModel conversationModel, View v) {
-		conversationTagService.unTag(conversationModel, unreadTagModel);
+		conversationTagService.removeTagAndNotify(conversationModel, unreadTagModel);
 		conversationModel.setUnreadCount(0);
 
 		// Close keyboard if search view is expanded
@@ -1457,7 +1457,7 @@ public class MessageSectionFragment extends MainFragment
 				}
 				break;
 			case TAG_MARK_READ:
-				conversationTagService.unTag(conversationModel, unreadTagModel);
+				conversationTagService.removeTagAndNotify(conversationModel, unreadTagModel);
 				conversationModel.setIsUnreadTagged(false);
 				conversationModel.setUnreadCount(0);
 				new Thread(() -> messageService.markConversationAsRead(
@@ -1466,7 +1466,7 @@ public class MessageSectionFragment extends MainFragment
 				).start();
 				break;
 			case TAG_MARK_UNREAD:
-				conversationTagService.tag(conversationModel, unreadTagModel);
+				conversationTagService.addTagAndNotify(conversationModel, unreadTagModel);
 				conversationModel.setIsUnreadTagged(true);
 				break;
 		}

+ 0 - 93
app/src/main/java/ch/threema/app/fragments/mediaviews/GifViewFragment.kt

@@ -1,93 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2023-2024 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.fragments.mediaviews
-
-import android.graphics.drawable.Drawable
-import android.net.Uri
-import android.os.Bundle
-import android.view.View
-import android.widget.ImageView
-import ch.threema.app.R
-import ch.threema.base.utils.LoggingUtil
-import pl.droidsonroids.gif.GifDrawable
-import pl.droidsonroids.gif.GifImageView
-import java.io.File
-import java.io.IOException
-import java.lang.ref.WeakReference
-
-private val logger = LoggingUtil.getThreemaLogger("GifViewFragment")
-
-/**
- * This fragment is used to show GIFs.
- */
-class GifViewFragment: MediaViewFragment() {
-
-    private lateinit var thumbnailImageView: WeakReference<ImageView>
-    private lateinit var gifImageViewRef: WeakReference<GifImageView>
-
-    override fun created(savedInstanceState: Bundle?) {
-        thumbnailImageView = WeakReference(rootViewReference.get()?.findViewById(R.id.gif_thumbnail))
-        gifImageViewRef = WeakReference(rootViewReference.get()?.findViewById(R.id.gif_view))
-    }
-
-    override fun getFragmentResourceId(): Int = R.layout.fragment_media_viewer_gif
-
-    override fun handleDecryptingFile() {
-        // nothing to do
-    }
-
-    override fun handleDecryptFailure() {
-        // nothing to do
-    }
-
-    override fun showThumbnail(thumbnail: Drawable) {
-        gifImageViewRef.get()?.visibility = View.INVISIBLE
-        thumbnailImageView.get()?.visibility = View.VISIBLE
-        thumbnailImageView.get()?.setImageDrawable(thumbnail)
-    }
-
-    override fun handleDecryptedFile(file: File?) {
-        if (file == null) {
-            logger.error("Cannot show gif: file is null")
-            return
-        }
-        showGif(file)
-    }
-
-    /**
-     * Show gif and hide the progress bar
-     *
-     * @param file the gif file
-     */
-    private fun showGif(file: File) {
-        try {
-            val gifDrawable = GifDrawable(requireContext().contentResolver, Uri.fromFile(file))
-            gifImageViewRef.get()?.setImageDrawable(gifDrawable)
-            gifImageViewRef.get()?.visibility = View.VISIBLE
-            thumbnailImageView.get()?.visibility = View.GONE
-            gifDrawable.start()
-        } catch (e: IOException) {
-            logger.error("Could not show gif", e)
-        }
-    }
-
-}

+ 28 - 15
app/src/main/java/ch/threema/app/fragments/mediaviews/ImageViewFragment.java

@@ -23,7 +23,6 @@ package ch.threema.app.fragments.mediaviews;
 
 import android.content.res.Configuration;
 import android.graphics.Bitmap;
-import android.graphics.drawable.AnimatedImageDrawable;
 import android.graphics.drawable.Drawable;
 import android.net.Uri;
 import android.os.Bundle;
@@ -31,6 +30,11 @@ import android.view.View;
 import android.view.ViewTreeObserver;
 import android.widget.ImageView;
 
+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;
 import com.davemorrissey.labs.subscaleview.ImageSource;
 import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView;
 
@@ -41,6 +45,7 @@ import java.lang.ref.WeakReference;
 
 import androidx.annotation.NonNull;
 
+import androidx.annotation.Nullable;
 import ch.threema.app.R;
 import ch.threema.app.utils.BitmapUtil;
 import ch.threema.app.utils.ConfigUtils;
@@ -48,7 +53,7 @@ import ch.threema.app.utils.TestUtil;
 import ch.threema.base.utils.LoggingUtil;
 
 /**
- * This fragment is used to show images. Note that GIFs must be shown with the GifViewFragment.
+ * This fragment is used to show images.
  */
 public class ImageViewFragment extends MediaViewFragment {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("ImageViewFragment");
@@ -165,20 +170,28 @@ public class ImageViewFragment extends MediaViewFragment {
 	 * @param file the image file
 	 */
 	private void showImage(@NonNull File file) {
-		Drawable drawable = null;
-
-		if (ConfigUtils.isSupportedAnimatedImageFormat(getMessageModel().getFileData().getMimeType())) {
-			drawable = Drawable.createFromPath(file.getPath());
-			if (drawable instanceof AnimatedImageDrawable) {
-				previewViewReference.get().setImageDrawable(drawable);
-				((AnimatedImageDrawable) drawable).start();
-				imageViewReference.get().setVisibility(View.GONE);
-			} else {
-				drawable = null;
-			}
-		}
+		if (ConfigUtils.isDisplayableAnimatedImageFormat(getMessageModel().getFileData().getMimeType())) {
+			Glide.with(this)
+				.load(file)
+				.optionalFitCenter()
+				.error(R.drawable.ic_baseline_broken_image_200)
+				.addListener(new RequestListener<>() {
+					@Override
+					public boolean onLoadFailed(@Nullable GlideException e, @Nullable Object model, @NonNull Target<Drawable> target, boolean isFirstResource) {
+						imageViewReference.get().setVisibility(View.GONE);
+						return true;
+					}
 
-		if (drawable == null) {
+					@Override
+					public boolean onResourceReady(@NonNull Drawable resource, @NonNull Object model, Target<Drawable> target, @NonNull DataSource dataSource, boolean isFirstResource) {
+						imageViewReference.get().setVisibility(View.GONE);
+						return false;
+					}
+				})
+				.skipMemoryCache(true)
+				.into(previewViewReference.get());
+		}
+		else  {
 			imageViewReference.get().setImage(ImageSource.uri(file.getPath()));
 			imageViewReference.get().setVisibility(View.VISIBLE);
 

+ 1 - 1
app/src/main/java/ch/threema/app/glide/AvatarGlideModule.java

@@ -43,7 +43,7 @@ public class AvatarGlideModule extends AppGlideModule {
 
 	@Override
 	public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) {
-		builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565));
+		builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_ARGB_8888));
 	}
 
 	@Override

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

@@ -284,13 +284,13 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
 			.error(placeholderIcon)
 			.addListener(new RequestListener<>() {
 				@Override
-				public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Bitmap> target, boolean isFirstResource) {
+				public boolean onLoadFailed(@Nullable GlideException e, Object model, @NonNull Target<Bitmap> target, boolean isFirstResource) {
 					setupPlaceholder(holder);
 					return false;
 				}
 
 				@Override
-				public boolean onResourceReady(Bitmap resource, Object model, Target<Bitmap> target, DataSource dataSource, boolean isFirstResource) {
+				public boolean onResourceReady(@NonNull Bitmap resource, @NonNull Object model, Target<Bitmap> target, @NonNull DataSource dataSource, boolean isFirstResource) {
 					holder.thumbnailView.clearColorFilter();
 					holder.thumbnailView.setScaleType(ImageView.ScaleType.CENTER_CROP);
 					return false;
@@ -314,7 +314,7 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
 	 * @param holder   ItemHolder containing a textview
 	 * @return Snippet containing the match with a trailing ellipsis if the match is located beyond the first snippetThreshold characters
 	 */
-	private String getSnippet(@NonNull String fullText, @Nullable String needle, ItemHolder holder) {
+	private CharSequence getSnippet(@NonNull String fullText, @Nullable String needle, ItemHolder holder) {
 		if (!TestUtil.empty(needle)) {
 			int firstMatch = fullText.toLowerCase().indexOf(needle);
 			if (firstMatch > snippetThreshold) {
@@ -326,7 +326,7 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
 					}
 				}
 
-				SpannableStringBuilder emojified = (SpannableStringBuilder) EmojiMarkupUtil.getInstance().addTextSpans(context, fullText, holder.snippetView, true);
+				SpannableStringBuilder emojified = (SpannableStringBuilder) EmojiMarkupUtil.getInstance().addTextSpans(context, fullText, holder.snippetView, true, false, false);
 
 				int transitionStart = emojified.nextSpanTransition(firstMatch - snippetThreshold, firstMatch, EmojiImageSpan.class);
 				if (transitionStart == firstMatch) {
@@ -337,7 +337,7 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
 				}
 			}
 		}
-		return fullText;
+		return EmojiMarkupUtil.getInstance().addTextSpans(context, fullText, holder.snippetView, false, false, false);
 	}
 
 
@@ -347,7 +347,7 @@ public class GlobalSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewH
 	}
 
 	private void setSnippetToTextView(AbstractMessageModel current, ItemHolder itemHolder) {
-		String snippetText = null;
+		CharSequence snippetText = null;
 		switch (current.getType()) {
 			case FILE:
 				// fallthrough

+ 7 - 11
app/src/main/java/ch/threema/app/managers/ServiceManager.java

@@ -391,7 +391,7 @@ public class ServiceManager {
 		logger.trace("startConnection");
 
 		String currentIdentity = this.identityStore.getIdentity();
-		if (currentIdentity == null || currentIdentity.length() == 0) {
+		if (currentIdentity == null || currentIdentity.isEmpty()) {
 			throw new NoIdentityException();
 		}
 
@@ -622,16 +622,10 @@ public class ServiceManager {
 							return null;
 						}
 
-						@Override
-						public String validate(Credentials credentials, boolean allowException) {
-							return null;
-						}
-
 						@Override
 						public String validate(boolean allowException) {
 							return null;
 						}
-
 						@Override
 						public boolean hasCredentials() {
 							return false;
@@ -693,6 +687,7 @@ public class ServiceManager {
 				this.getMutedChatsListService(),
 				this.getHiddenChatsListService(),
 				this.getRingtoneService(),
+				this.getConversationTagService(),
 				this
 			);
 		}
@@ -762,10 +757,11 @@ public class ServiceManager {
 	public DistributionListService getDistributionListService() throws MasterKeyLockedException, NoIdentityException, FileSystemNotPresentException {
 		if(null == this.distributionListService) {
 			this.distributionListService = new DistributionListServiceImpl(
-					this.getContext(),
-					this.getAvatarCacheService(),
-					this.databaseServiceNew,
-					this.getContactService()
+				this.getContext(),
+				this.getAvatarCacheService(),
+				this.databaseServiceNew,
+				this.getContactService(),
+				this.getConversationTagService()
 			);
 		}
 

+ 11 - 27
app/src/main/java/ch/threema/app/mediaattacher/ImagePreviewFragment.java

@@ -21,7 +21,6 @@
 
 package ch.threema.app.mediaattacher;
 
-import android.graphics.drawable.AnimatedImageDrawable;
 import android.graphics.drawable.BitmapDrawable;
 import android.graphics.drawable.Drawable;
 import android.os.Build;
@@ -40,18 +39,14 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import ch.threema.app.R;
-import ch.threema.app.ThreemaApplication;
-import ch.threema.app.ui.MediaItem;
-import pl.droidsonroids.gif.GifImageView;
 
 import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade;
 
 public class ImagePreviewFragment extends PreviewFragment {
-	private GifImageView gifView;
 	private SubsamplingScaleImageView scaleImageView;
 	private ImageView imageView;
 
-	ImagePreviewFragment(MediaAttachItem mediaItem, MediaAttachViewModel mediaAttachViewModel){
+	ImagePreviewFragment(MediaAttachItem mediaItem, MediaAttachViewModel mediaAttachViewModel) {
 		super(mediaItem, mediaAttachViewModel);
 	}
 
@@ -68,20 +63,17 @@ public class ImagePreviewFragment extends PreviewFragment {
 
 		if (rootView != null) {
 			this.scaleImageView = rootView.findViewById(R.id.scale_image_view);
-			this.gifView = rootView.findViewById(R.id.gif_view);
 			this.imageView = rootView.findViewById(R.id.image_view);
 
-			if (mediaItem.getType() == MediaItem.TYPE_GIF) {
-				scaleImageView.setVisibility(View.GONE);
-				imageView.setVisibility(View.GONE);
-				Glide.with(ThreemaApplication.getAppContext())
-					.load(mediaItem.getUri())
+			if (mediaAttachItem.getType() == MediaAttachItem.TYPE_GIF || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && mediaAttachItem.getType() == MediaAttachItem.TYPE_WEBP)) {
+				Glide.with(this).load(mediaAttachItem.getUri())
 					.transition(withCrossFade())
-					.into(gifView);
+					.optionalFitCenter()
+					.error(R.drawable.ic_baseline_broken_image_200)
+					.into(imageView);
 			} else {
-				gifView.setVisibility(View.GONE);
 				Glide.with(this)
-					.load(mediaItem.getUri())
+					.load(mediaAttachItem.getUri())
 					.transition(withCrossFade())
 					.optionalCenterInside()
 					.error(R.drawable.ic_baseline_broken_image_200)
@@ -92,19 +84,10 @@ public class ImagePreviewFragment extends PreviewFragment {
 
 						@Override
 						public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
-							if (resource instanceof BitmapDrawable) {
-								scaleImageView.setImage(ImageSource.bitmap(((BitmapDrawable) resource).getBitmap()));
-
-								scaleImageView.setVisibility(View.VISIBLE);
-								imageView.setVisibility(View.GONE);
+							scaleImageView.setImage(ImageSource.bitmap(((BitmapDrawable) resource).getBitmap()));
 
-							} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && resource instanceof AnimatedImageDrawable) {
-								imageView.setImageDrawable(resource);
-								((AnimatedImageDrawable)resource).start();
-
-								imageView.setVisibility(View.VISIBLE);
-								scaleImageView.setVisibility(View.GONE);
-							}
+							scaleImageView.setVisibility(View.VISIBLE);
+							imageView.setVisibility(View.GONE);
 						}
 
 						@Override
@@ -112,6 +95,7 @@ public class ImagePreviewFragment extends PreviewFragment {
 						}
 					});
 			}
+
 		}
 	}
 }

+ 3 - 6
app/src/main/java/ch/threema/app/mediaattacher/ImagePreviewPagerAdapter.java

@@ -29,15 +29,12 @@ import java.util.List;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
-import androidx.annotation.OptIn;
 import androidx.fragment.app.Fragment;
 import androidx.fragment.app.FragmentActivity;
 import androidx.lifecycle.ViewModelProvider;
-import androidx.media3.common.util.UnstableApi;
 import androidx.viewpager2.adapter.FragmentStateAdapter;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.activities.MediaViewerActivity;
-import ch.threema.app.ui.MediaItem;
 
 public class ImagePreviewPagerAdapter extends FragmentStateAdapter {
 	private final MediaAttachViewModel mediaAttachViewModel;
@@ -54,14 +51,14 @@ public class ImagePreviewPagerAdapter extends FragmentStateAdapter {
 	public Fragment createFragment(int position) {
 		MediaAttachItem mediaAttachItem = getItem(position);
 		if (mediaAttachItem != null) {
-			int mimeType = mediaAttachItem.getType();
+			int mediaType = mediaAttachItem.getType();
 			Bundle args = new Bundle();
 			args.putBoolean(MediaViewerActivity.EXTRA_ID_IMMEDIATE_PLAY, true);
 
 			PreviewFragment fragment = null;
-			if (mimeType == MediaItem.TYPE_IMAGE || mimeType == MediaItem.TYPE_GIF || mimeType == MediaItem.TYPE_IMAGE_ANIMATED) {
+			if (mediaType == MediaAttachItem.TYPE_IMAGE || mediaType == MediaAttachItem.TYPE_GIF || mediaType == MediaAttachItem.TYPE_WEBP) {
 				fragment = new ImagePreviewFragment(mediaAttachItem, mediaAttachViewModel);
-			} else if (mimeType == MediaItem.TYPE_VIDEO) {
+			} else if (mediaType == MediaAttachItem.TYPE_VIDEO) {
 				fragment = new VideoPreviewFragment(mediaAttachItem, mediaAttachViewModel);
 			}
 

+ 3 - 4
app/src/main/java/ch/threema/app/mediaattacher/MediaAttachAdapter.java

@@ -55,7 +55,6 @@ import java.util.concurrent.RejectedExecutionException;
 
 import ch.threema.app.R;
 import ch.threema.app.ui.CheckableFrameLayout;
-import ch.threema.app.ui.MediaItem;
 import ch.threema.app.utils.StringConversionUtil;
 import ch.threema.base.utils.LoggingUtil;
 
@@ -184,11 +183,11 @@ public class MediaAttachAdapter extends RecyclerView.Adapter<MediaAttachAdapter.
 
 							contentView.setContentDescription(context.getString(R.string.attach_picture) +  ": " + mediaAttachItem.getDisplayName());
 
-							if (mediaAttachItem.getType() == MediaItem.TYPE_GIF) {
+							if (mediaAttachItem.getType() == MediaAttachItem.TYPE_GIF) {
 								gifIndicator.setVisibility(View.VISIBLE);
 								gifIcon.setImageResource(R.drawable.ic_gif_24dp);
 								contentView.setContentDescription(context.getString(R.string.attach_gif) +  ": " + mediaAttachItem.getDisplayName());
-							} else if (mediaAttachItem.getType() == MediaItem.TYPE_IMAGE_ANIMATED) {
+							} else if (mediaAttachItem.getType() == MediaAttachItem.TYPE_WEBP) {
 								gifIndicator.setVisibility(View.VISIBLE);
 								gifIcon.setImageResource(R.drawable.ic_webp);
 								contentView.setContentDescription("WebP: " + mediaAttachItem.getDisplayName());
@@ -196,7 +195,7 @@ public class MediaAttachAdapter extends RecyclerView.Adapter<MediaAttachAdapter.
 								gifIndicator.setVisibility(View.GONE);
 							}
 
-							if (mediaAttachItem.getType() == MediaItem.TYPE_VIDEO) {
+							if (mediaAttachItem.getType() == MediaAttachItem.TYPE_VIDEO) {
 								videoDuration.setText(StringConversionUtil.getDurationString(mediaAttachItem.getDuration()));
 								videoIndicator.setVisibility(View.VISIBLE);
 								contentView.setContentDescription(context.getString(R.string.attach_video) +  ": " + mediaAttachItem.getDisplayName());

+ 22 - 2
app/src/main/java/ch/threema/app/mediaattacher/MediaAttachItem.java

@@ -25,6 +25,11 @@ import android.net.Uri;
 import android.os.Parcel;
 import android.os.Parcelable;
 
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+import androidx.annotation.IntDef;
+
 /**
  * A MediaAttachItem represents a media item in the attacher (e.g. a photo or a video).
  *
@@ -40,7 +45,21 @@ public class MediaAttachItem implements Parcelable {
 	private final String displayName;
 	private final int orientation;
 	private final int duration;
-	private final int type;
+	@MediaAttachType private final int type;
+
+	@Retention(RetentionPolicy.SOURCE)
+	@IntDef({TYPE_FILE, TYPE_IMAGE, TYPE_VIDEO, TYPE_IMAGE_CAM, TYPE_VIDEO_CAM, TYPE_VOICEMESSAGE, TYPE_TEXT, TYPE_LOCATION, TYPE_GIF, TYPE_WEBP})
+	public @interface MediaAttachType {}
+	public static final int TYPE_FILE = 0;
+	public static final int TYPE_IMAGE = 1;
+	public static final int TYPE_VIDEO = 2;
+	public static final int TYPE_IMAGE_CAM = 3;
+	public static final int TYPE_VIDEO_CAM = 4;
+	public static final int TYPE_VOICEMESSAGE = 5;
+	public static final int TYPE_TEXT = 6;
+	public static final int TYPE_LOCATION = 7;
+	public static final int TYPE_GIF = 8;
+	public static final int TYPE_WEBP = 9;
 
 	public MediaAttachItem(
 		int id,
@@ -52,7 +71,7 @@ public class MediaAttachItem implements Parcelable {
 		String bucketName,
 		int orientation,
 		int duration,
-		int type
+		@MediaAttachType int type
 	) {
 		this.id = id;
 		this.dateAdded = dateAdded;
@@ -150,6 +169,7 @@ public class MediaAttachItem implements Parcelable {
 		return duration;
 	}
 
+	@MediaAttachType
 	public int getType() {
 		return type;
 	}

+ 7 - 8
app/src/main/java/ch/threema/app/mediaattacher/MediaRepository.java

@@ -44,7 +44,6 @@ import java.util.Collections;
 import java.util.List;
 
 import ch.threema.app.ThreemaApplication;
-import ch.threema.app.ui.MediaItem;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.FileUtil;
 import ch.threema.app.utils.MimeUtil;
@@ -181,7 +180,7 @@ public class MediaRepository {
 	 */
 	@SuppressLint("NewApi")
 	@WorkerThread
-	private void addToMediaResults(@Nullable Cursor cursor, @NonNull List<MediaAttachItem> mediaList, boolean isVideo) {
+	private void addToMediaResults(@Nullable Cursor cursor, @NonNull List<MediaAttachItem> mediaAttachItems, boolean isVideo) {
 		if (cursor != null) {
 			while (cursor.moveToNext()) {
 				int id = cursor.getInt(cursor.getColumnIndex(MediaStore.MediaColumns._ID));
@@ -205,7 +204,7 @@ public class MediaRepository {
 
 				int type;
 				if (MimeUtil.isVideoFile(mimeType)) {
-					type = MediaItem.TYPE_VIDEO;
+					type = MediaAttachItem.TYPE_VIDEO;
 					if (duration == 0) {
 						// do not use automatic resource management on MediaMetadataRetriever
 						MediaMetadataRetriever metaDataRetriever = new MediaMetadataRetriever();
@@ -222,17 +221,17 @@ public class MediaRepository {
 						}
 					}
 				} else if (MimeUtil.isGifFile(mimeType)) {
-					type = MediaItem.TYPE_GIF;
-				} else if (ConfigUtils.isSupportedAnimatedImageFormat(mimeType) && FileUtil.isAnimatedImageFile(contentUri)) {
-					type = MediaItem.TYPE_IMAGE_ANIMATED;
+					type = MediaAttachItem.TYPE_GIF;
+				} else if (MimeUtil.isWebPFile(mimeType)) {
+					type = MediaAttachItem.TYPE_WEBP;
 				} else {
-					type = MediaItem.TYPE_IMAGE;
+					type = MediaAttachItem.TYPE_IMAGE;
 				}
 				MediaAttachItem item = new MediaAttachItem(
 					id, dateAdded, dateTaken, dateModified, contentUri,
 					displayName, bucketName, orientation, duration, type
 				);
-				mediaList.add(item);
+				mediaAttachItems.add(item);
 			}
 		}
 	}

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

@@ -40,7 +40,6 @@ import androidx.activity.result.contract.ActivityResultContracts;
 import androidx.annotation.NonNull;
 import androidx.constraintlayout.widget.ConstraintLayout;
 import androidx.core.app.ActivityCompat;
-import androidx.core.content.ContextCompat;
 import androidx.lifecycle.Observer;
 
 import com.google.android.material.bottomsheet.BottomSheetBehavior;
@@ -107,7 +106,7 @@ public class MediaSelectionActivity extends MediaSelectionBaseActivity {
 					if (finalPreviousQuery != null) {
 						switch (finalPreviousQueryType) {
 							case FILTER_MEDIA_TYPE:
-								MediaSelectionActivity.this.filterMediaByMimeType(finalPreviousQuery);
+								MediaSelectionActivity.this.filterMediaByMediaAttachType(finalPreviousQuery);
 								break;
 							case FILTER_MEDIA_BUCKET:
 								MediaSelectionActivity.this.filterMediaByBucket(finalPreviousQuery);

+ 39 - 40
app/src/main/java/ch/threema/app/mediaattacher/MediaSelectionBaseActivity.java

@@ -32,6 +32,7 @@ import static ch.threema.app.mediaattacher.MediaFilterQuery.FILTER_MEDIA_TYPE;
 
 import android.animation.Animator;
 import android.animation.AnimatorListenerAdapter;
+import android.annotation.SuppressLint;
 import android.content.Context;
 import android.content.Intent;
 import android.content.res.Configuration;
@@ -100,7 +101,6 @@ import ch.threema.app.ui.CheckableView;
 import ch.threema.app.ui.EmptyRecyclerView;
 import ch.threema.app.ui.EmptyView;
 import ch.threema.app.ui.MediaGridItemDecoration;
-import ch.threema.app.ui.MediaItem;
 import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.FileUtil;
 import ch.threema.app.utils.LocaleUtil;
@@ -209,7 +209,7 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 	}
 
 	@Override
-	protected void onSaveInstanceState(Bundle outState) {
+	protected void onSaveInstanceState(@NonNull Bundle outState) {
 		super.onSaveInstanceState(outState);
 		outState.putInt(KEY_BOTTOM_SHEET_STATE, bottomSheetBehavior.getState());
 		outState.putBoolean(KEY_PREVIEW_MODE, isPreviewMode);
@@ -316,8 +316,7 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 			}
 		});
 
-		// fill background with transparent black to see chat behind drawer
-		FitWindowsFrameLayout contentFrameLayout = (FitWindowsFrameLayout) ((ViewGroup) rootView.getParent()).getParent();
+		@SuppressLint("RestrictedApi") FitWindowsFrameLayout contentFrameLayout = (FitWindowsFrameLayout) ((ViewGroup) rootView.getParent()).getParent();
 		contentFrameLayout.setOnClickListener(v -> finish());
 
 		// set status bar color
@@ -414,7 +413,7 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		ConfigUtils.tintMenuItem(this, topMenuItem, R.attr.colorOnSurface);
 
 		// Fetch all media, add a unique menu item for each media storage bucket and media type group.
-		registerOnAllDataFetchedListener(new Observer<List<MediaAttachItem>>() {
+		registerOnAllDataFetchedListener(new Observer<>() {
 			@Override
 			public void onChanged(List<MediaAttachItem> mediaAttachItems) {
 				synchronized (filterMenuLock) {
@@ -434,41 +433,41 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 
 					// Extract buckets and media types
 					final List<String> buckets = new ArrayList<>();
-					final TreeMap<String, Integer> mediaTypes = new TreeMap<>();
+					final TreeMap<String, Integer> mediaAttachTypes = new TreeMap<>();
 
-					for (MediaAttachItem mediaItem : mediaAttachItems) {
-						String bucket = mediaItem.getBucketName();
+					for (MediaAttachItem mediaAttachItem : mediaAttachItems) {
+						String bucket = mediaAttachItem.getBucketName();
 						if (!TextUtils.isEmpty(bucket) && !buckets.contains(bucket)) {
-							buckets.add(mediaItem.getBucketName());
+							buckets.add(mediaAttachItem.getBucketName());
 						}
 
-						int type = mediaItem.getType();
-						if (!mediaTypes.containsValue(type)) {
-							String mediaTypeName = MediaSelectionBaseActivity.this.getMimeTypeTitle(type);
-							mediaTypes.put(mediaTypeName, type);
+						int type = mediaAttachItem.getType();
+						if (!mediaAttachTypes.containsValue(type)) {
+							String mediaTypeName = MediaSelectionBaseActivity.this.getMediaAttachTypeTitle(type);
+							mediaAttachTypes.put(mediaTypeName, type);
 						}
 					}
 
 					Collections.sort(buckets);
 
 					// Fill menu first media types sorted then folders/buckets sorted
-					for (Map.Entry<String, Integer> mediaType : mediaTypes.entrySet()) {
+					for (Map.Entry<String, Integer> mediaType : mediaAttachTypes.entrySet()) {
 						MenuItem item = menu.add(mediaType.getKey()).setOnMenuItemClickListener(menuItem -> {
-							MediaSelectionBaseActivity.this.filterMediaByMimeType(menuItem.toString());
+							MediaSelectionBaseActivity.this.filterMediaByMediaAttachType(menuItem.toString());
 							return true;
 						});
 
 						switch (mediaType.getValue()) {
-							case MediaItem.TYPE_IMAGE:
+							case MediaAttachItem.TYPE_IMAGE:
 								item.setIcon(R.drawable.ic_image_outline);
 								break;
-							case MediaItem.TYPE_VIDEO:
+							case MediaAttachItem.TYPE_VIDEO:
 								item.setIcon(R.drawable.ic_movie_outline);
 								break;
-							case MediaItem.TYPE_GIF:
+							case MediaAttachItem.TYPE_GIF:
 								item.setIcon(R.drawable.ic_gif_24dp);
 								break;
-							case MediaItem.TYPE_IMAGE_ANIMATED:
+							case MediaAttachItem.TYPE_WEBP:
 								item.setIcon(R.drawable.ic_webp);
 								break;
 						}
@@ -503,7 +502,7 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 				if (savedQueryType != null) {
 					switch (savedQueryType) {
 						case FILTER_MEDIA_TYPE:
-							MediaSelectionBaseActivity.this.filterMediaByMimeType(savedQuery);
+							MediaSelectionBaseActivity.this.filterMediaByMediaAttachType(savedQuery);
 							break;
 						case FILTER_MEDIA_BUCKET:
 							MediaSelectionBaseActivity.this.filterMediaByBucket(savedQuery);
@@ -733,27 +732,27 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		mediaAttachViewModel.setlastQuery(FILTER_MEDIA_BUCKET, mediaBucket);
 	}
 
-	public void filterMediaByMimeType(@NonNull String mimeTypeTitle) {
-		int mimeTypeIndex = 0;
+	public void filterMediaByMediaAttachType(@NonNull String mediaAttachTypeTitle) {
+		int mediaAttachType = 0;
 
-		if (mimeTypeTitle.equals(ThreemaApplication.getAppContext().getResources().getString(R.string.media_gallery_pictures))) {
-			mimeTypeIndex = MediaItem.TYPE_IMAGE;
+		if (mediaAttachTypeTitle.equals(ThreemaApplication.getAppContext().getResources().getString(R.string.media_gallery_pictures))) {
+			mediaAttachType = MediaAttachItem.TYPE_IMAGE;
 		}
-		else if (mimeTypeTitle.equals(ThreemaApplication.getAppContext().getResources().getString(R.string.media_gallery_videos))) {
-			mimeTypeIndex = MediaItem.TYPE_VIDEO;
+		else if (mediaAttachTypeTitle.equals(ThreemaApplication.getAppContext().getResources().getString(R.string.media_gallery_videos))) {
+			mediaAttachType = MediaAttachItem.TYPE_VIDEO;
 		}
-		else if (mimeTypeTitle.equals(ThreemaApplication.getAppContext().getResources().getString(R.string.media_gallery_gifs))) {
-			mimeTypeIndex = MediaItem.TYPE_GIF;
+		else if (mediaAttachTypeTitle.equals(ThreemaApplication.getAppContext().getResources().getString(R.string.media_gallery_gifs))) {
+			mediaAttachType = MediaAttachItem.TYPE_GIF;
 		}
-		else if (mimeTypeTitle.equals(ThreemaApplication.getAppContext().getResources().getString(R.string.media_gallery_animated_webps))) {
-			mimeTypeIndex = MediaItem.TYPE_IMAGE_ANIMATED;
+		else if (mediaAttachTypeTitle.equals(ThreemaApplication.getAppContext().getResources().getString(R.string.media_gallery_animated_webps))) {
+			mediaAttachType = MediaAttachItem.TYPE_WEBP;
 		}
 
-		if (mimeTypeIndex != 0) {
-			mediaAttachViewModel.setMediaByType(mimeTypeIndex);
+		if (mediaAttachType != 0) {
+			mediaAttachViewModel.setMediaByType(mediaAttachType);
 		}
-		menuTitle.setText(mimeTypeTitle);
-		mediaAttachViewModel.setlastQuery(FILTER_MEDIA_TYPE, mimeTypeTitle);
+		menuTitle.setText(mediaAttachTypeTitle);
+		mediaAttachViewModel.setlastQuery(FILTER_MEDIA_TYPE, mediaAttachTypeTitle);
 	}
 
 	public void filterMediaBySelectedItems() {
@@ -762,15 +761,15 @@ abstract public class MediaSelectionBaseActivity extends ThreemaActivity impleme
 		mediaAttachViewModel.setlastQuery(FILTER_MEDIA_SELECTED, null);
 	}
 
-	public String getMimeTypeTitle(int mimeType) {
-		switch (mimeType){
-			case (MediaItem.TYPE_IMAGE):
+	public String getMediaAttachTypeTitle(int mediaAttachType) {
+		switch (mediaAttachType){
+			case (MediaAttachItem.TYPE_IMAGE):
 				return getResources().getString(R.string.media_gallery_pictures);
-			case (MediaItem.TYPE_VIDEO):
+			case (MediaAttachItem.TYPE_VIDEO):
 				return getResources().getString(R.string.media_gallery_videos);
-			case (MediaItem.TYPE_GIF):
+			case (MediaAttachItem.TYPE_GIF):
 				return getResources().getString(R.string.media_gallery_gifs);
-			case (MediaItem.TYPE_IMAGE_ANIMATED):
+			case (MediaAttachItem.TYPE_WEBP):
 				return getResources().getString(R.string.media_gallery_animated_webps);
 			default:
 				return null;

+ 2 - 8
app/src/main/java/ch/threema/app/mediaattacher/PreviewFragment.java

@@ -21,24 +21,18 @@
 
 package ch.threema.app.mediaattacher;
 
-import android.content.Context;
-import android.media.AudioManager;
-import android.os.Bundle;
 import android.view.View;
-import android.widget.Toast;
 
-import androidx.annotation.Nullable;
 import androidx.fragment.app.Fragment;
-import ch.threema.app.R;
 
 public abstract class PreviewFragment extends Fragment {
-	protected MediaAttachItem mediaItem;
+	protected MediaAttachItem mediaAttachItem;
 	protected MediaAttachViewModel mediaAttachViewModel;
 	protected View rootView;
 	protected boolean isChecked = false;
 
 	public PreviewFragment(MediaAttachItem mediaItem, MediaAttachViewModel mediaAttachViewModel) {
-		this.mediaItem = mediaItem;
+		this.mediaAttachItem = mediaItem;
 		this.mediaAttachViewModel = mediaAttachViewModel;
 
 		setRetainInstance(true);

+ 1 - 1
app/src/main/java/ch/threema/app/mediaattacher/VideoPreviewFragment.java

@@ -162,7 +162,7 @@ public class VideoPreviewFragment extends PreviewFragment implements DefaultLife
 			this.videoView.setControllerShowTimeoutMs(1500);
 			this.videoView.showController();
 
-			this.videoPlayer.setMediaItem(MediaItem.fromUri(this.mediaItem.getUri()));
+			this.videoPlayer.setMediaItem(MediaItem.fromUri(this.mediaAttachItem.getUri()));
 			this.videoPlayer.setPlayWhenReady(playWhenReady);
 			this.videoPlayer.prepare();
 		} catch (OutOfMemoryError e) {

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

@@ -238,6 +238,10 @@ public class ContactMessageReceiver implements MessageReceiver<MessageModel> {
 			modelFileData.setEncryptionKey(encryptionResult.getKey());
 		}
 
+		// Set file data model again explicitly to enforce that the body of the message is rewritten
+		// and therefore updated.
+		messageModel.setFileData(modelFileData);
+
 		// Create a new message id if the given message id is null
 		messageModel.setApiMessageId(messageId != null ? messageId.toString() : new MessageId().toString());
 		saveLocalModel(messageModel);

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

@@ -210,6 +210,10 @@ public class GroupMessageReceiver implements MessageReceiver<GroupMessageModel>
 			modelFileData.setEncryptionKey(encryptionResult.getKey());
 		}
 
+		// Set file data model again explicitly to enforce that the body of the message is rewritten
+		// and therefore updated.
+		messageModel.setFileData(modelFileData);
+
 		// Create a new message id if the given message id is null
 		messageModel.setApiMessageId(messageId != null ? messageId.toString() : new MessageId().toString());
 		saveLocalModel(messageModel);

+ 11 - 2
app/src/main/java/ch/threema/app/preference/SettingsRateFragment.java

@@ -37,6 +37,7 @@ import ch.threema.app.BuildFlavor;
 import ch.threema.app.R;
 import ch.threema.app.dialogs.GenericAlertDialog;
 import ch.threema.app.dialogs.RateDialog;
+import ch.threema.app.utils.ConfigUtils;
 import ch.threema.base.utils.LoggingUtil;
 
 import static ch.threema.app.ThreemaApplication.getAppContext;
@@ -94,12 +95,20 @@ public class SettingsRateFragment extends ThreemaPreferenceFragment implements R
 
 	@Override
 	public void onNo(String tag, Object data) {
-		onBackPressed();
+		if (!ConfigUtils.isTabletLayout()) {
+			// We only need to navigate back on phones, because on tablets this would leave the
+			// preferences entirely.
+			onBackPressed();
+		}
 	}
 
 	@Override
 	public void onCancel(String tag) {
-		onBackPressed();
+		if (!ConfigUtils.isTabletLayout()) {
+			// We only need to navigate back on phones, because on tablets this would leave the
+			// preferences entirely.
+			onBackPressed();
+		}
 	}
 
 	@Override

+ 1 - 0
app/src/main/java/ch/threema/app/preference/SettingsSummaryFragment.kt

@@ -152,6 +152,7 @@ class SettingsSummaryFragment : ThreemaPreferenceFragment() {
             Pair(getPref("pref_key_chatdisplay"), intArrayOf(R.string.prefs_header_keyboard, R.string.media).reduce()),
             Pair(getPref("pref_key_particular_settings"), intArrayOf(R.string.prefs_image_size, R.string.prefs_auto_download_title, R.string.prefs_storage_mgmt_title).reduce()),
             getPrefOrNull<Preference>("pref_key_calls")?.let { Pair(it, intArrayOf(R.string.prefs_title_voip, R.string.video_calls, R.string.group_calls).reduce()) },
+            Pair(getPref("pref_key_rate"), ""),
             Pair(getPref("pref_key_about"), ""),
             Pair(getPref("pref_key_developers"), "")
     )

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

@@ -186,8 +186,8 @@ public class SynchronizeContactsRoutine implements Runnable {
 
 			if (preferenceService.getEmailSyncHashCode() == emailsHash
 				&& preferenceService.getPhoneNumberSyncHashCode() == phoneNumbersHash
-				&& (preferenceService.getTimeOfLastContactSync() + DateUtils.DAY_IN_MILLIS) > System.currentTimeMillis()) {
-				logger.info("Contacts are unchanged. Not syncing.");
+				&& (preferenceService.getTimeOfLastContactSync() + (DateUtils.HOUR_IN_MILLIS * 23)) > System.currentTimeMillis()) {
+				logger.info("System contacts are unchanged or grace time not yet reached. Not syncing.");
 				success = true;
 				return;
 			}

+ 33 - 22
app/src/main/java/ch/threema/app/routines/UpdateWorkInfoRoutine.java

@@ -25,6 +25,7 @@ import android.content.Context;
 
 import org.slf4j.Logger;
 
+import androidx.annotation.Nullable;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
 import ch.threema.app.exceptions.FileSystemNotPresentException;
@@ -52,11 +53,13 @@ public class UpdateWorkInfoRoutine implements Runnable {
 	private final LicenseService licenseService;
 	private final Context context;
 
-	public UpdateWorkInfoRoutine(Context context,
-	                      APIConnector apiConnector,
-	                      IdentityStoreInterface identityStore,
-	                      DeviceService deviceService,
-	                      LicenseService licenseService) {
+	public UpdateWorkInfoRoutine(
+		Context context,
+		APIConnector apiConnector,
+		IdentityStoreInterface identityStore,
+		DeviceService deviceService,
+		LicenseService licenseService
+	) {
 		this.context = context;
 		this.apiConnector = apiConnector;
 		this.identityStore = identityStore;
@@ -72,8 +75,7 @@ public class UpdateWorkInfoRoutine implements Runnable {
 		}
 
 		if (this.deviceService == null || this.deviceService.isOnline()) {
-
-			logger.debug("update work info");
+			logger.info("Update work info");
 
 			UserCredentials userCredentials = ((LicenseServiceUser) this.licenseService).loadCredentials();
 
@@ -125,29 +127,38 @@ public class UpdateWorkInfoRoutine implements Runnable {
 
 	/**
 	 * start a update in a new thread
-	 * return the new created thread
+	 * @return the new created thread or null if the thread could not be created
 	 */
+	@Nullable
 	public static Thread start() {
-		//try to get all instances
+		UpdateWorkInfoRoutine updateWorkInfoRoutine = create();
+		if (updateWorkInfoRoutine != null) {
+			Thread t = new Thread(updateWorkInfoRoutine);
+			t.start();
+			return t;
+		} else {
+			return null;
+		}
+	}
+
+	@Nullable
+	public static UpdateWorkInfoRoutine create() {
 		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
 
 		if(serviceManager == null) {
 			return null;
 		}
 		try {
-			Thread t = new Thread(new UpdateWorkInfoRoutine(
-					serviceManager.getContext(),
-					serviceManager.getAPIConnector(),
-					serviceManager.getIdentityStore(),
-					serviceManager.getDeviceService(),
-					serviceManager.getLicenseService()
-			));
-			t.start();
-			return t;
-		} catch (FileSystemNotPresentException x) {
-			logger.error("File system not present", x);
+			return new UpdateWorkInfoRoutine(
+				serviceManager.getContext(),
+				serviceManager.getAPIConnector(),
+				serviceManager.getIdentityStore(),
+				serviceManager.getDeviceService(),
+				serviceManager.getLicenseService()
+			);
+		} catch (FileSystemNotPresentException e) {
+			logger.error("File system not present", e);
+			return null;
 		}
-
-		return null;
 	}
 }

+ 136 - 23
app/src/main/java/ch/threema/app/services/AppRestrictionService.java

@@ -38,13 +38,21 @@ import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
 
+import androidx.annotation.AnyThread;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.WorkerThread;
 import ch.threema.app.R;
 import ch.threema.app.ThreemaApplication;
+import ch.threema.app.exceptions.FileSystemNotPresentException;
+import ch.threema.app.managers.ServiceManager;
+import ch.threema.app.routines.UpdateWorkInfoRoutine;
+import ch.threema.app.services.license.LicenseServiceUser;
 import ch.threema.app.services.license.UserCredentials;
+import ch.threema.app.stores.PreferenceStoreInterface;
+import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.RuntimeUtil;
+import ch.threema.app.utils.TestUtil;
 import ch.threema.base.ThreemaException;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.domain.protocol.api.APIConnector;
@@ -85,8 +93,7 @@ public class AppRestrictionService {
 	 */
 	public void storeWorkMDMSettings(@NonNull final WorkMDMSettings settings) {
 		if (!Objects.equals(this.workMDMSettings, settings)) {
-			if (ThreemaApplication.getServiceManager() != null
-					&& ThreemaApplication.getServiceManager().getPreferenceStore() != null) {
+			if (ThreemaApplication.getServiceManager() != null) {
 				logger.debug("Store work mdm settings");
 				ThreemaApplication.getServiceManager().getPreferenceStore()
 						.save(PREFERENCE_KEY, convert(settings), true);
@@ -102,13 +109,15 @@ public class AppRestrictionService {
 	public WorkMDMSettings getWorkMDMSettings() {
 		if (this.workMDMSettings == null) {
 			// Load from preference store
-			if (ThreemaApplication.getServiceManager() != null
-					&& ThreemaApplication.getServiceManager().getPreferenceStore() != null) {
-				JSONObject object = ThreemaApplication.getServiceManager().getPreferenceStore()
-						.getJSONObject(PREFERENCE_KEY, true);
-
-				if (object != null) {
-					this.workMDMSettings = filterWorkMdmSettings(convert(object));
+			if (ThreemaApplication.getServiceManager() != null) {
+				PreferenceStoreInterface preferenceStore = ThreemaApplication.getServiceManager().getPreferenceStore();
+				if (preferenceStore.containsKey(PREFERENCE_KEY, true)) {
+					JSONObject object = preferenceStore.getJSONObject(PREFERENCE_KEY, true);
+					if (object != null) {
+						this.workMDMSettings = filterWorkMdmSettings(convert(object));
+					}
+				} else {
+					logger.warn("No work mdm settings stored");
 				}
 			}
 		}
@@ -117,11 +126,11 @@ public class AppRestrictionService {
 
 	/**
 	 * Get the source of active mdm parameters in text representation.
-	 *
+	 * <p>
 	 * If at least one Threema-MDM parameter and at least one external MDM parameter is active, "me" is returned.
 	 * If at least one Threema-MDM parameter is active, append "m" is returned.
 	 * If at least one external MDM parameter is active, append "e" is returned.
-	 *
+	 * <p>
 	 * (See "Update Work Info" in documentation)
 	 *
 	 * @return the source(s) of active mdm parameters as text, null if no mdm parameters are active
@@ -176,7 +185,7 @@ public class AppRestrictionService {
 	 * @return true if Threema MDM is active
 	 */
 	private boolean hasThreemaMDMRestrictions() {
-		return this.workMDMSettings != null && this.workMDMSettings.parameters != null && this.workMDMSettings.parameters.size() > 0;
+		return this.workMDMSettings != null && this.workMDMSettings.parameters != null && !this.workMDMSettings.parameters.isEmpty();
 	}
 
 	/**
@@ -220,8 +229,9 @@ public class AppRestrictionService {
 	 */
 	public void reload() {
 		if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) {
-			RestrictionsManager restrictionsManager = (RestrictionsManager)
-				ThreemaApplication.getAppContext().getSystemService(Context.RESTRICTIONS_SERVICE);
+			RestrictionsManager restrictionsManager = (RestrictionsManager) ThreemaApplication
+				.getAppContext()
+				.getSystemService(Context.RESTRICTIONS_SERVICE);
 			this.appRestrictions = restrictionsManager.getApplicationRestrictions();
 		}
 
@@ -229,7 +239,11 @@ public class AppRestrictionService {
 			this.appRestrictions = new Bundle();
 		}
 
-		hasExternalMDMRestrictions = this.appRestrictions.size() > 0;
+		hasExternalMDMRestrictions = !this.appRestrictions.isEmpty();
+
+		if (ConfigUtils.isWorkBuild() && hasExternalMDMRestrictions) {
+			updateUserCredentials();
+		}
 
 		WorkMDMSettings settings = this.getWorkMDMSettings();
 
@@ -241,17 +255,13 @@ public class AppRestrictionService {
 				{
 					if (miniMDMSetting.getValue() instanceof Integer) {
 						appRestrictions.putInt(miniMDMSetting.getKey(), (Integer)miniMDMSetting.getValue());
-					}
-					else if (miniMDMSetting.getValue() instanceof Boolean) {
+					} else if (miniMDMSetting.getValue() instanceof Boolean) {
 						appRestrictions.putBoolean(miniMDMSetting.getKey(), (Boolean)miniMDMSetting.getValue());
-					}
-					else if (miniMDMSetting.getValue() instanceof String) {
+					} else if (miniMDMSetting.getValue() instanceof String) {
 						appRestrictions.putString(miniMDMSetting.getKey(), (String)miniMDMSetting.getValue());
-					}
-					else if (miniMDMSetting.getValue() instanceof Long) {
+					} else if (miniMDMSetting.getValue() instanceof Long) {
 						appRestrictions.putLong(miniMDMSetting.getKey(), (Long)miniMDMSetting.getValue());
-					}
-					else if (miniMDMSetting.getValue() instanceof Double) {
+					} else if (miniMDMSetting.getValue() instanceof Double) {
 						appRestrictions.putDouble(miniMDMSetting.getKey(), (Double)miniMDMSetting.getValue());
 					}
 				}
@@ -315,6 +325,109 @@ public class AppRestrictionService {
 		return json;
 	}
 
+	/**
+	 * Update the stored credentials if changed username or password are provided via mdm.
+	 * */
+	private void updateUserCredentials() {
+		ServiceManager serviceManager = ThreemaApplication.getServiceManager();
+		if (serviceManager != null) {
+			Context context = serviceManager.getContext();
+			@Nullable String mdmUsername = getStringRestriction(context.getString(R.string.restriction__license_username));
+			@Nullable String mdmPassword = getStringRestriction(context.getString(R.string.restriction__license_password));
+
+			if (TestUtil.empty(mdmUsername) && TestUtil.empty(mdmPassword)) {
+				logger.debug("No credentials provided via mdm");
+				return;
+			}
+
+			LicenseServiceUser licenseService = getLicenseService(serviceManager);
+			if (licenseService == null) {
+				logger.error("User license service not available");
+				return;
+			}
+
+			UserCredentials currentCredentials = licenseService.loadCredentials();
+			UserCredentials mergedCredentials = mergeCurrentAndMdmCredentials(
+				currentCredentials,
+				mdmUsername,
+				mdmPassword
+			);
+
+			if (mergedCredentials != null && !mergedCredentials.equals(currentCredentials)) {
+				logger.info("Update changed work credentials");
+				licenseService.saveCredentials(mergedCredentials);
+
+				if (serviceManager.getUserService().hasIdentity()) {
+					updateWorkInfo(licenseService);
+				}
+			}
+		} else {
+			logger.warn("Could not update mdm credentials. Service manager not available");
+		}
+	}
+
+	/**
+	 * Updates the work info if the stored credentials are valid.
+	 */
+	@AnyThread
+	private void updateWorkInfo(@NonNull LicenseServiceUser licenseService) {
+		logger.info("Schedule work info update");
+		new Thread(() -> {
+			String error = licenseService.validate(false);
+			if (error == null) {
+				UpdateWorkInfoRoutine routine = UpdateWorkInfoRoutine.create();
+				if (routine != null) {
+					routine.run();
+				}
+			} else {
+				logger.info("Credentials could not be validated, do not update work info: {}", error);
+			}
+		}).start();
+	}
+
+	@Nullable
+	private LicenseServiceUser getLicenseService(@NonNull ServiceManager serviceManager) {
+		try {
+			return (LicenseServiceUser) serviceManager.getLicenseService();
+		} catch (ClassCastException | FileSystemNotPresentException e) {
+			logger.error("Could not get license service", e);
+			return null;
+		}
+	}
+
+	@Nullable
+	private UserCredentials mergeCurrentAndMdmCredentials(
+		@Nullable UserCredentials currentCredentials,
+		@Nullable String mdmUsername,
+		@Nullable String mdmPassword
+	) {
+		@Nullable String currentUsername = currentCredentials != null ? currentCredentials.username : null;
+		@Nullable String currentPassword = currentCredentials != null ? currentCredentials.password : null;
+
+		String username = !TestUtil.empty(mdmUsername) && !mdmUsername.equals(currentUsername)
+			? mdmUsername
+			: currentUsername;
+
+		String password = !TestUtil.empty(mdmPassword) && !mdmPassword.equals(currentPassword)
+			? mdmPassword
+			: currentPassword;
+
+		return username != null && password != null
+			? new UserCredentials(username, password)
+			: null;
+	}
+
+	@Nullable
+	private String getStringRestriction(String key) {
+		if (appRestrictions != null && appRestrictions.containsKey(key)) {
+			String value = appRestrictions.getString(key);
+			if (!TestUtil.empty(value)) {
+				return value;
+			}
+		}
+		return null;
+	}
+
 	/***********************************************************************************************
 	 * Singleton Stuff
 	 ***********************************************************************************************/

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

@@ -191,9 +191,7 @@ final public class AvatarCacheServiceImpl implements AvatarCacheService {
 		synchronized (this.groupAvatarStates) {
 			groupAvatarStates.clear();
 		}
-		RuntimeUtil.runOnUiThread(() -> {
-            Glide.get(context).clearMemory();
-        });
+		RuntimeUtil.runOnUiThread(() -> Glide.get(context).clearMemory());
 	}
 
 	@WorkerThread

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

@@ -30,7 +30,6 @@ import org.slf4j.Logger;
 
 import java.util.ArrayList;
 import java.util.Collections;
-import java.util.Comparator;
 import java.util.Date;
 import java.util.HashSet;
 import java.util.List;
@@ -40,7 +39,6 @@ import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import ch.threema.app.collections.Functional;
 import ch.threema.app.collections.IPredicateNonNull;
-import ch.threema.app.listeners.ConversationListener;
 import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.messagereceiver.ContactMessageReceiver;
 import ch.threema.app.messagereceiver.DistributionListMessageReceiver;
@@ -863,7 +861,7 @@ public class ConversationServiceImpl implements ConversationService {
 			// Update read/unread state if necessary
 			if(model.getReceiver() != null && MessageUtil.isUnread(model.getLatestMessage())) {
 				model.setUnreadCount(model.getReceiver().getUnreadMessagesCount());
-				conversationTagService.unTag(model, unreadTagModel);
+				conversationTagService.removeTagAndNotify(model, unreadTagModel);
 			} else {
 				if (model.getLatestMessage() == null) {
 					// If there are no messages, mark the conversation as read
@@ -1315,13 +1313,7 @@ public class ConversationServiceImpl implements ConversationService {
 		}
 
 		if (newestMessage == null) {
-			if (conversationModel.isGroupConversation() || conversationModel.isDistributionListConversation()) {
-				// do not remove groups and distribution list conversations from cache as they should still be accessible in message list
-				conversationModel.setMessageCount(0);
-			} else {
-				// remove model from cache completely
-				this.removeFromCache(conversationModel);
-			}
+			conversationModel.setMessageCount(0);
 		}
 	}
 

+ 14 - 13
app/src/main/java/ch/threema/app/services/ConversationTagService.java

@@ -23,7 +23,6 @@ package ch.threema.app.services;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
-import androidx.annotation.WorkerThread;
 
 import java.util.List;
 
@@ -32,10 +31,6 @@ import ch.threema.storage.models.ConversationTagModel;
 import ch.threema.storage.models.TagModel;
 
 public interface ConversationTagService {
-	/**
-	 * Return all available {@link TagModel}
-	 */
-	List<TagModel> getTagModels();
 
 	/**
 	 * Select a {@link TagModel} by the key
@@ -43,19 +38,19 @@ public interface ConversationTagService {
 	@Nullable TagModel getTagModel(@NonNull String tagKey);
 
 	/**
-	 * Return all tags for the specified  {@link ConversationModel}.
+	 * Tag the {@link ConversationModel} with the given {@link TagModel}
 	 */
-	List<ConversationTagModel> getTagsForConversation(@NonNull final ConversationModel conversation);
+	void addTagAndNotify(@Nullable ConversationModel conversation, @Nullable TagModel tagModel);
 
 	/**
-	 * Tag the {@link ConversationModel} with the given {@link TagModel}
+	 * Untag the {@link ConversationModel} with the given {@link TagModel}
 	 */
-	boolean tag(@Nullable ConversationModel conversation, @Nullable TagModel tagModel);
+	void removeTagAndNotify(@Nullable ConversationModel conversation, @Nullable TagModel tagModel);
 
 	/**
-	 * Untag the {@link ConversationModel} with the given {@link TagModel}
+	 * Remove the given tag of the conversation with the provided conversation uid.
 	 */
-	@WorkerThread boolean unTag(@Nullable ConversationModel conversation, @Nullable TagModel tagModel);
+	void removeTag(@NonNull String conversationUid, @NonNull TagModel tagModel);
 
 	/**
 	 * Toggle the {@link TagModel} of the {@link ConversationModel}
@@ -73,15 +68,21 @@ public interface ConversationTagService {
 	void removeAll(@Nullable ConversationModel conversation);
 
 	/**
-	 * Remove all tags linked with the given {@link TagModel}
+	 * Remove all tags linked with the given conversation uid
 	 */
-	void removeAll(@Nullable TagModel tagModel);
+	void removeAll(@NonNull String conversationUid);
 
 	/**
 	 * Get all tags regardless of type
 	 */
 	List<ConversationTagModel> getAll();
 
+	/**
+	 * Get all conversation uids that are tagged with the provided type.
+	 */
+	@NonNull
+	List<String> getConversationUidsByTag(@NonNull TagModel tagModel);
+
 	/**
 	 * Return the number of conversations with the provided tag
 	 * @param tagModel tag

+ 22 - 28
app/src/main/java/ch/threema/app/services/ConversationTagServiceImpl.java

@@ -23,7 +23,6 @@ package ch.threema.app.services;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
-import androidx.annotation.WorkerThread;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -58,11 +57,6 @@ public class ConversationTagServiceImpl implements ConversationTagService {
 		this.databaseService = databaseService;
 	}
 
-	@Override
-	public List<TagModel> getTagModels() {
-		return this.tagModels;
-	}
-
 	@Override
 	@Nullable
 	public TagModel getTagModel(@NonNull final String tagKey) {
@@ -75,36 +69,31 @@ public class ConversationTagServiceImpl implements ConversationTagService {
 	}
 
 	@Override
-	public List<ConversationTagModel> getTagsForConversation(@NonNull final ConversationModel conversation) {
-		return this.databaseService.getConversationTagFactory()
-			.getByConversationUid(conversation.getUid());
-	}
-
-	@Override
-	public boolean tag(@Nullable ConversationModel conversation, @Nullable TagModel tagModel) {
+	public void addTagAndNotify(@Nullable ConversationModel conversation, @Nullable TagModel tagModel) {
 		if (conversation != null && tagModel != null) {
 			if (!this.isTaggedWith(conversation, tagModel)) {
 				this.databaseService.getConversationTagFactory()
 					.create(new ConversationTagModel(conversation.getUid(), tagModel.getTag()));
 				this.fireOnModifiedConversation(conversation);
-				return true;
 			}
 		}
-		return false;
 	}
 
 	@Override
-	@WorkerThread
-	public boolean unTag(@Nullable ConversationModel conversation, @Nullable TagModel tagModel) {
+	public void removeTagAndNotify(@Nullable ConversationModel conversation, @Nullable TagModel tagModel) {
 		if (conversation != null && tagModel != null) {
 			if (this.isTaggedWith(conversation, tagModel)) {
-				this.databaseService.getConversationTagFactory()
-					.deleteByConversationUidAndTag(conversation.getUid(), tagModel.getTag());
+				this.removeTag(conversation.getUid(), tagModel);
 				this.fireOnModifiedConversation(conversation);
-				return true;
 			}
 		}
-		return false;
+	}
+
+	@Override
+	public void removeTag(@NonNull String conversationUid, @NonNull TagModel tagModel) {
+		this.databaseService.getConversationTagFactory().deleteByConversationUidAndTag(
+			conversationUid, tagModel.getTag()
+		);
 	}
 
 	@Override
@@ -142,17 +131,14 @@ public class ConversationTagServiceImpl implements ConversationTagService {
 	@Override
 	public void removeAll(@Nullable ConversationModel conversation) {
 		if (conversation != null) {
-			this.databaseService.getConversationTagFactory()
-				.deleteByConversationUid(conversation.getUid());
+			removeAll(conversation.getUid());
 		}
 	}
 
 	@Override
-	public void removeAll(@Nullable TagModel tagModel) {
-		if (tagModel != null) {
-			this.databaseService.getConversationTagFactory()
-				.deleteByConversationTag(tagModel.getTag());
-		}
+	public void removeAll(@NonNull String conversationUid) {
+		this.databaseService.getConversationTagFactory()
+			.deleteByConversationUid(conversationUid);
 	}
 
 	@Override
@@ -160,6 +146,14 @@ public class ConversationTagServiceImpl implements ConversationTagService {
 		return this.databaseService.getConversationTagFactory().getAll();
 	}
 
+	@Override
+	@NonNull
+	public List<String> getConversationUidsByTag(@NonNull TagModel tagModel) {
+		return this.databaseService.getConversationTagFactory().getAllConversationUidsByTag(
+			tagModel.getTag()
+		);
+	}
+
 	@Override
 	public long getCount(@NonNull TagModel tagModel) {
 		return this.databaseService.getConversationTagFactory().countByTag(tagModel.getTag());

+ 11 - 3
app/src/main/java/ch/threema/app/services/DistributionListServiceImpl.java

@@ -45,6 +45,7 @@ import ch.threema.app.managers.ListenerManager;
 import ch.threema.app.managers.ServiceManager;
 import ch.threema.app.messagereceiver.DistributionListMessageReceiver;
 import ch.threema.app.utils.ColorUtil;
+import ch.threema.app.utils.ConversationUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.ShortcutUtil;
 import ch.threema.base.ThreemaException;
@@ -63,18 +64,20 @@ public class DistributionListServiceImpl implements DistributionListService {
 	private final AvatarCacheService avatarCacheService;
 	private final DatabaseServiceNew databaseServiceNew;
 	private final ContactService contactService;
+	private final @NonNull ConversationTagService conversationTagService;
 
 	public DistributionListServiceImpl(
 			Context context,
 			AvatarCacheService avatarCacheService,
 			DatabaseServiceNew databaseServiceNew,
-			ContactService contactService
-	)
-	{
+			ContactService contactService,
+			@NonNull ConversationTagService conversationTagService
+	) {
 		this.context = context;
 		this.avatarCacheService = avatarCacheService;
 		this.databaseServiceNew = databaseServiceNew;
 		this.contactService = contactService;
+		this.conversationTagService = conversationTagService;
 	}
 
 	@Override
@@ -230,6 +233,11 @@ public class DistributionListServiceImpl implements DistributionListService {
 		// Remove conversation
 		conversationService.removeFromCache(distributionListModel);
 
+		// Remove conversation tags
+		conversationTagService.removeAll(
+			ConversationUtil.getDistributionListConversationUid(distributionListModel.getId())
+		);
+
 		// Delete distribution list fully from database
 		this.databaseServiceNew.getDistributionListModelFactory().delete(distributionListModel);
 

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

@@ -74,6 +74,7 @@ import ch.threema.app.tasks.OutgoingGroupSyncTask;
 import ch.threema.app.utils.AppRestrictionUtil;
 import ch.threema.app.utils.BitmapUtil;
 import ch.threema.app.utils.ColorUtil;
+import ch.threema.app.utils.ConversationUtil;
 import ch.threema.app.utils.NameUtil;
 import ch.threema.app.utils.ShortcutUtil;
 import ch.threema.app.utils.TestUtil;
@@ -118,6 +119,7 @@ public class GroupServiceImpl implements GroupService {
 	private final @NonNull WallpaperService wallpaperService;
 	private final @NonNull DeadlineListService mutedChatsListService, hiddenChatsListService;
 	private final @NonNull RingtoneService ringtoneService;
+	private final @NonNull ConversationTagService conversationTagService;
 
 	private final SparseArray<Map<String, Integer>> groupMemberColorCache;
 	private final SparseArray<GroupModel> groupModelCache;
@@ -143,6 +145,7 @@ public class GroupServiceImpl implements GroupService {
 		@NonNull DeadlineListService mutedChatsListService,
 		@NonNull DeadlineListService hiddenChatsListService,
 		@NonNull RingtoneService ringtoneService,
+		@NonNull ConversationTagService conversationTagService,
 		@NonNull ServiceManager serviceManager
 	) {
 		this.context = context;
@@ -156,6 +159,7 @@ public class GroupServiceImpl implements GroupService {
 		this.mutedChatsListService = mutedChatsListService;
 		this.hiddenChatsListService = hiddenChatsListService;
 		this.ringtoneService = ringtoneService;
+		this.conversationTagService = conversationTagService;
 		this.serviceManager = serviceManager;
 
 		this.groupModelCache = cacheService.getGroupModelCache();
@@ -332,6 +336,7 @@ public class GroupServiceImpl implements GroupService {
 		this.hiddenChatsListService.remove(uniqueIdString);
 		ShortcutUtil.deleteShareTargetShortcut(uniqueIdString);
 		ShortcutUtil.deletePinnedShortcut(uniqueIdString);
+		this.conversationTagService.removeAll(ConversationUtil.getGroupConversationUid(groupModel.getId()));
 
 		// Update model
 		groupModel.setDeleted(true);

+ 2 - 6
app/src/main/java/ch/threema/app/services/MessageServiceImpl.java

@@ -26,7 +26,6 @@ import static ch.threema.app.ThreemaApplication.MAX_BLOB_SIZE_MB;
 import static ch.threema.app.services.PreferenceService.ImageScale_DEFAULT;
 import static ch.threema.app.ui.MediaItem.TIME_UNDEFINED;
 import static ch.threema.app.ui.MediaItem.TYPE_FILE;
-import static ch.threema.app.ui.MediaItem.TYPE_GIF;
 import static ch.threema.app.ui.MediaItem.TYPE_IMAGE;
 import static ch.threema.app.ui.MediaItem.TYPE_IMAGE_ANIMATED;
 import static ch.threema.app.ui.MediaItem.TYPE_IMAGE_CAM;
@@ -3817,8 +3816,6 @@ public class MessageServiceImpl implements MessageService {
 			case TYPE_IMAGE_ANIMATED:
 				metaData.put(FileDataModel.METADATA_KEY_ANIMATED, true);
 				// fallthrough
-			case TYPE_GIF:
-				// fallthrough
 			case TYPE_VOICEMESSAGE:
 				// fallthrough
 			case TYPE_FILE:
@@ -3928,7 +3925,7 @@ public class MessageServiceImpl implements MessageService {
 						mediaItem.getExifFlip()), mediaItem.getRotation(), mediaItem.getFlip());
 				}
 				break;
-			case TYPE_GIF:
+			case TYPE_IMAGE_ANIMATED:
 				fileDataModel.setThumbnailMimeType(MimeUtil.MIME_TYPE_IMAGE_PNG);
 				thumbnailBitmap = IconUtil.getThumbnailFromUri(context, mediaItem.getUri(), THUMBNAIL_SIZE_PX, fileDataModel.getMimeType(), true);
 				break;
@@ -4234,7 +4231,7 @@ public class MessageServiceImpl implements MessageService {
 		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
 			// non-animated images are being sent as png files
 			// we should fix the mime type before creating a local message model in order not to confuse the chat adapter
-			if (ConfigUtils.isSupportedAnimatedImageFormat(mimeType)
+			if (MimeUtil.isAnimatedImageFormat(mimeType)
 				&& mediaItem.getType() != TYPE_IMAGE_ANIMATED
 				&& mediaItem.getType() != TYPE_FILE
 				&& mediaItem.getImageScale() != PreferenceService.ImageScale_SEND_AS_FILE) {
@@ -4250,7 +4247,6 @@ public class MessageServiceImpl implements MessageService {
 				filename = FileUtil.getDefaultFilename(mimeType); // the internal temporary file name is of no use to the recipient
 				renderingType = FileData.RENDERING_MEDIA;
 				break;
-			case TYPE_GIF:
 			case TYPE_IMAGE_ANIMATED:
 				if (renderingType == FileData.RENDERING_DEFAULT) {
 					// do not override stickers

+ 13 - 17
app/src/main/java/ch/threema/app/services/license/LicenseService.java

@@ -21,6 +21,9 @@
 
 package ch.threema.app.services.license;
 
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
+
 public interface LicenseService<T extends LicenseService.Credentials> {
 	/**
 	 * Holder of the credential values
@@ -28,38 +31,31 @@ public interface LicenseService<T extends LicenseService.Credentials> {
 	interface Credentials{}
 
 	/**
-	 * validate by credentials (do not throw any exception)
-	 * save on success
+	 * Validate by credentials
+	 * On success, the credentials will be saved.
 	 * @param credentials holder of the credential values
-	 * @return null or a error message
+	 * @return `null` for success or an error message if validation failed
 	 */
+	@Nullable
+	@WorkerThread
 	String validate(T credentials);
 
 	/**
-	 * validate by credentials
-	 * save on success
-	 * @param credentials holder of the credential values
-	 * @param allowException
-	 * @return null or a error message
-	 */
-	String validate(T credentials, boolean allowException);
-
-	/**
-	 * validate by saved credentials
-	 * @param allowException
-	 * @return null or a error message
+	 * Validate by saved credentials
+	 * @param allowException If true, general exceptions will be ignored
+	 * @return `null` for success or an error message if validation failed
 	 */
+	@Nullable
+	@WorkerThread
 	String validate(boolean allowException);
 
 	/**
 	 * check if any credentials are saved
-	 * @return
 	 */
 	boolean hasCredentials();
 
 	/**
 	 * check if a validate check was successfully
-	 * @return
 	 */
 	boolean isLicensed();
 

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

@@ -42,7 +42,7 @@ public class LicenseServiceSerial extends LicenseServiceThreema<SerialCredential
 	}
 
 	@Override
-	protected void saveCredentials(SerialCredentials credentials) {
+	public void saveCredentials(SerialCredentials credentials) {
 		this.preferenceService.setSerialNumber(credentials.licenseKey);
 	}
 

+ 63 - 17
app/src/main/java/ch/threema/app/services/license/LicenseServiceThreema.java

@@ -20,12 +20,20 @@
  */
 
 package ch.threema.app.services.license;
+import org.slf4j.Logger;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.utils.TestUtil;
+import ch.threema.base.utils.LoggingUtil;
 import ch.threema.domain.onprem.UnauthorizedFetchException;
 import ch.threema.domain.protocol.api.APIConnector;
 
 abstract public class  LicenseServiceThreema<T extends LicenseService.Credentials>  implements LicenseService<T> {
+	private static final Logger logger = LoggingUtil.getThreemaLogger("LicenseServiceThreema");
+
 	protected final APIConnector apiConnector;
 	protected final PreferenceService preferenceService;
 	private final String deviceId;
@@ -49,15 +57,37 @@ abstract public class  LicenseServiceThreema<T extends LicenseService.Credential
 	}
 
 	@Override
+	@Nullable
+	@WorkerThread
 	public String validate(T credentials) {
 		return this.validate(credentials, false);
 	}
 
+	@Override
+	@Nullable
+	@WorkerThread
+	public String validate(boolean allowException) {
+		T credentials = this.loadCredentials();
+		if(credentials != null) {
+			return this.validate(credentials, allowException);
+		}
+		return "no license";
+	}
+
 	/**
-	 * Validate the license credentials and check for updates.
+	 * Validate the license credentials. If the credentials validate, the licensed state
+	 * will be set to `true` and saved. In case of success also an update message and update url
+	 * (if available) are retrieved.
+	 * If the validation yields an invalid result, the licensed state will be set to `false`.
+	 *
+	 * @param credentials holder of the credential values
+	 * @param allowException If true, general exceptions will be ignored
+	 * @return In case of success `null` is returned. If validation failed an error message will be
+	 *         returned
 	 */
-	@Override
-	public String validate(T credentials, boolean allowException) {
+	@Nullable
+	@WorkerThread
+	private String validate(T credentials, boolean allowException) {
 		APIConnector.CheckLicenseResult result;
 		try {
 			result = this.checkLicense(credentials, deviceId);
@@ -69,8 +99,7 @@ abstract public class  LicenseServiceThreema<T extends LicenseService.Credential
 				this.saveCredentials(credentials);
 				this.preferenceService.setLicensedStatus(true);
 				this.isLicensed = true;
-			}
-			else {
+			} else {
 				this.preferenceService.setLicensedStatus(false);
 				this.isLicensed = false;
 				return result.error;
@@ -78,16 +107,34 @@ abstract public class  LicenseServiceThreema<T extends LicenseService.Credential
 		} catch (UnauthorizedFetchException e) {
 			// Treat unauthorized OPPF fetch like (temporarily) bad license
 			this.isLicensed = false;
-			return e.getMessage();
+			return getExceptionMessageOrDefault(
+				e,
+				"Unauthorized"
+			);
 		} catch (Exception e) {
 			if(!allowException) {
-				return e.getMessage();
+				return getExceptionMessageOrDefault(
+					e,
+					"Error during validation"
+				);
+			} else {
+				logger.warn("Could not validate credentials", e);
 			}
 		}
-
 		return null;
 	}
 
+	@NonNull
+	private String getExceptionMessageOrDefault(
+		@NonNull Throwable t,
+		@NonNull String defaultMessage
+	) {
+		String message = t.getMessage();
+		return message == null
+			? defaultMessage
+			: message;
+	}
+
 	public String getUpdateMessage() {
 		return updateMessage;
 	}
@@ -109,15 +156,14 @@ abstract public class  LicenseServiceThreema<T extends LicenseService.Credential
 		return this.isLicensed;
 	}
 
-	@Override
-	public String validate(boolean allowException) {
-		T credentials = this.loadCredentials();
-		if(credentials != null) {
-			return this.validate(credentials, allowException);
-		}
-		return "no license";
-	}
+	/**
+	 * Save the credentials. Note that the credentials will override existing credentials, even if
+	 * the new credentials are invalid.
+	 *
+	 * @param credentials The credentials to save
+	 */
+	abstract public void saveCredentials(T credentials);
 
+	@WorkerThread
 	abstract protected APIConnector.CheckLicenseResult checkLicense(T credentials, String deviceId) throws Exception;
-	abstract protected void saveCredentials(T credentials);
 }

+ 3 - 1
app/src/main/java/ch/threema/app/services/license/LicenseServiceUser.java

@@ -22,6 +22,7 @@
 package ch.threema.app.services.license;
 
 import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
 import ch.threema.app.services.PreferenceService;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.domain.protocol.api.APIConnector;
@@ -39,12 +40,13 @@ public class LicenseServiceUser extends LicenseServiceThreema<UserCredentials> {
 	}
 
 	@Override
+	@WorkerThread
 	protected APIConnector.CheckLicenseResult checkLicense(UserCredentials credentials, String deviceId) throws Exception {
 		return this.apiConnector.checkLicense(credentials.username, credentials.password, deviceId);
 	}
 
 	@Override
-	protected void saveCredentials(UserCredentials credentials) {
+	public void saveCredentials(UserCredentials credentials) {
 		this.preferenceService.setLicenseUsername(credentials.username);
 		this.preferenceService.setLicensePassword(credentials.password);
 	}

+ 21 - 1
app/src/main/java/ch/threema/app/services/license/UserCredentials.java

@@ -21,19 +21,39 @@
 
 package ch.threema.app.services.license;
 
+import java.util.Objects;
+
+import androidx.annotation.NonNull;
+
 /**
  * save the username and password
  */
 public class UserCredentials implements LicenseService.Credentials {
+	@NonNull
 	public final String username;
+	@NonNull
 	public final String password;
 
-	public UserCredentials(String username, String password) {
+	public UserCredentials(@NonNull String username, @NonNull String password) {
 		this.username = username;
 		this.password = password;
 	}
 
 	@Override
+	public boolean equals(Object o) {
+		if (this == o) return true;
+		if (o == null || getClass() != o.getClass()) return false;
+		UserCredentials that = (UserCredentials) o;
+		return Objects.equals(username, that.username) && Objects.equals(password, that.password);
+	}
+
+	@Override
+	public int hashCode() {
+		return Objects.hash(username, password);
+	}
+
+	@Override
+	@NonNull
 	public String toString() {
 		return this.username;
 	}

+ 46 - 32
app/src/main/java/ch/threema/app/services/messageplayer/AnimatedImageDrawableMessagePlayer.java

@@ -23,24 +23,31 @@ package ch.threema.app.services.messageplayer;
 
 import android.content.Context;
 import android.content.Intent;
-import android.graphics.drawable.AnimatedImageDrawable;
+import android.graphics.drawable.Animatable;
 import android.graphics.drawable.Drawable;
-import android.net.Uri;
-import android.os.Build;
 import android.widget.ImageView;
 
-import androidx.annotation.RequiresApi;
+import com.bumptech.glide.Glide;
+import com.bumptech.glide.load.DataSource;
+import com.bumptech.glide.load.engine.GlideException;
+import com.bumptech.glide.load.resource.gif.GifDrawable;
+import com.bumptech.glide.request.RequestListener;
+import com.bumptech.glide.request.target.Target;
 
 import org.slf4j.Logger;
 
 import java.io.File;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import ch.threema.app.R;
 import ch.threema.app.activities.MediaViewerActivity;
 import ch.threema.app.activities.ThreemaActivity;
 import ch.threema.app.messagereceiver.MessageReceiver;
 import ch.threema.app.services.FileService;
 import ch.threema.app.services.MessageService;
 import ch.threema.app.services.PreferenceService;
+import ch.threema.app.utils.ConfigUtils;
 import ch.threema.app.utils.IntentDataUtil;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
@@ -53,7 +60,6 @@ import ch.threema.storage.models.data.media.MediaMessageDataInterface;
  * A message player for animated image formats supported by AnimatedImageDrawable
  * Currently, this is limited to WebP
  */
-@RequiresApi(Build.VERSION_CODES.P)
 public class AnimatedImageDrawableMessagePlayer extends MessagePlayer {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("AnimatedImageDrawableMessagePlayer");
 
@@ -107,22 +113,30 @@ public class AnimatedImageDrawableMessagePlayer extends MessagePlayer {
 	public void autoPlay(final File decryptedFile) {
 		logger.debug("autoPlay(decryptedFile)");
 
-		if (this.imageContainer != null && this.currentActivityRef != null && this.currentActivityRef.get() != null) {
-			if (this.imageDrawable != null && this.imageDrawable instanceof AnimatedImageDrawable) {
-				((AnimatedImageDrawable) this.imageDrawable).stop();
-			}
+		if (this.imageContainer != null && this.currentActivityRef != null && this.currentActivityRef.get() != null && getMessageModel() != null) {
+			this.makePause(SOURCE_UNDEFINED);
 
-			final Uri uri = Uri.parse(decryptedFile.getPath());
-			this.imageDrawable = Drawable.createFromPath(uri.getPath());
+			final String mimeType = getMessageModel().getFileData().getMimeType();
 
-			RuntimeUtil.runOnUiThread(() -> {
-				if (imageDrawable != null) {
-					imageContainer.setImageDrawable(imageDrawable);
-					if (imageDrawable instanceof AnimatedImageDrawable && preferenceService.isAnimationAutoplay()) {
-						((AnimatedImageDrawable) imageDrawable).start();
-					}
-				}
-			});
+			if (ConfigUtils.isDisplayableAnimatedImageFormat(mimeType)) {
+				Glide.with(getContext())
+					.load(new File(decryptedFile.getPath()))
+					.optionalFitCenter()
+					.error(R.drawable.ic_image_outline)
+					.addListener(new RequestListener<>() {
+						@Override
+						public boolean onLoadFailed(@Nullable GlideException e, @Nullable Object model, @NonNull Target<Drawable> target, boolean isFirstResource) {
+							return false;
+						}
+
+						@Override
+						public boolean onResourceReady(@NonNull Drawable resource, @NonNull Object model, Target<Drawable> target, @NonNull DataSource dataSource, boolean isFirstResource) {
+							imageDrawable = resource;
+							return false;
+						}
+					})
+					.into(imageContainer);
+			}
 		}
 	}
 
@@ -153,11 +167,10 @@ public class AnimatedImageDrawableMessagePlayer extends MessagePlayer {
 	@Override
 	protected void makePause(int source) {
 		logger.debug("makePause");
-		if (this.imageContainer != null) {
-			if (this.imageDrawable != null && imageDrawable instanceof AnimatedImageDrawable) {
-				AnimatedImageDrawable animatedImageDrawable = (AnimatedImageDrawable) imageDrawable;
-				if (animatedImageDrawable.isRunning()) {
-					animatedImageDrawable.stop();
+		if (this.imageContainer != null && this.imageDrawable != null) {
+			if (imageDrawable instanceof Animatable) {
+				if (((Animatable) imageDrawable).isRunning()) {
+					((Animatable) this.imageDrawable).stop();
 				}
 			}
 		}
@@ -166,11 +179,10 @@ public class AnimatedImageDrawableMessagePlayer extends MessagePlayer {
 	@Override
 	protected void makeResume(int source) {
 		logger.debug("makeResume: " + getMessageModel().getId());
-		if (this.imageContainer != null) {
-			if (this.imageDrawable != null && imageDrawable instanceof AnimatedImageDrawable) {
-				AnimatedImageDrawable animatedImageDrawable = (AnimatedImageDrawable) imageDrawable;
-				if (!animatedImageDrawable.isRunning()) {
-					animatedImageDrawable.start();
+		if (this.imageContainer != null && this.imageDrawable != null) {
+			if (imageDrawable instanceof Animatable) {
+				if (!((Animatable) imageDrawable).isRunning()) {
+					((Animatable) this.imageDrawable).start();
 				}
 			}
 		}
@@ -195,10 +207,12 @@ public class AnimatedImageDrawableMessagePlayer extends MessagePlayer {
 		super.removeListeners();
 		logger.debug("removeListeners");
 
-		// release webp players if item comes out of view
+		// release animated image players if item comes out of view
 		if (this.imageDrawable != null) {
-			if (imageDrawable instanceof AnimatedImageDrawable) {
-				((AnimatedImageDrawable) this.imageDrawable).stop();
+			if (imageDrawable instanceof Animatable) {
+				if (((Animatable) imageDrawable).isRunning()) {
+					((Animatable) this.imageDrawable).stop();
+				}
 			}
 			this.imageDrawable = null;
 		}

+ 0 - 198
app/src/main/java/ch/threema/app/services/messageplayer/GifMessagePlayer.java

@@ -1,198 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2016-2024 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.services.messageplayer;
-
-import android.content.Context;
-import android.content.Intent;
-import android.net.Uri;
-import android.widget.ImageView;
-
-import org.slf4j.Logger;
-
-import java.io.File;
-import java.io.IOException;
-
-import ch.threema.app.activities.MediaViewerActivity;
-import ch.threema.app.activities.ThreemaActivity;
-import ch.threema.app.messagereceiver.MessageReceiver;
-import ch.threema.app.services.FileService;
-import ch.threema.app.services.MessageService;
-import ch.threema.app.services.PreferenceService;
-import ch.threema.app.utils.ImageViewUtil;
-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.storage.models.AbstractMessageModel;
-import ch.threema.storage.models.data.media.FileDataModel;
-import ch.threema.storage.models.data.media.MediaMessageDataInterface;
-import pl.droidsonroids.gif.GifDrawable;
-
-public class GifMessagePlayer extends MessagePlayer {
-	private static final Logger logger = LoggingUtil.getThreemaLogger("GifMessagePlayer");
-
-	private final PreferenceService preferenceService;
-	private GifDrawable gifDrawable;
-	private ImageView imageContainer;
-
-	protected GifMessagePlayer(Context context,
-							   MessageService messageService,
-							   FileService fileService,
-							   PreferenceService preferenceService,
-							   MessageReceiver messageReceiver,
-							   AbstractMessageModel messageModel) {
-		super(context, messageService, fileService, messageReceiver, messageModel);
-		this.preferenceService = preferenceService;
-	}
-
-	public GifMessagePlayer attachContainer(ImageView container) {
-		this.imageContainer = container;
-		return this;
-	}
-
-	@Override
-	public MediaMessageDataInterface getData() {
-		return this.getMessageModel().getFileData();
-	}
-
-	@Override
-	protected AbstractMessageModel setData(MediaMessageDataInterface data) {
-		AbstractMessageModel messageModel = this.getMessageModel();
-		messageModel.setFileData((FileDataModel) data);
-		return messageModel;
-	}
-
-	@Override
-	protected void open(final File decryptedFile) {
-		logger.debug("open(decryptedFile)");
-		if (this.currentActivityRef != null && this.currentActivityRef.get() != null && this.isReceiverMatch(this.currentMessageReceiver)) {
-			final String mimeType = getMessageModel().getFileData().getMimeType();
-
-			if (!TestUtil.empty(mimeType) && decryptedFile.exists()) {
-				if (preferenceService.isAnimationAutoplay()) {
-					autoPlay(decryptedFile);
-				} else {
-					openInExternalPlayer();
-				}
-			}
-		}
-	}
-
-	public void autoPlay(final File decryptedFile) {
-		logger.debug("autoPlay(decryptedFile)");
-
-		if (this.imageContainer != null && this.currentActivityRef != null && this.currentActivityRef.get() != null) {
-			if (this.gifDrawable != null && !gifDrawable.isRecycled()) {
-				this.gifDrawable.stop();
-			}
-
-			final Uri uri = Uri.parse(decryptedFile.getPath());
-			try {
-				this.gifDrawable = new GifDrawable(uri.getPath());
-				this.gifDrawable.setCornerRadius(ImageViewUtil.getCornerRadius(getContext()));
-			} catch (IOException e) {
-				logger.error("I/O Exception", e);
-				return;
-			}
-
-			RuntimeUtil.runOnUiThread(() -> {
-				if (gifDrawable != null && !gifDrawable.isRecycled()) {
-					imageContainer.setImageDrawable(gifDrawable);
-					if (preferenceService.isAnimationAutoplay()) {
-						gifDrawable.start();
-					}
-				}
-			});
-		}
-	}
-
-	@Override
-	public boolean open() {
-		logger.debug("open");
-
-		return super.open();
-	}
-
-	public boolean autoPlay() {
-		logger.debug("autoPlay");
-
-		return super.open(true);
-	}
-
-	public void openInExternalPlayer() {
-		RuntimeUtil.runOnUiThread(() -> {
-			if (currentActivityRef != null && currentActivityRef.get() != null && this.isReceiverMatch(currentMessageReceiver)) {
-				Intent intent = new Intent(getContext(), MediaViewerActivity.class);
-				IntentDataUtil.append(getMessageModel(), intent);
-				intent.putExtra(MediaViewerActivity.EXTRA_ID_REVERSE_ORDER, true);
-				currentActivityRef.get().startActivityForResult(intent, ThreemaActivity.ACTIVITY_ID_MEDIA_VIEWER);
-			}
-		});
-	}
-
-	@Override
-	protected void makePause(int source) {
-		logger.debug("makePause");
-		if (this.imageContainer != null) {
-			if(this.gifDrawable != null && this.gifDrawable.isPlaying() && ! gifDrawable.isRecycled()) {
-				this.gifDrawable.pause();
-			}
-		}
-	}
-
-	@Override
-	protected void makeResume(int source) {
-		logger.debug("makeResume: " + getMessageModel().getId());
-		if (this.imageContainer != null) {
-			if(this.gifDrawable != null && !this.gifDrawable.isPlaying() && !gifDrawable.isRecycled()) {
-				this.gifDrawable.start();
-			}
-		}
-	}
-
-	@Override
-	public void seekTo(int pos) {
-	}
-
-	@Override
-	public int getDuration() {
-		return 0;
-	}
-
-	@Override
-	public int getPosition() {
-		return 0;
-	}
-
-	@Override
-	public void removeListeners() {
-		super.removeListeners();
-		logger.debug("removeListeners");
-
-		// release animgif players if item comes out of view
-		if (this.gifDrawable != null && !this.gifDrawable.isRecycled()) {
-			this.gifDrawable.stop();
-			this.gifDrawable.recycle();
-			this.gifDrawable = null;
-		}
-	}
-}

+ 4 - 16
app/src/main/java/ch/threema/app/services/messageplayer/MessagePlayerServiceImpl.java

@@ -103,16 +103,7 @@ public class MessagePlayerServiceImpl implements MessagePlayerService {
 							messageModel
 					);
 				} else if (messageModel.getType() == MessageType.FILE) {
-					if (MimeUtil.isGifFile(messageModel.getFileData().getMimeType())) {
-						o = new GifMessagePlayer(
-							this.context,
-							this.messageService,
-							this.fileService,
-							this.preferenceService,
-							messageReceiver,
-							messageModel
-						);
-					} else if (MimeUtil.isAudioFile(messageModel.getFileData().getMimeType())
+					if (MimeUtil.isAudioFile(messageModel.getFileData().getMimeType())
 							&& messageModel.getFileData().getRenderingType() == FileData.RENDERING_MEDIA) {
 						o = new AudioMessagePlayer(
 							this.context,
@@ -124,7 +115,7 @@ public class MessagePlayerServiceImpl implements MessagePlayerService {
 							mediaControllerFuture,
 							messageModel
 						);
-					} else if (ConfigUtils.isSupportedAnimatedImageFormat(messageModel.getFileData().getMimeType())
+					} else if (MimeUtil.isAnimatedImageFormat(messageModel.getFileData().getMimeType())
 							&& (messageModel.getFileData().getRenderingType() == FileData.RENDERING_MEDIA
 							|| messageModel.getFileData().getRenderingType() == FileData.RENDERING_STICKER)) {
 						o = new AnimatedImageDrawableMessagePlayer(
@@ -203,11 +194,8 @@ public class MessagePlayerServiceImpl implements MessagePlayerService {
 		synchronized (this.messagePlayers) {
 			for (Map.Entry<Integer, MessagePlayer> entry : messagePlayers.entrySet()) {
 				if (!entry.getKey().equals(messageModel.getId())) {
-					if (!(entry.getValue() instanceof GifMessagePlayer)) {
-						logger.debug("maybe stopping player {} if not running ", entry.getKey());
-
-						entry.getValue().stop();
-					}
+					logger.debug("maybe stopping player {} if not running ", entry.getKey());
+					entry.getValue().stop();
 				}
 			}
 		}

+ 10 - 2
app/src/main/java/ch/threema/app/services/systemupdate/SystemUpdateToVersion89.kt

@@ -60,12 +60,16 @@ internal class SystemUpdateToVersion89(
     private fun calculateLastUpdateContacts() {
         logger.info("Calculate lastUpdate for contacts")
 
+        // Consider all message types except date separators and forward security status messages.
+        // Note that in a previous version of the update script (that has been applied for most
+        // users), all message types have been used to determine the last update flag leading to
+        // some chat reordering.
         db.execSQL("""
             UPDATE contacts
             SET lastUpdate = tmp.lastUpdate FROM (
                 SELECT m.identity, max(m.createdAtUtc) as lastUpdate
                 FROM message m
-                WHERE m.isSaved = 1
+                WHERE m.isSaved = 1 AND type != 10 AND type != 12
                 GROUP BY m.identity
             ) tmp
             WHERE contacts.identity = tmp.identity;
@@ -76,12 +80,16 @@ internal class SystemUpdateToVersion89(
         logger.info("Calculate lastUpdate for groups")
 
         // Set lastUpdate to the create date of the latest message if present
+        // Consider all message types except date separators and group status messages. Note that in
+        // a previous version of the update script (that has been applied for most users), all
+        // message types have been used to determine the last update flag leading to some chat
+        // reordering.
         db.execSQL("""
             UPDATE m_group
             SET lastUpdate = tmp.lastUpdate FROM (
                 SELECT m.groupId, max(m.createdAtUtc) as lastUpdate
                 FROM m_group_message m
-                WHERE m.isSaved = 1
+                WHERE m.isSaved = 1 AND type != 10 AND type != 13
                 GROUP BY m.groupId
             ) tmp
             WHERE m_group.id = tmp.groupId;

+ 50 - 0
app/src/main/java/ch/threema/app/services/systemupdate/SystemUpdateToVersion92.kt

@@ -0,0 +1,50 @@
+/*  _____ _
+ * |_   _| |_  _ _ ___ ___ _ __  __ _
+ *   | | | ' \| '_/ -_) -_) '  \/ _` |_
+ *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
+ *
+ * Threema for Android
+ * Copyright (c) 2024 Threema GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+package ch.threema.app.services.systemupdate
+
+import ch.threema.app.services.UpdateSystemService
+import net.zetetic.database.sqlcipher.SQLiteDatabase
+
+class SystemUpdateToVersion92(
+    private val db: SQLiteDatabase,
+) : UpdateSystemService.SystemUpdate {
+    companion object {
+        const val VERSION = 92
+    }
+
+    override fun runAsync() = true
+
+    override fun runDirectly(): Boolean {
+        for (table in arrayOf(
+            "message",
+            "m_group_message",
+            "distribution_list_message"
+        )) {
+            if (fieldExists(db, table, "displayTags")) {
+                db.execSQL("UPDATE $table SET `displayTags` = 0 WHERE `type` = 12 AND `displayTags` = 1")
+            }
+        }
+        return true
+    }
+
+    override fun getText() = "version $VERSION (remove star from fs status messages)"
+}

+ 28 - 12
app/src/main/java/ch/threema/app/tasks/OutgoingFileMessageTask.kt

@@ -27,6 +27,7 @@ import ch.threema.app.messagereceiver.MessageReceiver.MessageReceiverType
 import ch.threema.base.utils.LoggingUtil
 import ch.threema.base.utils.Utils
 import ch.threema.domain.models.MessageId
+import ch.threema.domain.protocol.csp.ProtocolDefines
 import ch.threema.domain.protocol.csp.messages.file.FileData
 import ch.threema.domain.protocol.csp.messages.file.FileMessage
 import ch.threema.domain.protocol.csp.messages.file.GroupFileMessage
@@ -124,18 +125,33 @@ class OutgoingFileMessageTask(
     private fun FileDataModel.toFileData(
         thumbnailBlobId: ByteArray?,
         messageModel: AbstractMessageModel,
-    ) = FileData().also {
-        it.fileBlobId = blobId
-        it.thumbnailBlobId = thumbnailBlobId
-        it.encryptionKey = encryptionKey
-        it.mimeType = mimeType
-        it.thumbnailMimeType = thumbnailMimeType
-        it.fileSize = fileSize
-        it.fileName = fileName
-        it.renderingType = renderingType
-        it.caption = caption
-        it.correlationId = messageModel.correlationId
-        it.metaData = metaData
+    ): FileData {
+
+        // Validate that the blob id has the correct length
+        if (blobId == null || blobId.size != ProtocolDefines.BLOB_ID_LEN) {
+            logger.error("Invalid blob id of length {}", blobId?.size)
+            throw IllegalStateException("Invalid blob id")
+        }
+
+        // Validate that the encryption key has the correct length
+        if (encryptionKey == null || encryptionKey.size != ProtocolDefines.BLOB_KEY_LEN) {
+            logger.error("Invalid encryption key of length {}", encryptionKey?.size)
+            throw IllegalStateException("Invalid blob encryption key")
+        }
+
+        return FileData().also {
+            it.fileBlobId = blobId
+            it.thumbnailBlobId = thumbnailBlobId
+            it.encryptionKey = encryptionKey
+            it.mimeType = mimeType
+            it.thumbnailMimeType = thumbnailMimeType
+            it.fileSize = fileSize
+            it.fileName = fileName
+            it.renderingType = renderingType
+            it.caption = caption
+            it.correlationId = messageModel.correlationId
+            it.metaData = metaData
+        }
     }
 
     override fun serialize(): SerializableTaskData = OutgoingFileMessageData(

+ 1 - 1
app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeServerTestResponse.java

@@ -21,7 +21,7 @@
 
 package ch.threema.app.threemasafe;
 
-class ThreemaSafeServerTestResponse {
+public class ThreemaSafeServerTestResponse {
 	static final String CONFIG_MAX_BACKUP_BYTES = "maxBackupBytes";
 	static final String CONFIG_RETENTION_DAYS = "retentionDays";
 

+ 4 - 5
app/src/main/java/ch/threema/app/threemasafe/ThreemaSafeServiceImpl.java

@@ -40,9 +40,9 @@ import androidx.work.OneTimeWorkRequest;
 import androidx.work.PeriodicWorkRequest;
 import androidx.work.WorkManager;
 
-import com.lambdaworks.crypto.SCrypt;
 import com.neilalexander.jnacl.NaCl;
 
+import org.bouncycastle.crypto.generators.SCrypt;
 import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
@@ -56,7 +56,6 @@ import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.net.URL;
 import java.nio.charset.StandardCharsets;
-import java.security.GeneralSecurityException;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
@@ -288,9 +287,9 @@ public class ThreemaSafeServiceImpl implements ThreemaSafeService {
 			try {
 				final byte[] passwordBytes = password.getBytes(StandardCharsets.UTF_8);
 				final byte[] identityBytes = identity.getBytes(StandardCharsets.UTF_8);
-				return SCrypt.scrypt(passwordBytes, identityBytes, SCRYPT_N, SCRYPT_R, SCRYPT_P, MASTERKEY_LENGTH);
-			} catch (GeneralSecurityException e) {
-				logger.error("Exception", e);
+				return SCrypt.generate(passwordBytes, identityBytes, SCRYPT_N, SCRYPT_R, SCRYPT_P, MASTERKEY_LENGTH);
+			} catch (Exception e) {
+				logger.error("Could not derive master key", e);
 			}
 		}
 		return null;

+ 12 - 6
app/src/main/java/ch/threema/app/ui/AckjiPopup.java

@@ -143,12 +143,16 @@ AckjiPopup extends PopupWindow implements View.OnClickListener {
 		}
 
 		this.infoSeparator.setVisibility(View.GONE);
-		if (messageModel.getType().equals(MessageType.STATUS) ||
-			messageModel.getType().equals(MessageType.VOIP_STATUS) ||
-			messageModel.getType().equals(MessageType.GROUP_CALL_STATUS) ||
-			messageModel.getType().equals(MessageType.GROUP_STATUS)) {
-			this.starButton.setVisibility(View.GONE);
-		} else {
+		if (
+			messageModel.getType().equals(MessageType.TEXT) ||
+			messageModel.getType().equals(MessageType.FILE) ||
+			messageModel.getType().equals(MessageType.LOCATION) ||
+			messageModel.getType().equals(MessageType.BALLOT) ||
+			messageModel.getType().equals(MessageType.CONTACT) ||
+			messageModel.getType().equals(MessageType.IMAGE) ||
+			messageModel.getType().equals(MessageType.VIDEO) ||
+			messageModel.getType().equals(MessageType.VOICEMESSAGE)
+		) {
 			this.infoButton.setVisibility(View.VISIBLE);
 			this.starButton.setVisibility(messageModel instanceof DistributionListMessageModel ? View.GONE : View.VISIBLE);
 			if (ackButton.getVisibility() == View.VISIBLE || decButton.getVisibility() == View.VISIBLE
@@ -162,6 +166,8 @@ AckjiPopup extends PopupWindow implements View.OnClickListener {
 				this.starButton.setImageResource(R.drawable.ic_star_golden_24dp);
 				this.starButton.setColorFilter(null);
 			}
+		} else {
+			this.starButton.setVisibility(View.GONE);
 		}
 
 		int[] originLocation = {0, 0};

+ 1 - 1
app/src/main/java/ch/threema/app/ui/ContentCommitComposeEditText.java

@@ -130,7 +130,7 @@ public class ContentCommitComposeEditText extends ComposeEditText {
 							MediaItem mediaItem = new MediaItem(
 								uri,
 								MimeUtil.isGifFile(mimeType) ?
-									MediaItem.TYPE_GIF :
+									MediaItem.TYPE_IMAGE_ANIMATED :
 									MediaItem.TYPE_IMAGE);
 							mediaItem.setCaption(caption);
 							mediaItem.setMimeType(mimeType);

+ 6 - 7
app/src/main/java/ch/threema/app/ui/MediaItem.java

@@ -78,18 +78,17 @@ public class MediaItem implements Parcelable {
 	private static final Logger logger = LoggingUtil.getThreemaLogger("MediaItem");
 
 	@Retention(RetentionPolicy.SOURCE)
-	@IntDef({TYPE_FILE, TYPE_IMAGE, TYPE_VIDEO, TYPE_IMAGE_CAM, TYPE_VIDEO_CAM, TYPE_GIF, TYPE_VOICEMESSAGE, TYPE_TEXT, TYPE_LOCATION, TYPE_IMAGE_ANIMATED})
+	@IntDef({TYPE_FILE, TYPE_IMAGE, TYPE_VIDEO, TYPE_IMAGE_CAM, TYPE_VIDEO_CAM, TYPE_VOICEMESSAGE, TYPE_TEXT, TYPE_LOCATION, TYPE_IMAGE_ANIMATED})
 	public @interface MediaType {}
 	public static final int TYPE_FILE = 0;
 	public static final int TYPE_IMAGE = 1;
 	public static final int TYPE_VIDEO = 2;
 	public static final int TYPE_IMAGE_CAM = 3;
 	public static final int TYPE_VIDEO_CAM = 4;
-	public static final int TYPE_GIF = 5;
-	public static final int TYPE_VOICEMESSAGE = 6;
-	public static final int TYPE_TEXT = 7;
-	public static final int TYPE_LOCATION = 8;
-	public static final int TYPE_IMAGE_ANIMATED = 9; // animated images such as animated WebP
+	public static final int TYPE_VOICEMESSAGE = 5;
+	public static final int TYPE_TEXT = 6;
+	public static final int TYPE_LOCATION = 7;
+	public static final int TYPE_IMAGE_ANIMATED = 8; // animated images such as animated WebP
 
 	public static final long TIME_UNDEFINED = Long.MIN_VALUE;
 
@@ -594,7 +593,7 @@ public class MediaItem implements Parcelable {
 		} else if (type == TYPE_IMAGE || type == TYPE_IMAGE_CAM) {
 			return getImageScale() == PreferenceService.ImageScale_SEND_AS_FILE;
 		} else {
-			return type == TYPE_FILE || type == TYPE_GIF || type == TYPE_IMAGE_ANIMATED;
+			return type == TYPE_FILE || type == TYPE_IMAGE_ANIMATED;
 		}
 	}
 

+ 42 - 19
app/src/main/java/ch/threema/app/ui/QuotePopup.kt

@@ -42,15 +42,17 @@ import ch.threema.app.emojis.EmojiMarkupUtil
 import ch.threema.app.services.ContactService
 import ch.threema.app.services.FileService
 import ch.threema.app.services.UserService
-import ch.threema.app.utils.AnimationUtil
 import ch.threema.app.utils.ConfigUtils
 import ch.threema.app.utils.MessageUtil
 import ch.threema.app.utils.NameUtil
 import ch.threema.app.utils.QuoteUtil
+import ch.threema.base.utils.LoggingUtil
 import ch.threema.storage.models.AbstractMessageModel
 import com.google.android.material.card.MaterialCardView
 import com.google.android.material.textfield.TextInputLayout
 
+private val logger = LoggingUtil.getThreemaLogger("QuotePopup")
+
 @SuppressLint("InflateParams")
 class QuotePopup(
     private val context: Context,
@@ -65,6 +67,7 @@ class QuotePopup(
     private val quoteTypeImage: ImageView
     private val quoteBar: View
     private val popupLayout: MaterialCardView
+    private var quotePopupListener: QuotePopupListener? = null
 
     class QuoteInfo {
         var quoteText: String? = null
@@ -85,8 +88,8 @@ class QuotePopup(
         quoteCloseButton.setOnClickListener { dismiss() }
 
         contentView = popupLayout
-        inputMethodMode = INPUT_METHOD_NOT_NEEDED
-        animationStyle = 0
+        inputMethodMode = INPUT_METHOD_NEEDED
+        animationStyle = R.style.Threema_Animation_QuotePopup
         isFocusable = false
         isTouchable = true
         isOutsideTouchable = false
@@ -102,8 +105,11 @@ class QuotePopup(
         textInputLayout: TextInputLayout,
         messageModel: AbstractMessageModel?,
         identity: String?,
-        @ColorInt color: Int
+        @ColorInt color: Int,
+        listener: QuotePopupListener?
     ) {
+        this.quotePopupListener = listener
+
         super.show(activity, textInputLayout)
 
         popupLayout.setCardBackgroundColor(textInputLayout.boxBackgroundColor)
@@ -116,23 +122,18 @@ class QuotePopup(
 
         try {
             showAtLocation(editText, Gravity.LEFT or Gravity.BOTTOM, popupX, popupY)
-            textInputLayout.setBoxCornerRadiiResources(
-                R.dimen.compose_textinputlayout_radius_expanded,
-                R.dimen.compose_textinputlayout_radius_expanded,
-                R.dimen.compose_textinputlayout_radius,
-                R.dimen.compose_textinputlayout_radius
-            )
+            adjustCornersToOpenState(textInputLayout, 20)
             contentView.viewTreeObserver.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
                 override fun onGlobalLayout() {
-                    contentView.viewTreeObserver.removeOnGlobalLayoutListener(this)
-                    AnimationUtil.slideInAnimation(contentView, true, 120)
+                    contentView?.viewTreeObserver?.removeOnGlobalLayoutListener(this)
+                    listener?.onHeightSet(popupLayout.measuredHeight)
                 }
             })
-
             anchorView?.let {
                 ViewCompat.setWindowInsetsAnimationCallback(it, windowInsetsAnimationCallback)
                 it.addOnLayoutChangeListener(onLayoutChangeListener)
             }
+            adjustCornersToOpenState(textInputLayout, 200)
         } catch (e: BadTokenException) {
             //
         }
@@ -162,19 +163,41 @@ class QuotePopup(
             quoteTypeImage.setImageResource(messageViewElement.icon)
             quoteTypeImage.visibility = View.VISIBLE
         }
+
+        quotePopupListener?.onPostVisibilityChange()
+    }
+
+    fun adjustCornersToOpenState(layout: TextInputLayout, delayMs: Long) {
+        layout.postDelayed({
+            layout.setBoxCornerRadiiResources(
+                R.dimen.compose_textinputlayout_radius_expanded,
+                R.dimen.compose_textinputlayout_radius_expanded,
+                R.dimen.compose_textinputlayout_radius,
+                R.dimen.compose_textinputlayout_radius)
+        }, delayMs)
     }
 
     override fun dismiss() {
         anchorView?.let {
-            it.setBoxCornerRadiiResources(
-                R.dimen.compose_textinputlayout_radius,
-                R.dimen.compose_textinputlayout_radius,
-                R.dimen.compose_textinputlayout_radius,
-                R.dimen.compose_textinputlayout_radius
-            )
+            it.postDelayed({
+                it.setBoxCornerRadiiResources(
+                    R.dimen.compose_textinputlayout_radius,
+                    R.dimen.compose_textinputlayout_radius,
+                    R.dimen.compose_textinputlayout_radius,
+                    R.dimen.compose_textinputlayout_radius
+                )
+            }, 200)
             it.removeOnLayoutChangeListener(onLayoutChangeListener)
             ViewCompat.setWindowInsetsAnimationCallback(it, null)
         }
+        quotePopupListener?.onDismiss()
         super.dismiss()
+        quotePopupListener?.onPostVisibilityChange()
+    }
+
+    interface QuotePopupListener {
+        fun onHeightSet(height : Int)
+        fun onDismiss()
+        fun onPostVisibilityChange()
     }
 }

+ 0 - 50
app/src/main/java/ch/threema/app/ui/SquareGifView.java

@@ -1,50 +0,0 @@
-/*  _____ _
- * |_   _| |_  _ _ ___ ___ _ __  __ _
- *   | | | ' \| '_/ -_) -_) '  \/ _` |_
- *   |_| |_||_|_| \___\___|_|_|_\__,_(_)
- *
- * Threema for Android
- * Copyright (c) 2014-2024 Threema GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-package ch.threema.app.ui;
-
-import android.content.Context;
-import android.util.AttributeSet;
-
-import pl.droidsonroids.gif.GifImageView;
-
-public class SquareGifView extends GifImageView {
-
-	public SquareGifView(Context context) {
-		super(context);
-	}
-
-	public SquareGifView(Context context, AttributeSet attrs) {
-		super(context, attrs);
-	}
-
-	public SquareGifView(Context context, AttributeSet attrs, int defStyle) {
-		super(context, attrs, defStyle);
-	}
-
-	@Override
-	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
-	{
-		super.onMeasure(widthMeasureSpec, heightMeasureSpec);
-		setMeasuredDimension(getMeasuredWidth(), getMeasuredWidth());
-	}
-
-}

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

@@ -1595,13 +1595,12 @@ public class ConfigUtils {
 	}
 
 	/**
-	 * Check whether the provided mime type hints at an image format capable of animations and natively supported by the operating system
+	 * Check whether the provided mime type hints at an image format that can be displayed with Glide on this Android version
 	 * @param mimeType Mime type to check
 	 * @return true if conditions are met
 	 */
-	public static boolean isSupportedAnimatedImageFormat(@Nullable String mimeType) {
-		return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
-			MimeUtil.isWebPFile(mimeType);
+	public static boolean isDisplayableAnimatedImageFormat(@Nullable String mimeType) {
+		return (MimeUtil.isWebPFile(mimeType) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) || MimeUtil.isGifFile(mimeType);
 	}
 
 	/**

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

@@ -847,11 +847,11 @@ public class FileUtil {
 	}
 
 	/**
-	 * Check if the file at the provided Uri is an animation. Currently, only animated WebP is supported
+	 * Check if the file at the provided Uri is an animation. Currently, only animated WebP and (possibly static) GIFs are supported
 	 * @param uri A File Uri pointing to an image file
 	 * @return true if the file an animated image
 	 */
-	public static boolean isAnimatedImageFile(@NonNull Uri uri) {
-		return isAnimatedWebPFile(uri);
+	public static boolean isAnimatedImageFile(@NonNull Uri uri, String mimeType) {
+		return isAnimatedWebPFile(uri) || MimeUtil.isGifFile(mimeType);
 	}
 }

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

@@ -38,7 +38,6 @@ import android.text.Selection;
 import android.text.Spannable;
 import android.text.SpannableString;
 import android.text.Spanned;
-import android.text.SpannedString;
 import android.text.style.ClickableSpan;
 import android.text.style.URLSpan;
 import android.text.util.Linkify;
@@ -262,9 +261,7 @@ public class LinkifyUtil {
 					return false;
 				}
 
-				// linkify message caption or body instead of linkified text which may be truncated by view
-				String s = messageModel.getCaption() != null ? messageModel.getCaption() : messageModel.getBody();
-				Spanned buffer = linkifyText(new SpannedString(s), true);
+				Spanned buffer = (Spanned) text;
 				int action = event.getAction();
 
 				int x = (int) event.getX() - widget.getTotalPaddingLeft() + widget.getScrollX();

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

@@ -84,11 +84,6 @@ public class LocaleUtil {
 	}
 
 	public static String formatTimeStampString(Context context, long when, boolean fullFormat) {
-		// TODO: optimize - currently extremely slow
-		return formatTimeStampStringRelative(context, when, fullFormat);
-	}
-
-	private static String formatTimeStampStringRelative(Context context, long when, boolean fullFormat) {
 		String time = DateUtils.formatDateTime(context, when, DateUtils.FORMAT_NO_NOON | DateUtils.FORMAT_NO_MIDNIGHT | DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_SHOW_TIME);
 
 		if (DateUtils.isToday(when)) {

+ 25 - 9
app/src/main/java/ch/threema/app/utils/MimeUtil.java

@@ -23,6 +23,7 @@ package ch.threema.app.utils;
 
 import android.content.Context;
 import android.net.Uri;
+import android.os.Build;
 import android.provider.DocumentsContract;
 
 import java.util.EnumMap;
@@ -40,7 +41,6 @@ import ch.threema.storage.models.data.MessageContentsType;
 import ch.threema.storage.models.data.media.FileDataModel;
 
 import static ch.threema.app.ui.MediaItem.TYPE_FILE;
-import static ch.threema.app.ui.MediaItem.TYPE_GIF;
 import static ch.threema.app.ui.MediaItem.TYPE_IMAGE;
 import static ch.threema.app.ui.MediaItem.TYPE_VIDEO;
 import static ch.threema.app.ui.MediaItem.TYPE_VOICEMESSAGE;
@@ -274,6 +274,13 @@ public class MimeUtil {
 		MIME_TYPE_IMAGE_HEIC
 	};
 
+	private static final String[] supportedImageMimeTypes_pre28 = {
+		MIME_TYPE_IMAGE_JPEG,
+		MIME_TYPE_IMAGE_JPG,
+		MIME_TYPE_IMAGE_PNG,
+		MIME_TYPE_IMAGE_GIF,
+	};
+
 	public enum MimeCategory {
 		APK,
 		AUDIO,
@@ -356,7 +363,11 @@ public class MimeUtil {
 	}
 
 	public static String[] getSupportedImageMimeTypes() {
-		return supportedImageMimeTypes;
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+			return supportedImageMimeTypes;
+		} else {
+			return supportedImageMimeTypes_pre28;
+		}
 	}
 
 	public static boolean isVideoFile(@Nullable String mimeType) {
@@ -490,14 +501,10 @@ public class MimeUtil {
 
 	public static @MediaItem.MediaType int getMediaTypeFromMimeType(String mimeType, Uri uri) {
 		if (MimeUtil.isSupportedImageFile(mimeType)) {
-			if (MimeUtil.isGifFile(mimeType)) {
-				return TYPE_GIF;
-			} else {
-				if (ConfigUtils.isSupportedAnimatedImageFormat(mimeType) && FileUtil.isAnimatedImageFile(uri)) {
-					return MediaItem.TYPE_IMAGE_ANIMATED;
-				}
-				return TYPE_IMAGE;
+			if (FileUtil.isAnimatedImageFile(uri, mimeType)) {
+				return MediaItem.TYPE_IMAGE_ANIMATED;
 			}
+			return TYPE_IMAGE;
 		} else if (MimeUtil.isVideoFile(mimeType)) {
 			return TYPE_VIDEO;
 		} else if (MimeUtil.isAudioFile(mimeType) && (mimeType.startsWith(MimeUtil.MIME_TYPE_AUDIO_AAC) || mimeType.startsWith(MimeUtil.MIME_TYPE_AUDIO_M4A))) {
@@ -505,4 +512,13 @@ public class MimeUtil {
 		}
 		return TYPE_FILE;
 	}
+
+	/**
+	 * Check whether the provided mime type hints at an image format capable of animations (this does not necessarily mean that the image is animated)
+	 * @param mimeType Mime type to check
+	 * @return true if conditions are met
+	 */
+	public static boolean isAnimatedImageFormat(@Nullable String mimeType) {
+		return MimeUtil.isWebPFile(mimeType) || MimeUtil.isGifFile(mimeType);
+	}
 }

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

@@ -82,7 +82,7 @@ public class ThumbnailUtil {
 		switch (MimeUtil.getMediaTypeFromMimeType(mimeType, uri)) {
 			case MediaItem.TYPE_IMAGE:
 				return BitmapUtil.safeGetBitmapFromUri(context, uri, MessageServiceImpl.THUMBNAIL_SIZE_PX, false, true, true);
-			case MediaItem.TYPE_GIF:
+			case MediaItem.TYPE_IMAGE_ANIMATED:
 				return IconUtil.getThumbnailFromUri(context, uri, MessageServiceImpl.THUMBNAIL_SIZE_PX, mimeType, true);
 			case MediaItem.TYPE_VIDEO:
 				return IconUtil.getVideoThumbnailFromUri(context, uri);

+ 30 - 53
app/src/main/java/ch/threema/app/video/transcoder/VideoConfig.java

@@ -52,10 +52,6 @@ public class VideoConfig {
 	public static final int BITRATE_MEDIUM = 1500000;
 	public static final int BITRATE_DEFAULT = 2000000;
 
-	public static final int AUDIO_BITRATE_LOW = 32000;
-	public static final int AUDIO_BITRATE_MEDIUM = 64000;
-	public static final int AUDIO_BITRATE_DEFAULT = 128000;
-
 	// longest edge of video
 	public static final int VIDEO_SIZE_MEDIUM = 848;
 	public static final int VIDEO_SIZE_SMALL = 480;
@@ -72,32 +68,6 @@ public class VideoConfig {
 		return BITRATE_DEFAULT;
 	}
 
-	public static int getPreferredVideoDimensions(int videoSizeId) {
-		int maxSize = 0;
-		switch (videoSizeId) {
-			case PreferenceService.VideoSize_SMALL:
-				maxSize = VIDEO_SIZE_SMALL;
-				break;
-			case PreferenceService.VideoSize_MEDIUM:
-				maxSize = VIDEO_SIZE_MEDIUM;
-				break;
-			case PreferenceService.VideoSize_ORIGINAL:
-				maxSize = 65535;
-				break;
-		}
-		return maxSize;
-	}
-
-	public static int getPreferredAudioBitrate(int videoSizeId) {
-		switch (videoSizeId) {
-			case PreferenceService.VideoSize_MEDIUM:
-				return AUDIO_BITRATE_MEDIUM;
-			case PreferenceService.VideoSize_SMALL:
-				return AUDIO_BITRATE_LOW;
-		}
-		return AUDIO_BITRATE_DEFAULT;
-	}
-
 	public static int getMaxSizeFromBitrate(int bitrate) {
 		switch (bitrate) {
 			case BITRATE_MEDIUM:
@@ -136,7 +106,7 @@ public class VideoConfig {
 	 * @throws ThreemaException
 	 */
 	public static int getTargetVideoBitrate(Context context, MediaItem mediaItem, int videoSize) throws ThreemaException {
-		int originalBitrate;
+		Integer originalBitrate = null;
 		int targetBitrate;
 		int preferredBitrate = getPreferredVideoBitrate(videoSize);
 
@@ -148,7 +118,6 @@ public class VideoConfig {
 			originalBitrate = Integer.parseInt(metaRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE));
 		} catch (Exception e) {
 			logger.error("Exception querying MediaMetaDataRetriever", e);
-			throw new ThreemaException(e.getMessage());
 		} finally {
 			try {
 				metaRetriever.release();
@@ -157,6 +126,11 @@ public class VideoConfig {
 			}
 		}
 
+		if (originalBitrate == null) {
+			logger.info("Original bit rate could not be extracted. Falling back to bit rate {}", preferredBitrate);
+			return preferredBitrate;
+		}
+
 		MediaExtractor extractor = new MediaExtractor();
 		try {
 			extractor.setDataSource(context, mediaItem.getUri(), null);
@@ -180,6 +154,27 @@ public class VideoConfig {
 			}
 		}
 
+		targetBitrate = calculateTargetBitrate(extractor, mediaItem, originalBitrate);
+		extractor.release();
+
+		if (targetBitrate < preferredBitrate) {
+			logger.info("Preferred bit rate is {}. Falling back to bit rate {} due to size", preferredBitrate, targetBitrate);
+		}
+
+		if (mediaItem.getType() != MediaItem.TYPE_VIDEO_CAM && targetBitrate > preferredBitrate && preferredBitrate != BITRATE_DEFAULT) {
+			logger.info("Target bitrate ({}) is higher than preferred bitrate ({})", targetBitrate, preferredBitrate);
+			return preferredBitrate;
+		}
+
+		if (targetBitrate != originalBitrate) {
+			logger.info("Target bitrate ({}) is not original bitrate ({})", targetBitrate, originalBitrate);
+			return targetBitrate;
+		}
+
+		return 0; // no change necessary
+	}
+
+	private static int calculateTargetBitrate(MediaExtractor extractor, MediaItem mediaItem, int originalBitrate) throws ThreemaException {
 		int calculatedAudioSize = 0;
 		int srcAudioTrack = findTrack(extractor, MIME_AUDIO);
 		if (srcAudioTrack >= 0) {
@@ -209,34 +204,16 @@ public class VideoConfig {
 					if (calculatedFileSize > MAX_BLOB_SIZE) {
 						return -1;
 					} else {
-						targetBitrate = BITRATE_LOW;
+						return BITRATE_LOW;
 					}
 				} else {
-					targetBitrate = BITRATE_MEDIUM;
+					return BITRATE_MEDIUM;
 				}
 			} else {
-				targetBitrate = originalBitrate;
+				return originalBitrate;
 			}
 		} else {
 			throw new ThreemaException("No video track found in this file");
 		}
-
-		extractor.release();
-
-		if (targetBitrate < preferredBitrate) {
-			logger.info("Preferred bit rate is {}. Falling back to bit rate {} due to size", preferredBitrate, targetBitrate);
-		}
-
-		if (mediaItem.getType() != MediaItem.TYPE_VIDEO_CAM && targetBitrate > preferredBitrate && preferredBitrate != BITRATE_DEFAULT) {
-			logger.info("Target bitrate ({}) is higher than preferred bitrate ({})", targetBitrate, preferredBitrate);
-			return preferredBitrate;
-		}
-
-		if (targetBitrate != originalBitrate) {
-			logger.info("Target bitrate ({}) is not original bitrate ({})", targetBitrate, originalBitrate);
-			return targetBitrate;
-		}
-
-		return 0; // no change necessary
 	}
 }

+ 7 - 6
app/src/main/java/ch/threema/app/voip/groupcall/service/GroupCallService.kt

@@ -35,6 +35,7 @@ import android.telephony.PhoneStateListener
 import android.telephony.TelephonyManager
 import android.widget.Toast
 import androidx.core.app.NotificationCompat
+import androidx.core.app.Person
 import androidx.core.content.ContextCompat
 import androidx.core.content.res.ResourcesCompat
 import ch.threema.app.R
@@ -232,6 +233,10 @@ class GroupCallService : Service() {
 
     private fun getForegroundNotification(startedAt: Long = System.currentTimeMillis()): Notification {
         val group = groupService.getById(groupId.id)
+        val callerPerson = Person.Builder()
+            .setName(getNotificationTitle(group))
+            .setImportant(true)
+            .build()
         val builder = NotificationBuilderWrapper(
             this, NotificationService.NOTIFICATION_CHANNEL_IN_CALL, null)
             .setContentTitle(getNotificationTitle(group))
@@ -246,11 +251,7 @@ class GroupCallService : Service() {
             .setWhen(startedAt)
             .setPriority(NotificationCompat.PRIORITY_DEFAULT)
             .setContentIntent(getJoinCallPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
-            .addAction(
-                R.drawable.ic_outline_logout_bitmap,
-                getString(R.string.leave),
-                getLeaveCallPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)
-            )
+            .setStyle(NotificationCompat.CallStyle.forOngoingCall(callerPerson, getLeaveCallPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)))
 
         return builder.build()
     }
@@ -265,7 +266,7 @@ class GroupCallService : Service() {
 
     }
 
-    private fun getLeaveCallPendingIntent(flags: Int): PendingIntent? {
+    private fun getLeaveCallPendingIntent(flags: Int): PendingIntent {
         return PendingIntent.getService(
             applicationContext,
             REQUEST_CODE_LEAVE_CALL,

+ 8 - 1
app/src/main/java/ch/threema/app/voip/services/VoipCallService.java

@@ -58,6 +58,7 @@ import androidx.annotation.StringRes;
 import androidx.annotation.UiThread;
 import androidx.annotation.WorkerThread;
 import androidx.core.app.NotificationCompat;
+import androidx.core.app.Person;
 import androidx.core.content.ContextCompat;
 import androidx.core.util.Pair;
 import androidx.lifecycle.LifecycleService;
@@ -2171,6 +2172,12 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 		openIntent.putExtra(EXTRA_ACTIVITY_MODE, CallActivity.MODE_ACTIVE_CALL);
 		openIntent.putExtra(EXTRA_CONTACT_IDENTITY, contact.getIdentity());
 		openIntent.putExtra(EXTRA_START_TIME, elapsedTimeMs);
+
+		final Person callerPerson = new Person.Builder()
+			.setName(NameUtil.getDisplayNameOrNickname(contact, true))
+			.setImportant(true)
+			.build();
+
 		final PendingIntent openPendingIntent = PendingIntent.getActivity(
 				this,
 				(int)System.currentTimeMillis(),
@@ -2189,7 +2196,7 @@ public class VoipCallService extends LifecycleService implements PeerConnectionC
 				.setSmallIcon(R.drawable.ic_phone_locked_white_24dp)
 				.setPriority(NotificationCompat.PRIORITY_DEFAULT)
 				.setContentIntent(openPendingIntent)
-				.addAction(R.drawable.ic_call_end_grey600_24dp, getString(R.string.voip_hangup), hangupPendingIntent);
+				.setStyle(NotificationCompat.CallStyle.forOngoingCall(callerPerson, hangupPendingIntent));
 
 		final Bitmap avatar = contactService.getAvatar(contact, false);
 		notificationBuilder.setLargeIcon(avatar);

+ 2 - 1
app/src/main/java/ch/threema/app/webclient/converter/MessageState.java

@@ -53,8 +53,9 @@ public class MessageState extends Converter {
 					return MessageState.USERACK;
 				case USERDEC:
 					return MessageState.USERDEC;
-				case TRANSCODING:
 				case PENDING:
+				case TRANSCODING:
+				case UPLOADING:
 					return MessageState.PENDING;
 				case SENDING:
 					return MessageState.SENDING;

+ 1 - 1
app/src/main/java/ch/threema/app/webclient/services/instance/message/receiver/ActiveConversationHandler.java

@@ -95,7 +95,7 @@ public class ActiveConversationHandler extends MessageReceiver {
 		if (messageReceiver != null) {
 			final ConversationModel conversationModel = this.conversationService.refresh(messageReceiver);
 			if (conversationModel != null) {
-				conversationTagService.unTag(conversationModel, conversationTagService.getTagModel(ConversationTagServiceImpl.FIXED_TAG_UNREAD));
+				conversationTagService.removeTagAndNotify(conversationModel, conversationTagService.getTagModel(ConversationTagServiceImpl.FIXED_TAG_UNREAD));
 			}
 		}
 	}

+ 24 - 11
app/src/main/java/ch/threema/app/webclient/services/instance/message/receiver/FileMessageCreateHandler.java

@@ -35,7 +35,7 @@ import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.IOException;
-import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -56,6 +56,10 @@ import ch.threema.domain.protocol.csp.messages.file.FileData;
 import ch.threema.storage.models.AbstractMessageModel;
 
 import static ch.threema.app.ThreemaApplication.MAX_BLOB_SIZE;
+import static ch.threema.app.utils.MimeUtil.MIME_TYPE_AUDIO_OGG;
+import static ch.threema.app.utils.MimeUtil.MIME_TYPE_IMAGE_JPEG;
+import static ch.threema.app.utils.MimeUtil.MIME_TYPE_IMAGE_JPG;
+import static ch.threema.app.utils.MimeUtil.MIME_TYPE_IMAGE_PNG;
 
 @WorkerThread
 public class FileMessageCreateHandler extends MessageCreateHandler {
@@ -68,14 +72,15 @@ public class FileMessageCreateHandler extends MessageCreateHandler {
 	private static final String FIELD_CAPTION = "caption";
 	private static final String FIELD_SEND_AS_FILE = "sendAsFile";
 
-	private static final List<String> IMAGE_MIME_TYPES = new ArrayList<String>() {{
-		add("image/png");
-		add("image/jpg");
-		add("image/jpeg");
-	}};
-	private static final List<String> AUDIO_MIME_TYPES = new ArrayList<String>() {{
-		add("audio/ogg");
-	}};
+	private static final List<String> IMAGE_MIME_TYPES = Arrays.asList(
+		MIME_TYPE_IMAGE_PNG,
+		MIME_TYPE_IMAGE_JPG,
+		MIME_TYPE_IMAGE_JPEG
+	);
+
+	private static final List<String> AUDIO_MIME_TYPES = List.of(
+		MIME_TYPE_AUDIO_OGG
+	);
 
 	private final FileService fileService;
 
@@ -84,8 +89,8 @@ public class FileMessageCreateHandler extends MessageCreateHandler {
 	                                MessageService messageService,
 	                                FileService fileService,
 	                                LifetimeService lifetimeService,
-	                                IdListService blackListservice) {
-		super(Protocol.SUB_TYPE_FILE_MESSAGE, dispatcher, messageService, lifetimeService, blackListservice);
+	                                IdListService blackListService) {
+		super(Protocol.SUB_TYPE_FILE_MESSAGE, dispatcher, messageService, lifetimeService, blackListService);
 
 		this.fileService = fileService;
 	}
@@ -141,6 +146,14 @@ public class FileMessageCreateHandler extends MessageCreateHandler {
 			BitmapUtil.ExifOrientation exifOrientation = BitmapUtil.getExifOrientation(ThreemaApplication.getAppContext(), Uri.fromFile(file));
 			exifRotation = (int) exifOrientation.getRotation();
 			exifFlip = exifOrientation.getFlip();
+		} else if (!sendAsFile && MimeUtil.isAnimatedImageFormat(mimeType)) {
+			mediaType = MediaItem.TYPE_IMAGE_ANIMATED;
+			renderingType = FileData.RENDERING_MEDIA;
+			if (MimeUtil.isWebPFile(mimeType)) {
+				BitmapUtil.ExifOrientation exifOrientation = BitmapUtil.getExifOrientation(ThreemaApplication.getAppContext(), Uri.fromFile(file));
+				exifRotation = (int) exifOrientation.getRotation();
+				exifFlip = exifOrientation.getFlip();
+			}
 		} else if (!sendAsFile && FileMessageCreateHandler.AUDIO_MIME_TYPES.contains(mimeType)) {
 			mediaType = MediaItem.TYPE_VOICEMESSAGE;
 			renderingType = FileData.RENDERING_DEFAULT;

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

@@ -121,10 +121,10 @@ public class ModifyConversationHandler extends MessageReceiver {
 			// Tag model
 			final TagModel pinTagModel = conversationTagService.getTagModel(ConversationTagServiceImpl.FIXED_TAG_PIN);
 			if (isPinned) {
-				this.conversationTagService.tag(conversation, pinTagModel);
+				this.conversationTagService.addTagAndNotify(conversation, pinTagModel);
 				conversation.setIsPinTagged(true);
 			} else {
-				this.conversationTagService.unTag(conversation, pinTagModel);
+				this.conversationTagService.removeTagAndNotify(conversation, pinTagModel);
 				conversation.setIsPinTagged(false);
 			}
 

+ 3 - 4
app/src/main/java/ch/threema/localcrypto/MasterKey.java

@@ -21,8 +21,7 @@
 
 package ch.threema.localcrypto;
 
-import com.lambdaworks.crypto.SCrypt;
-
+import org.bouncycastle.crypto.generators.SCrypt;
 import org.slf4j.Logger;
 
 import java.io.DataInputStream;
@@ -525,11 +524,11 @@ public class MasterKey {
 					SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
 					return factory.generateSecret(keySpec).getEncoded();
 				case SCRYPT:
-					return SCrypt.scrypt(new String(passphrase).getBytes(StandardCharsets.UTF_8), salt, SCRYPT_N, SCRYPT_R, SCRYPT_P, KEY_LENGTH);
+					return SCrypt.generate(new String(passphrase).getBytes(StandardCharsets.UTF_8), salt, SCRYPT_N, SCRYPT_R, SCRYPT_P, KEY_LENGTH);
 				default:
 					throw new RuntimeException("Unsupported protection type " + protectionType);
 			}
-		} catch (GeneralSecurityException e) {
+		} catch (GeneralSecurityException | IllegalArgumentException e) {
 			throw new RuntimeException(e);
 		}
 	}

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

@@ -115,6 +115,7 @@ import ch.threema.app.services.systemupdate.SystemUpdateToVersion89;
 import ch.threema.app.services.systemupdate.SystemUpdateToVersion9;
 import ch.threema.app.services.systemupdate.SystemUpdateToVersion90;
 import ch.threema.app.services.systemupdate.SystemUpdateToVersion91;
+import ch.threema.app.services.systemupdate.SystemUpdateToVersion92;
 import ch.threema.app.utils.RuntimeUtil;
 import ch.threema.app.utils.TestUtil;
 import ch.threema.base.utils.LoggingUtil;
@@ -148,7 +149,7 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 
 	public static final String DATABASE_NAME_V4 = "threema4.db";
 	public static final String DATABASE_BACKUP_EXT = ".backup";
-	private static final int DATABASE_VERSION = SystemUpdateToVersion91.VERSION;
+	private static final int DATABASE_VERSION = SystemUpdateToVersion92.VERSION;
 
 	private final Context context;
 	private final UpdateSystemService updateSystemService;
@@ -718,6 +719,9 @@ public class DatabaseServiceNew extends SQLiteOpenHelper {
 		if (oldVersion < SystemUpdateToVersion91.VERSION) {
 			this.updateSystemService.addUpdate(new SystemUpdateToVersion91(this.context));
 		}
+		if (oldVersion < SystemUpdateToVersion92.VERSION) {
+			this.updateSystemService.addUpdate(new SystemUpdateToVersion92(sqLiteDatabase));
+		}
 	}
 
 	public void executeNull() throws SQLiteException {

+ 32 - 48
app/src/main/java/ch/threema/storage/factories/ConversationTagFactory.java

@@ -24,12 +24,15 @@ package ch.threema.storage.factories;
 import android.content.ContentValues;
 
 import android.database.Cursor;
+import android.database.SQLException;
 
 import org.slf4j.Logger;
 
 import java.util.ArrayList;
 import java.util.List;
 
+import androidx.annotation.NonNull;
+import androidx.sqlite.db.SupportSQLiteQueryBuilder;
 import ch.threema.base.utils.LoggingUtil;
 import ch.threema.storage.CursorHelper;
 import ch.threema.storage.DatabaseServiceNew;
@@ -44,40 +47,15 @@ public class ConversationTagFactory extends ModelFactory {
 	}
 
 	public List<ConversationTagModel> getAll() {
-		Cursor cursor = null;
-		try {
-			cursor = this.databaseService.getReadableDatabase().query(this.getTableName(),
+		try (Cursor cursor = this.databaseService.getReadableDatabase().query(this.getTableName(),
 				null,
 				null,
 				null,
 				null,
 				null,
-				null);
+				null)) {
 
 			return convertList(cursor);
-		} finally {
-			if (cursor != null) {
-				cursor.close();
-			}
-		}
-	}
-
-	public List<ConversationTagModel> getByConversationUid(String conversationUid) {
-		Cursor cursor = null;
-		try {
-			cursor = this.databaseService.getReadableDatabase().query(this.getTableName(),
-				null,
-				ConversationTagModel.COLUMN_CONVERSATION_UID + "=?",
-				new String[]{conversationUid},
-				null,
-				null,
-				null);
-
-			return convertList(cursor);
-		} finally {
-			if (cursor != null) {
-				cursor.close();
-			}
 		}
 	}
 
@@ -101,6 +79,27 @@ public class ConversationTagFactory extends ModelFactory {
 		));
 	}
 
+	@NonNull
+	public List<String> getAllConversationUidsByTag(@NonNull String tag) {
+		try (Cursor cursor = databaseService.getReadableDatabase().query(
+			SupportSQLiteQueryBuilder.builder(getTableName())
+				.columns(new String[]{ConversationTagModel.COLUMN_CONVERSATION_UID})
+				.selection(ConversationTagModel.COLUMN_TAG + " = ?", new String[]{tag})
+				.create()
+		)) {
+			List<String> conversationUids = new ArrayList<>(cursor.getCount());
+			int columnIndex =
+					cursor.getColumnIndexOrThrow(ConversationTagModel.COLUMN_CONVERSATION_UID);
+			while (cursor.moveToNext()) {
+				conversationUids.add(cursor.getString(columnIndex));
+			}
+			return conversationUids;
+		} catch (SQLException | IllegalArgumentException e) {
+			logger.error("Could not get uids by tag '{}'", tag, e);
+			return List.of();
+		}
+	}
+
 	private List<ConversationTagModel> convertList(Cursor cursor) {
 		List<ConversationTagModel> result = new ArrayList<>();
 		if (cursor != null) {
@@ -148,12 +147,8 @@ public class ConversationTagFactory extends ModelFactory {
 		this.databaseService.getWritableDatabase().insertOrThrow(this.getTableName(), null, contentValues);
 	}
 
-	public int delete(ConversationTagModel model) {
-		return this.deleteByConversationUidAndTag(model.getConversationUid(), model.getTag());
-	}
-
-	public int deleteByConversationUidAndTag(String conversationUid, String tag) {
-		return this.databaseService.getWritableDatabase().delete(this.getTableName(),
+	public void deleteByConversationUidAndTag(String conversationUid, String tag) {
+		this.databaseService.getWritableDatabase().delete(this.getTableName(),
 			ConversationTagModel.COLUMN_CONVERSATION_UID + "=? AND "
 				+ ConversationTagModel.COLUMN_TAG + "=? ",
 			new String[]{
@@ -162,22 +157,14 @@ public class ConversationTagFactory extends ModelFactory {
 			});
 	}
 
-	public int deleteByConversationUid(String conversationUid) {
-		return this.databaseService.getWritableDatabase().delete(this.getTableName(),
+	public void deleteByConversationUid(String conversationUid) {
+		this.databaseService.getWritableDatabase().delete(this.getTableName(),
 			ConversationTagModel.COLUMN_CONVERSATION_UID + "=?",
 			new String[]{
 				conversationUid
 			});
 	}
 
-	public int deleteByConversationTag(String tag) {
-		return this.databaseService.getWritableDatabase().delete(this.getTableName(),
-			ConversationTagModel.COLUMN_TAG + "=?",
-			new String[]{
-				tag
-			});
-	}
-
 	private ConversationTagModel getFirst(String selection, String[] selectionArgs) {
 		Cursor cursor = this.databaseService.getReadableDatabase().query (
 				this.getTableName(),
@@ -189,15 +176,12 @@ public class ConversationTagFactory extends ModelFactory {
 				null
 		);
 
-		if(cursor != null) {
-			try {
+		if (cursor != null) {
+			try (cursor) {
 				if (cursor.moveToFirst()) {
 					return convert(cursor);
 				}
 			}
-			finally {
-				cursor.close();
-			}
 		}
 
 		return null;

+ 0 - 157
app/src/main/java/com/lambdaworks/codec/Base64.java

@@ -1,157 +0,0 @@
-// Copyright (C) 2011 - Will Glozer.  All rights reserved.
-
-package com.lambdaworks.codec;
-
-import java.util.Arrays;
-
-/**
- * High-performance base64 codec based on the algorithm used in Mikael Grev's MiG Base64.
- * This implementation is designed to handle base64 without line splitting and with
- * optional padding. Alternative character tables may be supplied to the {@code encode}
- * and {@code decode} methods to implement modified base64 schemes.
- *
- * Decoding assumes correct input, the caller is responsible for ensuring that the input
- * contains no invalid characters.
- *
- * @author Will Glozer
- */
-public class Base64 {
-    private static final char[] encode = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".toCharArray();
-    private static final int[]  decode = new int[128];
-    private static final char   pad    = '=';
-
-    static {
-        Arrays.fill(decode, -1);
-        for (int i = 0; i < encode.length; i++) {
-            decode[encode[i]] = i;
-        }
-        decode[pad] = 0;
-    }
-
-    /**
-     * Decode base64 chars to bytes.
-     *
-     * @param chars Chars to encode.
-     *
-     * @return Decoded bytes.
-     */
-    public static byte[] decode(char[] chars) {
-        return decode(chars, decode, pad);
-    }
-
-    /**
-     * Encode bytes to base64 chars, with padding.
-     *
-     * @param bytes Bytes to encode.
-     *
-     * @return Encoded chars.
-     */
-    public static char[] encode(byte[] bytes) {
-        return encode(bytes, encode, pad);
-    }
-
-    /**
-     * Encode bytes to base64 chars, with optional padding.
-     *
-     * @param bytes     Bytes to encode.
-     * @param padded    Add padding to output.
-     *
-     * @return Encoded chars.
-     */
-    public static char[] encode(byte[] bytes, boolean padded) {
-        return encode(bytes, encode, padded ? pad : 0);
-    }
-
-    /**
-     * Decode base64 chars to bytes using the supplied decode table and padding
-     * character.
-     *
-     * @param src   Base64 encoded data.
-     * @param table Decode table.
-     * @param pad   Padding character.
-     *
-     * @return Decoded bytes.
-     */
-    public static byte[] decode(char[] src, int[] table, char pad) {
-        int len = src.length;
-
-        if (len == 0) return new byte[0];
-
-        int padCount = (src[len - 1] == pad ? (src[len - 2] == pad ? 2 : 1) : 0);
-        int bytes    = (len * 6 >> 3) - padCount;
-        int blocks   = (bytes / 3) * 3;
-
-        byte[] dst = new byte[bytes];
-        int si = 0, di = 0;
-
-        while (di < blocks) {
-            int n = table[src[si++]] << 18 | table[src[si++]] << 12 | table[src[si++]] << 6 | table[src[si++]];
-            dst[di++] = (byte) (n >> 16);
-            dst[di++] = (byte) (n >>  8);
-            dst[di++] = (byte) n;
-        }
-
-        if (di < bytes) {
-            int n = 0;
-            switch (len - si) {
-                case 4: n |= table[src[si+3]]; // fallthrough
-                case 3: n |= table[src[si+2]] <<  6; // fallthrough
-                case 2: n |= table[src[si+1]] << 12; // fallthrough
-                case 1: n |= table[src[si]]   << 18; // fallthrough
-            }
-            for (int r = 16; di < bytes; r -= 8) {
-                dst[di++] = (byte) (n >> r);
-            }
-        }
-
-        return dst;
-    }
-
-    /**
-     * Encode bytes to base64 chars using the supplied encode table and with
-     * optional padding.
-     *
-     * @param src   Bytes to encode.
-     * @param table Encoding table.
-     * @param pad   Padding character, or 0 for no padding.
-     *
-     * @return Encoded chars.
-     */
-    public static char[] encode(byte[] src, char[] table, char pad) {
-        int len = src.length;
-
-        if (len == 0) return new char[0];
-
-        int blocks = (len / 3) * 3;
-        int chars  = ((len - 1) / 3 + 1) << 2;
-        int tail   = len - blocks;
-        if (pad == 0 && tail > 0) chars -= 3 - tail;
-
-        char[] dst = new char[chars];
-        int si = 0, di = 0;
-
-        while (si < blocks) {
-            int n = (src[si++] & 0xff) << 16 | (src[si++] & 0xff) << 8 | (src[si++] & 0xff);
-            dst[di++] = table[(n >>> 18) & 0x3f];
-            dst[di++] = table[(n >>> 12) & 0x3f];
-            dst[di++] = table[(n >>>  6) & 0x3f];
-            dst[di++] = table[n          & 0x3f];
-        }
-
-        if (tail > 0) {
-            int n = (src[si] & 0xff) << 10;
-            if (tail == 2) n |= (src[++si] & 0xff) << 2;
-
-            dst[di++] = table[(n >>> 12) & 0x3f];
-            dst[di++] = table[(n >>> 6)  & 0x3f];
-            if (tail == 2) dst[di++] = table[n & 0x3f];
-
-            if (pad != 0) {
-                if (tail == 1) dst[di++] = pad;
-                dst[di] = pad;
-            }
-        }
-
-        return dst;
-    }
-}

+ 0 - 89
app/src/main/java/com/lambdaworks/crypto/PBKDF.java

@@ -1,89 +0,0 @@
-// Copyright (C) 2011 - Will Glozer.  All rights reserved.
-
-package com.lambdaworks.crypto;
-
-import java.security.GeneralSecurityException;
-
-import javax.crypto.Mac;
-import javax.crypto.spec.SecretKeySpec;
-
-import static java.lang.System.arraycopy;
-
-/**
- * An implementation of the Password-Based Key Derivation Function as specified
- * in RFC 2898.
- *
- * @author  Will Glozer
- */
-public class PBKDF {
-    /**
-     * Implementation of PBKDF2 (RFC2898).
-     *
-     * @param   alg     HMAC algorithm to use.
-     * @param   P       Password.
-     * @param   S       Salt.
-     * @param   c       Iteration count.
-     * @param   dkLen   Intended length, in octets, of the derived key.
-     *
-     * @return  The derived key.
-     *
-     * @throws  GeneralSecurityException
-     */
-    public static byte[] pbkdf2(String alg, byte[] P, byte[] S, int c, int dkLen) throws GeneralSecurityException {
-        Mac mac = Mac.getInstance(alg);
-        mac.init(new SecretKeySpec(P, alg));
-        byte[] DK = new byte[dkLen];
-        pbkdf2(mac, S, c, DK, dkLen);
-        return DK;
-    }
-
-    /**
-     * Implementation of PBKDF2 (RFC2898).
-     *
-     * @param   mac     Pre-initialized {@link Mac} instance to use.
-     * @param   S       Salt.
-     * @param   c       Iteration count.
-     * @param   DK      Byte array that derived key will be placed in.
-     * @param   dkLen   Intended length, in octets, of the derived key.
-     *
-     * @throws  GeneralSecurityException
-     */
-    public static void pbkdf2(Mac mac, byte[] S, int c, byte[] DK, int dkLen) throws GeneralSecurityException {
-        int hLen = mac.getMacLength();
-
-        if (dkLen > (Math.pow(2, 32) - 1) * hLen) {
-            throw new GeneralSecurityException("Requested key length too long");
-        }
-
-        byte[] U      = new byte[hLen];
-        byte[] T      = new byte[hLen];
-        byte[] block1 = new byte[S.length + 4];
-
-        int l = (int) Math.ceil((double) dkLen / hLen);
-        int r = dkLen - (l - 1) * hLen;
-
-        arraycopy(S, 0, block1, 0, S.length);
-
-        for (int i = 1; i <= l; i++) {
-            block1[S.length + 0] = (byte) (i >> 24 & 0xff);
-            block1[S.length + 1] = (byte) (i >> 16 & 0xff);
-            block1[S.length + 2] = (byte) (i >> 8  & 0xff);
-            block1[S.length + 3] = (byte) (i >> 0  & 0xff);
-
-            mac.update(block1);
-            mac.doFinal(U, 0);
-            arraycopy(U, 0, T, 0, hLen);
-
-            for (int j = 1; j < c; j++) {
-                mac.update(U);
-                mac.doFinal(U, 0);
-
-                for (int k = 0; k < hLen; k++) {
-                    T[k] ^= U[k];
-                }
-            }
-
-            arraycopy(T, 0, DK, (i - 1) * hLen, (i == l ? r : hLen));
-        }
-    }
-}

+ 0 - 235
app/src/main/java/com/lambdaworks/crypto/SCrypt.java

@@ -1,235 +0,0 @@
-// Copyright (C) 2011 - Will Glozer.  All rights reserved.
-
-package com.lambdaworks.crypto;
-
-import com.lambdaworks.jni.LibraryLoader;
-import com.lambdaworks.jni.LibraryLoaders;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.security.GeneralSecurityException;
-
-import javax.crypto.Mac;
-import javax.crypto.spec.SecretKeySpec;
-
-import static java.lang.Integer.MAX_VALUE;
-import static java.lang.System.arraycopy;
-
-/**
- * An implementation of the <a href="http://www.tarsnap.com/scrypt/scrypt.pdf"/>scrypt</a>
- * key derivation function. This class will attempt to load a native library
- * containing the optimized C implementation from
- * <a href="http://www.tarsnap.com/scrypt.html">http://www.tarsnap.com/scrypt.html<a> and
- * fall back to the pure Java version if that fails.
- *
- * @author  Will Glozer
- */
-public class SCrypt {
-
-    private final static Logger LOGGER = LoggerFactory.getLogger(SCrypt.class);
-
-    private static final boolean native_library_loaded;
-
-    static {
-        LibraryLoader loader = LibraryLoaders.loader();
-        native_library_loaded = loader.load("scrypt", true);
-
-        if(native_library_loaded) {
-            LOGGER.info("Native library loaded.");
-        } else {
-            LOGGER.error("Using pure java implementation.");
-        }
-    }
-
-    /**
-     * Reports whether the native scrypt library has been loaded.
-     */
-    public static boolean isNativeLibraryLoaded() {
-        return native_library_loaded;
-    }
-
-    /**
-     * Implementation of the <a href="http://www.tarsnap.com/scrypt/scrypt.pdf"/>scrypt KDF</a>.
-     * Calls the native implementation {@link #scryptN} when the native library was successfully
-     * loaded, otherwise calls {@link #scryptJ}.
-     *
-     * @param passwd    Password.
-     * @param salt      Salt.
-     * @param N         Iteration count.
-     * @param r         Block size.
-     * @param p         Parallelization parameter.
-     * @param dkLen     Intended length of the derived key.
-     *
-     * @return The derived key.
-     *
-     * @throws GeneralSecurityException when HMAC_SHA256 is not available.
-     */
-    public static byte[] scrypt(byte[] passwd, byte[] salt, int N, int r, int p, int dkLen) throws GeneralSecurityException {
-        return native_library_loaded ? scryptN(passwd, salt, N, r, p, dkLen) : scryptJ(passwd, salt, N, r, p, dkLen);
-    }
-
-    /**
-     * Native C implementation of the <a href="http://www.tarsnap.com/scrypt/scrypt.pdf"/>scrypt KDF</a> using
-     * the code from <a href="http://www.tarsnap.com/scrypt.html">http://www.tarsnap.com/scrypt.html<a>.
-     *
-     * @param passwd    Password.
-     * @param salt      Salt.
-     * @param N         Iteration count.
-     * @param r         Block size.
-     * @param p         Parallelization parameter.
-     * @param dkLen     Intended length of the derived key.
-     *
-     * @return The derived key.
-     */
-    public static native byte[] scryptN(byte[] passwd, byte[] salt, int N, int r, int p, int dkLen);
-
-    /**
-     * Pure Java implementation of the <a href="http://www.tarsnap.com/scrypt/scrypt.pdf"/>scrypt KDF</a>.
-     *
-     * @param passwd    Password.
-     * @param salt      Salt.
-     * @param N         Iteration count.
-     * @param r         Block size.
-     * @param p         Parallelization parameter.
-     * @param dkLen     Intended length of the derived key.
-     *
-     * @return The derived key.
-     *
-     * @throws GeneralSecurityException when HMAC_SHA256 is not available.
-     */
-    public static byte[] scryptJ(byte[] passwd, byte[] salt, int N, int r, int p, int dkLen) throws GeneralSecurityException {
-        if (N < 2 || (N & (N - 1)) != 0) throw new IllegalArgumentException("N must be a power of 2 greater than 1");
-
-        if (N > MAX_VALUE / 128 / r) throw new IllegalArgumentException("Parameter N is too large");
-        if (r > MAX_VALUE / 128 / p) throw new IllegalArgumentException("Parameter r is too large");
-
-        Mac mac = Mac.getInstance("HmacSHA256");
-        mac.init(new SecretKeySpec(passwd, "HmacSHA256"));
-
-        byte[] DK = new byte[dkLen];
-
-        byte[] B  = new byte[128 * r * p];
-        byte[] XY = new byte[256 * r];
-        byte[] V  = new byte[128 * r * N];
-        int i;
-
-        PBKDF.pbkdf2(mac, salt, 1, B, p * 128 * r);
-
-        for (i = 0; i < p; i++) {
-            smix(B, i * 128 * r, r, N, V, XY);
-        }
-
-        PBKDF.pbkdf2(mac, B, 1, DK, dkLen);
-
-        return DK;
-    }
-
-    public static void smix(byte[] B, int Bi, int r, int N, byte[] V, byte[] XY) {
-        int Xi = 0;
-        int Yi = 128 * r;
-        int i;
-
-        arraycopy(B, Bi, XY, Xi, 128 * r);
-
-        for (i = 0; i < N; i++) {
-            arraycopy(XY, Xi, V, i * (128 * r), 128 * r);
-            blockmix_salsa8(XY, Xi, Yi, r);
-        }
-
-        for (i = 0; i < N; i++) {
-            int j = integerify(XY, Xi, r) & (N - 1);
-            blockxor(V, j * (128 * r), XY, Xi, 128 * r);
-            blockmix_salsa8(XY, Xi, Yi, r);
-        }
-
-        arraycopy(XY, Xi, B, Bi, 128 * r);
-    }
-
-    public static void blockmix_salsa8(byte[] BY, int Bi, int Yi, int r) {
-        byte[] X = new byte[64];
-        int i;
-
-        arraycopy(BY, Bi + (2 * r - 1) * 64, X, 0, 64);
-
-        for (i = 0; i < 2 * r; i++) {
-            blockxor(BY, i * 64, X, 0, 64);
-            salsa20_8(X);
-            arraycopy(X, 0, BY, Yi + (i * 64), 64);
-        }
-
-        for (i = 0; i < r; i++) {
-            arraycopy(BY, Yi + (i * 2) * 64, BY, Bi + (i * 64), 64);
-        }
-
-        for (i = 0; i < r; i++) {
-            arraycopy(BY, Yi + (i * 2 + 1) * 64, BY, Bi + (i + r) * 64, 64);
-        }
-    }
-
-    public static int R(int a, int b) {
-        return (a << b) | (a >>> (32 - b));
-    }
-
-    public static void salsa20_8(byte[] B) {
-        int[] B32 = new int[16];
-        int[] x   = new int[16];
-        int i;
-
-        for (i = 0; i < 16; i++) {
-            B32[i]  = (B[i * 4 + 0] & 0xff) << 0;
-            B32[i] |= (B[i * 4 + 1] & 0xff) << 8;
-            B32[i] |= (B[i * 4 + 2] & 0xff) << 16;
-            B32[i] |= (B[i * 4 + 3] & 0xff) << 24;
-        }
-
-        arraycopy(B32, 0, x, 0, 16);
-
-        for (i = 8; i > 0; i -= 2) {
-            x[ 4] ^= R(x[ 0]+x[12], 7);  x[ 8] ^= R(x[ 4]+x[ 0], 9);
-            x[12] ^= R(x[ 8]+x[ 4],13);  x[ 0] ^= R(x[12]+x[ 8],18);
-            x[ 9] ^= R(x[ 5]+x[ 1], 7);  x[13] ^= R(x[ 9]+x[ 5], 9);
-            x[ 1] ^= R(x[13]+x[ 9],13);  x[ 5] ^= R(x[ 1]+x[13],18);
-            x[14] ^= R(x[10]+x[ 6], 7);  x[ 2] ^= R(x[14]+x[10], 9);
-            x[ 6] ^= R(x[ 2]+x[14],13);  x[10] ^= R(x[ 6]+x[ 2],18);
-            x[ 3] ^= R(x[15]+x[11], 7);  x[ 7] ^= R(x[ 3]+x[15], 9);
-            x[11] ^= R(x[ 7]+x[ 3],13);  x[15] ^= R(x[11]+x[ 7],18);
-            x[ 1] ^= R(x[ 0]+x[ 3], 7);  x[ 2] ^= R(x[ 1]+x[ 0], 9);
-            x[ 3] ^= R(x[ 2]+x[ 1],13);  x[ 0] ^= R(x[ 3]+x[ 2],18);
-            x[ 6] ^= R(x[ 5]+x[ 4], 7);  x[ 7] ^= R(x[ 6]+x[ 5], 9);
-            x[ 4] ^= R(x[ 7]+x[ 6],13);  x[ 5] ^= R(x[ 4]+x[ 7],18);
-            x[11] ^= R(x[10]+x[ 9], 7);  x[ 8] ^= R(x[11]+x[10], 9);
-            x[ 9] ^= R(x[ 8]+x[11],13);  x[10] ^= R(x[ 9]+x[ 8],18);
-            x[12] ^= R(x[15]+x[14], 7);  x[13] ^= R(x[12]+x[15], 9);
-            x[14] ^= R(x[13]+x[12],13);  x[15] ^= R(x[14]+x[13],18);
-        }
-
-        for (i = 0; i < 16; ++i) B32[i] = x[i] + B32[i];
-
-        for (i = 0; i < 16; i++) {
-            B[i * 4 + 0] = (byte) (B32[i] >> 0  & 0xff);
-            B[i * 4 + 1] = (byte) (B32[i] >> 8  & 0xff);
-            B[i * 4 + 2] = (byte) (B32[i] >> 16 & 0xff);
-            B[i * 4 + 3] = (byte) (B32[i] >> 24 & 0xff);
-        }
-    }
-
-    public static void blockxor(byte[] S, int Si, byte[] D, int Di, int len) {
-        for (int i = 0; i < len; i++) {
-            D[Di + i] ^= S[Si + i];
-        }
-    }
-
-    public static int integerify(byte[] B, int Bi, int r) {
-        int n;
-
-        Bi += (2 * r - 1) * 64;
-
-        n  = (B[Bi + 0] & 0xff) << 0;
-        n |= (B[Bi + 1] & 0xff) << 8;
-        n |= (B[Bi + 2] & 0xff) << 16;
-        n |= (B[Bi + 3] & 0xff) << 24;
-
-        return n;
-    }
-}

+ 0 - 236
app/src/main/java/com/lambdaworks/crypto/SCryptUtil.java

@@ -1,236 +0,0 @@
-// Copyright (C) 2011 - Will Glozer.  All rights reserved.
-
-package com.lambdaworks.crypto;
-
-import java.nio.ByteBuffer;
-import java.nio.CharBuffer;
-import java.nio.charset.Charset;
-import java.security.GeneralSecurityException;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
-import java.util.Arrays;
-
-import static com.lambdaworks.codec.Base64.decode;
-import static com.lambdaworks.codec.Base64.encode;
-
-/**
- * Simple {@link SCrypt} interface for hashing passwords using the
- * <a href="http://www.tarsnap.com/scrypt.html">scrypt</a> key derivation function
- * and comparing a plain text password to a hashed one. The hashed output is an
- * extended implementation of the Modular Crypt Format that also includes the scrypt
- * algorithm parameters.
- *
- * Format: <code>$s0$PARAMS$SALT$KEY</code>.
- *
- * <dl>
- * <dd>PARAMS</dd><dt>32-bit hex integer containing log2(N) (16 bits), r (8 bits), and p (8 bits)</dt>
- * <dd>SALT</dd><dt>base64-encoded salt</dt>
- * <dd>KEY</dd><dt>base64-encoded derived key</dt>
- * </dl>
- *
- * <code>s0</code> identifies version 0 of the scrypt format, using a 128-bit salt and 256-bit derived key.
- *
- * @author  Will Glozer
- */
-public class SCryptUtil {
-    private static final int SALT_BITS = 128;
-    private static final int DERIVED_KEY_BITS = 256;
-    private static final SecureRandom SECURE_RANDOM;
-    static {
-        try {
-            SECURE_RANDOM = SecureRandom.getInstance("SHA1PRNG");
-        } catch (NoSuchAlgorithmException e) {
-            throw new IllegalStateException("JVM doesn't support SHA1PRNG?");
-        }
-    }
-
-    /**
-     * Hash the supplied plaintext password and generate output in the format described
-     * in {@link SCryptUtil}.
-     *
-     * @param passwd    Password.
-     * @param N         Iteration count.
-     * @param r         Block size.
-     * @param p         Parallelization parameter.
-     *
-     * @return The hashed password.
-     */
-    public static String scrypt(String passwd, int N, int r, int p) {
-        byte[] bytes = passwd.getBytes(Charset.forName("UTF-8"));
-        try {
-            return scrypt(bytes, N, r, p);
-        } finally {
-            wipeArray(bytes);
-        }
-    }
-
-    /**
-     * Hash the supplied plaintext password and generate output in the format described
-     * in {@link SCryptUtil}.
-     *
-     * @param passwd Password.
-     * @param N      Iteration count.
-     * @param r      Block size.
-     * @param p      Parallelization parameter.
-     *
-     * @return The hashed password.
-     */
-    public static String scrypt(char[] passwd, int N, int r, int p) {
-        byte[] bytes = toBytes(passwd);
-        try {
-            return scrypt(bytes, N, r, p);
-        } finally {
-            wipeArray(bytes);
-        }
-    }
-
-    /**
-     * Hash the supplied plaintext password and generate output in the format described
-     * in {@link SCryptUtil}.
-     *
-     * @param passwordBytes Password.
-     * @param N             Iteration count.
-     * @param r             Block size.
-     * @param p             Parallelization parameter.
-     *
-     * @return The hashed password.
-     */
-    public static String scrypt(byte[] passwordBytes, int N, int r, int p) {
-        final byte[] salt = generateSalt();
-        return scrypt(passwordBytes, salt, N, r, p);
-    }
-
-    /**
-     * Hash the supplied plaintext password and generate output in the format described
-     * in {@link SCryptUtil}.
-     *
-     * Allows for passing in the salt in the rare case where you actually want to
-     * hash something to the same hash value. An example is hashing credit card
-     * numbers in order to detect duplicates without storing the actual card number.
-     *
-     * @param passwordBytes Password.
-     * @param salt          128 bit salt.
-     * @param N             Iteration count.
-     * @param r             Block size.
-     * @param p             Parallelization parameter.
-     *
-     * @return The hashed password.
-     * @see #generateSalt()
-     */
-    public static String scrypt(byte[] passwordBytes, byte[] salt, int N, int r, int p) {
-        try {
-            if (salt == null || salt.length != SALT_BITS / 8) {
-                throw new IllegalArgumentException("Salt must be " + SALT_BITS + " bits");
-            }
-
-            byte[] derived = SCrypt.scrypt(passwordBytes, salt, N, r, p, DERIVED_KEY_BITS / 8);
-
-            String params = Long.toString(log2(N) << 16L | r << 8 | p, 16);
-
-            StringBuilder sb = new StringBuilder((salt.length + derived.length) * 2);
-            sb.append("$s0$").append(params).append('$');
-            sb.append(encode(salt)).append('$');
-            sb.append(encode(derived));
-
-            return sb.toString();
-        } catch (GeneralSecurityException e) {
-            throw new IllegalStateException("JVM doesn't support HMAC_SHA256?");
-        }
-    }
-
-    private static byte[] toBytes(char[] chars) {
-        CharBuffer charBuffer = CharBuffer.wrap(chars);
-        ByteBuffer byteBuffer = Charset.forName("UTF-8").encode(charBuffer);
-        try {
-            return Arrays.copyOfRange(byteBuffer.array(), byteBuffer.position(), byteBuffer.limit());
-        } finally {
-            wipeArray(byteBuffer.array());
-        }
-    }
-
-    /**
-     * Compare the supplied plaintext password to a hashed password.
-     *
-     * @param   passwd  Plaintext password.
-     * @param   hashed  scrypt hashed password.
-     * @return true if passwd matches hashed value.
-     */
-    public static boolean check(String passwd, String hashed) {
-        byte[] bytes = passwd.getBytes(Charset.forName("UTF-8"));
-        try {
-            return checkInternal(bytes, hashed);
-        } finally {
-            wipeArray(bytes);
-        }
-    }
-
-    /**
-     * Compare the supplied plaintext password to a hashed password.
-     *
-     * @param   passwd  Plaintext password.
-     * @param   hashed  scrypt hashed password.
-     * @return true if passwd matches hashed value.
-     */
-    public static boolean check(char[] passwd, String hashed) {
-        byte[] bytes = toBytes(passwd);
-        try {
-            return checkInternal(bytes, hashed);
-        } finally {
-            wipeArray(bytes);
-        }
-    }
-
-    private static boolean checkInternal(byte[] passwordBytes, String hashed) {
-        try {
-            String[] parts = hashed.split("\\$");
-
-            if (parts.length != 5 || !parts[1].equals("s0")) {
-                throw new IllegalArgumentException("Invalid hashed value");
-            }
-
-            long params = Long.parseLong(parts[2], 16);
-            byte[] salt = decode(parts[3].toCharArray());
-            byte[] derived0 = decode(parts[4].toCharArray());
-
-            int N = (int) Math.pow(2, params >> 16 & 0xffff);
-            int r = (int) params >> 8 & 0xff;
-            int p = (int) params      & 0xff;
-
-            byte[] derived1 = SCrypt.scrypt(passwordBytes, salt, N, r, p, DERIVED_KEY_BITS / 8);
-
-            if (derived0.length != derived1.length) return false;
-
-            int result = 0;
-            for (int i = 0; i < derived0.length; i++) {
-                result |= derived0[i] ^ derived1[i];
-            }
-            return result == 0;
-        } catch (GeneralSecurityException e) {
-            throw new IllegalStateException("JVM doesn't support HMAC_SHA256?");
-        }
-    }
-
-    /**
-     * Generate a random 128 bit salt, in accordance with version 0 of the {@link SCryptUtil scrypt format}.
-     *
-     * @return 128 bit salt
-     */
-    public static byte[] generateSalt() {
-        final byte[] salt = new byte[SALT_BITS / 8];
-        SECURE_RANDOM.nextBytes(salt);
-        return salt;
-    }
-
-    private static int log2(int n) {
-        int log = 0;
-        if ((n & 0xffff0000 ) != 0) { n >>>= 16; log = 16; }
-        if (n >= 256) { n >>>= 8; log += 8; }
-        if (n >= 16 ) { n >>>= 4; log += 4; }
-        if (n >= 4  ) { n >>>= 2; log += 2; }
-        return log + (n >>> 1);
-    }
-
-    private static void wipeArray(byte[] array) {
-        Arrays.fill(array, (byte) 0);
-    }
-}

+ 0 - 21
app/src/main/java/com/lambdaworks/jni/LibraryLoader.java

@@ -1,21 +0,0 @@
-// Copyright (C) 2011 - Will Glozer.  All rights reserved.
-
-package com.lambdaworks.jni;
-
-/**
- * A {@code LibraryLoader} attempts to load the appropriate native library
- * for the current platform.
- *
- * @author Will Glozer
- */
-public interface LibraryLoader {
-    /**
-     * Load a native library, and optionally verify any signatures.
-     *
-     * @param name      Name of the library to load.
-     * @param verify    Verify signatures if signed.
-     *
-     * @return true if the library was successfully loaded.
-     */
-    boolean load(String name, boolean verify);
-}

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