Merge tag '2.18.0' of https://codeberg.org/iNPUTmice/Conversations

Stephen Paul Weber created

* tag '2.18.0' of https://codeberg.org/iNPUTmice/Conversations: (169 commits)
  use common trust manager in quicksy
  version bump to 2.18.0
  Translated using Weblate (Romanian)
  move common code to de.gultsch.common
  Translated using Weblate (Galician)
  Translated using Weblate (Serbian)
  Translated using Weblate (Estonian)
  Translated using Weblate (French)
  Translated using Weblate (Greek)
  Translated using Weblate (Romanian)
  Translated using Weblate (Portuguese (Brazil))
  Translated using Weblate (Ukrainian)
  Translated using Weblate (Russian)
  Translated using Weblate (Polish)
  Translated using Weblate (German)
  Translated using Weblate (Chinese (Simplified Han script))
  Translated using Weblate (Albanian)
  Translated using Weblate (Romanian)
  add some validation to fedilink (no query section)
  rewrite fedilinks to https when no app handles web+ap
  ...

Change summary

CHANGELOG.md                                                                                |   5 
build.gradle                                                                                |  23 
fastlane/metadata/android/de-DE/changelogs/4213304.txt                                      |   1 
fastlane/metadata/android/de-DE/changelogs/4213404.txt                                      |   1 
fastlane/metadata/android/de-DE/changelogs/4213904.txt                                      |   2 
fastlane/metadata/android/en-US/changelogs/4213904.txt                                      |   2 
fastlane/metadata/android/es-ES/changelogs/351.txt                                          |   2 
fastlane/metadata/android/es-ES/changelogs/394.txt                                          |   2 
fastlane/metadata/android/es-ES/changelogs/407.txt                                          |   2 
fastlane/metadata/android/es-ES/changelogs/42037.txt                                        |   2 
fastlane/metadata/android/es-ES/changelogs/4213204.txt                                      |   4 
fastlane/metadata/android/es-ES/changelogs/4213304.txt                                      |   1 
fastlane/metadata/android/es-ES/changelogs/4213404.txt                                      |   1 
fastlane/metadata/android/et/changelogs/383.txt                                             |   3 
fastlane/metadata/android/et/changelogs/387.txt                                             |   2 
fastlane/metadata/android/et/changelogs/388.txt                                             |   3 
fastlane/metadata/android/et/changelogs/390.txt                                             |   1 
fastlane/metadata/android/et/changelogs/393.txt                                             |   3 
fastlane/metadata/android/et/changelogs/394.txt                                             |   2 
fastlane/metadata/android/et/changelogs/395.txt                                             |   3 
fastlane/metadata/android/et/changelogs/398.txt                                             |   4 
fastlane/metadata/android/et/changelogs/401.txt                                             |   2 
fastlane/metadata/android/et/changelogs/402.txt                                             |   3 
fastlane/metadata/android/et/changelogs/403.txt                                             |   3 
fastlane/metadata/android/et/changelogs/407.txt                                             |   3 
fastlane/metadata/android/et/changelogs/42000.txt                                           |   4 
fastlane/metadata/android/et/changelogs/42006.txt                                           |   2 
fastlane/metadata/android/et/changelogs/42010.txt                                           |   2 
fastlane/metadata/android/et/changelogs/42012.txt                                           |   1 
fastlane/metadata/android/et/changelogs/42013.txt                                           |   1 
fastlane/metadata/android/et/changelogs/42014.txt                                           |   2 
fastlane/metadata/android/et/changelogs/42018.txt                                           |   3 
fastlane/metadata/android/et/changelogs/42022.txt                                           |   2 
fastlane/metadata/android/et/changelogs/42023.txt                                           |   2 
fastlane/metadata/android/et/changelogs/42037.txt                                           |  11 
fastlane/metadata/android/et/changelogs/4213904.txt                                         |   2 
fastlane/metadata/android/fr-FR/changelogs/351.txt                                          |   3 
fastlane/metadata/android/gl-ES/changelogs/4213204.txt                                      |   4 
fastlane/metadata/android/gl-ES/changelogs/4213304.txt                                      |   1 
fastlane/metadata/android/gl-ES/changelogs/4213404.txt                                      |   1 
fastlane/metadata/android/gl-ES/changelogs/4213904.txt                                      |   2 
fastlane/metadata/android/it-IT/changelogs/4213304.txt                                      |   1 
fastlane/metadata/android/it-IT/changelogs/4213404.txt                                      |   1 
fastlane/metadata/android/it-IT/changelogs/4213904.txt                                      |   2 
fastlane/metadata/android/pl-PL/changelogs/42042.txt                                        |   2 
fastlane/metadata/android/pl-PL/changelogs/42050.txt                                        |   1 
fastlane/metadata/android/pl-PL/changelogs/42059.txt                                        |   2 
fastlane/metadata/android/pl-PL/changelogs/4213304.txt                                      |   1 
fastlane/metadata/android/pl-PL/changelogs/4213404.txt                                      |   1 
fastlane/metadata/android/pl-PL/changelogs/4213904.txt                                      |   2 
fastlane/metadata/android/ru-RU/changelogs/4213304.txt                                      |   1 
fastlane/metadata/android/ru-RU/changelogs/4213404.txt                                      |   1 
fastlane/metadata/android/ru-RU/changelogs/4213904.txt                                      |   2 
fastlane/metadata/android/sq/changelogs/4213304.txt                                         |   1 
fastlane/metadata/android/sq/changelogs/4213404.txt                                         |   1 
fastlane/metadata/android/sq/changelogs/4213904.txt                                         |   2 
fastlane/metadata/android/uk/changelogs/4213904.txt                                         |   2 
fastlane/metadata/android/zh-CN/changelogs/4213904.txt                                      |   2 
libs/annotation-processor/build.gradle                                                      |   2 
src/conversations/AndroidManifest.xml                                                       |   9 
src/conversations/fastlane/metadata/android/es-ES/short_description.txt                     |   2 
src/conversations/fastlane/metadata/android/iw-IL/full_description.txt                      |  40 
src/conversations/fastlane/metadata/android/iw-IL/short_description.txt                     |   1 
src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java             | 503 
src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java                  | 379 
src/conversations/java/eu/siacs/conversations/ui/adapter/BackupFileAdapter.java             | 169 
src/conversations/res/layout/dialog_enter_password.xml                                      |  53 
src/conversations/res/values-iw/strings.xml                                                 |  16 
src/conversations/res/values-kab/strings.xml                                                |   5 
src/conversations/res/values-zh-rCN/strings.xml                                             |  10 
src/main/AndroidManifest.xml                                                                |  69 
src/main/java/de/gultsch/common/CombiningTrustManager.java                                  |  33 
src/main/java/de/gultsch/common/Linkify.java                                                | 150 
src/main/java/de/gultsch/common/MiniUri.java                                                | 129 
src/main/java/de/gultsch/common/Patterns.java                                               |  84 
src/main/java/de/gultsch/common/TrustManagers.java                                          |  39 
src/main/java/eu/siacs/conversations/AppSettings.java                                       |  52 
src/main/java/eu/siacs/conversations/crypto/BundledTrustManager.java                        |  65 
src/main/java/eu/siacs/conversations/crypto/DomainHostnameVerifier.java                     |  11 
src/main/java/eu/siacs/conversations/crypto/XmppDomainVerifier.java                         |  41 
src/main/java/eu/siacs/conversations/crypto/axolotl/SQLiteAxolotlStore.java                 | 955 
src/main/java/eu/siacs/conversations/entities/Account.java                                  |  19 
src/main/java/eu/siacs/conversations/entities/DownloadableFile.java                         | 163 
src/main/java/eu/siacs/conversations/entities/Message.java                                  |   9 
src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java                        |  77 
src/main/java/eu/siacs/conversations/parser/PresenceParser.java                             |   7 
src/main/java/eu/siacs/conversations/persistance/FileBackend.java                           |  44 
src/main/java/eu/siacs/conversations/services/MemorizingTrustManager.java                   |  21 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java                    |  31 
src/main/java/eu/siacs/conversations/ui/ChooseAccountForProfilePictureActivity.java         |   5 
src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java                      |   6 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java                           |  46 
src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java                            |  11 
src/main/java/eu/siacs/conversations/ui/OmemoActivity.java                                  |  39 
src/main/java/eu/siacs/conversations/ui/PublishGroupChatProfilePictureActivity.java         |  59 
src/main/java/eu/siacs/conversations/ui/PublishProfilePictureActivity.java                  | 117 
src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java                             |   1 
src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java                         |   4 
src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java                    |   8 
src/main/java/eu/siacs/conversations/ui/adapter/MediaAdapter.java                           |  15 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java                         |  14 
src/main/java/eu/siacs/conversations/ui/fragment/settings/BackupSettingsFragment.java       |  48 
src/main/java/eu/siacs/conversations/ui/text/FixedURLSpan.java                              |  54 
src/main/java/eu/siacs/conversations/ui/util/ShareUtil.java                                 |  84 
src/main/java/eu/siacs/conversations/utils/BackupFile.java                                  | 185 
src/main/java/eu/siacs/conversations/utils/GeoHelper.java                                   | 290 
src/main/java/eu/siacs/conversations/utils/IP.java                                          |  28 
src/main/java/eu/siacs/conversations/utils/IrregularUnicodeDetector.java                    |  12 
src/main/java/eu/siacs/conversations/utils/MimeUtils.java                                   |   6 
src/main/java/eu/siacs/conversations/utils/Patterns.java                                    | 103 
src/main/java/eu/siacs/conversations/utils/UIHelper.java                                    | 190 
src/main/java/eu/siacs/conversations/utils/XmppUri.java                                     |  11 
src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java                         | 177 
src/main/java/eu/siacs/conversations/worker/ImportBackupWorker.java                         | 389 
src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java                               |   7 
src/main/java/eu/siacs/conversations/xmpp/jingle/IceServers.java                            |  99 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java          |   5 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java                   |  13 
src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java                         |   9 
src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java                    |  12 
src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java                         |  31 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java           |  14 
src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/RtpDescription.java                |  23 
src/main/java/eu/siacs/conversations/xmpp/jingle/transports/WebRTCDataChannelTransport.java |  35 
src/main/java/im/conversations/android/xmpp/model/disco/external/Services.java              | 112 
src/main/res/drawable/ic_folder_open_24dp.xml                                               |  10 
src/main/res/layout/activity_import_backup.xml                                              |   0 
src/main/res/layout/dialog_enter_password.xml                                               |  47 
src/main/res/values-ar/strings.xml                                                          |   2 
src/main/res/values-bg/strings.xml                                                          |   2 
src/main/res/values-ca/strings.xml                                                          |   2 
src/main/res/values-cs/strings.xml                                                          |   2 
src/main/res/values-da-rDK/strings.xml                                                      |   2 
src/main/res/values-de/strings.xml                                                          |  27 
src/main/res/values-el/strings.xml                                                          |   2 
src/main/res/values-es/strings.xml                                                          | 229 
src/main/res/values-et/strings.xml                                                          |  22 
src/main/res/values-eu/strings.xml                                                          |   2 
src/main/res/values-fa-rIR/strings.xml                                                      |   2 
src/main/res/values-fr/strings.xml                                                          |  85 
src/main/res/values-gl/strings.xml                                                          |  20 
src/main/res/values-hu/strings.xml                                                          |   2 
src/main/res/values-id/strings.xml                                                          |   2 
src/main/res/values-it/strings.xml                                                          |  12 
src/main/res/values-iw/strings.xml                                                          | 104 
src/main/res/values-ja/strings.xml                                                          |   8 
src/main/res/values-kab/strings.xml                                                         | 234 
src/main/res/values-nl/strings.xml                                                          |   2 
src/main/res/values-pl/strings.xml                                                          |  25 
src/main/res/values-pt-rBR/strings.xml                                                      |  22 
src/main/res/values-ro-rRO/strings.xml                                                      |  60 
src/main/res/values-ru/strings.xml                                                          |  28 
src/main/res/values-sk/strings.xml                                                          |   2 
src/main/res/values-sq-rAL/strings.xml                                                      |  22 
src/main/res/values-sr/strings.xml                                                          |  20 
src/main/res/values-szl/strings.xml                                                         |   2 
src/main/res/values-tr-rTR/strings.xml                                                      |   2 
src/main/res/values-uk/strings.xml                                                          |  22 
src/main/res/values-vi/strings.xml                                                          |   2 
src/main/res/values-zh-rCN/strings.xml                                                      | 129 
src/main/res/values-zh-rTW/strings.xml                                                      |  20 
src/main/res/values/strings.xml                                                             |  20 
src/main/res/xml/preferences_backup.xml                                                     |   4 
src/quicksy/fastlane/metadata/android/iw-IL/full_description.txt                            |  38 
src/quicksy/fastlane/metadata/android/iw-IL/short_description.txt                           |   1 
src/quicksy/java/eu/siacs/conversations/services/QuickConversationsService.java             | 402 
src/quicksy/java/eu/siacs/conversations/ui/EnterPhoneNumberActivity.java                    | 214 
src/quicksy/java/eu/siacs/conversations/ui/VerifyActivity.java                              | 202 
src/quicksy/res/menu/verify_phone_number_menu.xml                                           |   8 
src/quicksy/res/values-el/strings.xml                                                       |   4 
src/quicksy/res/values-es/strings.xml                                                       |   8 
src/quicksy/res/values-iw/strings.xml                                                       |  12 
src/quicksy/res/values-kab/strings.xml                                                      |   6 
src/test/java/de/gultsch/common/MiniUriTest.java                                            |  62 
src/test/java/de/gultsch/common/PatternTest.java                                            | 120 
175 files changed, 4,383 insertions(+), 3,492 deletions(-)

Detailed changes

CHANGELOG.md 🔗

@@ -1,5 +1,10 @@
 # Changelog
 
+### Version 2.18.0
+
+* Add ability to pick backup location
+* More more URIs (tel:, mailto:) clickable
+
 ### Version 2.17.12
 
 * Fix crash on file transfer in fi translation

build.gradle 🔗

@@ -7,7 +7,7 @@ buildscript {
     }
     dependencies {
         classpath 'com.android.tools.build:gradle:8.5.2'
-        classpath "com.diffplug.spotless:spotless-plugin-gradle:6.25.0"
+        classpath "com.diffplug.spotless:spotless-plugin-gradle:7.0.2"
     }
 }
 
@@ -59,14 +59,14 @@ dependencies {
     androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
 
     implementation "androidx.core:core:1.10.1"
-    coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
+    coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
 
     implementation project(':libs:annotation')
     annotationProcessor project(':libs:annotation-processor')
 
     implementation 'androidx.viewpager:viewpager:1.1.0'
 
-    playstoreImplementation('com.google.firebase:firebase-messaging:24.1.0') {
+    playstoreImplementation('com.google.firebase:firebase-messaging:24.1.1') {
         exclude group: 'com.google.firebase', module: 'firebase-core'
         exclude group: 'com.google.firebase', module: 'firebase-analytics'
         exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
@@ -74,13 +74,13 @@ dependencies {
     cheogramPlaystoreImplementation("com.android.installreferrer:installreferrer:2.2")
     cheogramPlaystoreImplementation 'com.github.singpolyma:play-licensing:1c637ea03c'
     conversationsPlaystoreImplementation("com.android.installreferrer:installreferrer:2.2")
-    quicksyPlaystoreImplementation 'com.google.android.gms:play-services-auth-api-phone:18.1.0'
+    quicksyPlaystoreImplementation 'com.google.android.gms:play-services-auth-api-phone:18.2.0'
     implementation 'com.github.open-keychain.open-keychain:openpgp-api:v5.7.1'
-    implementation("com.github.CanHub:Android-Image-Cropper:2.0.0")
+    implementation("com.vanniktech:android-image-cropper:4.6.0")
     implementation "androidx.sharetarget:sharetarget:1.2.0"
 
     implementation 'androidx.appcompat:appcompat:1.7.0'
-    implementation 'androidx.exifinterface:exifinterface:1.3.7'
+    implementation 'androidx.exifinterface:exifinterface:1.4.0'
     implementation 'androidx.cardview:cardview:1.0.0'
     implementation "androidx.preference:preference:1.2.1"
     implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
@@ -90,8 +90,8 @@ dependencies {
     implementation "androidx.emoji2:emoji2:1.5.0"
     freeImplementation "androidx.emoji2:emoji2-bundled:1.5.0"
 
-    implementation 'org.bouncycastle:bcmail-jdk18on:1.78.1'
-    implementation 'org.bouncycastle:bcpg-jdk18on:1.78.1'
+    implementation 'org.bouncycastle:bcmail-jdk18on:1.80'
+    implementation 'org.bouncycastle:bcpg-jdk18on:1.80'
     implementation 'com.google.zxing:core:3.5.3'
     implementation 'org.minidns:minidns-hla:1.1.1'
     implementation 'me.leolin:ShortcutBadger:1.1.22@aar'
@@ -113,8 +113,8 @@ dependencies {
     implementation "com.squareup.retrofit2:converter-gson:2.11.0"
     implementation "com.squareup.okhttp3:okhttp:4.12.0"
 
-    implementation 'com.google.guava:guava:32.1.3-android'
-    implementation 'io.michaelrocks:libphonenumber-android:8.13.35'
+    implementation 'com.google.guava:guava:33.4.0-android'
+    implementation 'io.michaelrocks:libphonenumber-android:8.13.52'
     implementation 'im.conversations.webrtc:webrtc-android:129.0.0'
     implementation 'io.github.nishkarsh:android-permissions:2.1.6'
     implementation 'androidx.recyclerview:recyclerview:1.1.0'
@@ -135,6 +135,9 @@ dependencies {
     implementation 'net.fellbaum:jemoji:1.4.1'
     implementation 'com.github.natario1:Autocomplete:v1.1.0'
     implementation 'com.mikepenz:materialdrawer:9.0.1'
+
+    //Testing
+    testImplementation 'junit:junit:4.13.2'
 }
 
 ext {

fastlane/metadata/android/es-ES/changelogs/351.txt 🔗

@@ -1,3 +1,3 @@
 * Corrección de la transferencia de archivos Jingle IBB
-* Corrección de correcciones repetidas que llenaban la base de datos.
+* Corrección de correcciones repetidas que llenaban la base de datos
 * Transición a Last Message Correction v1.1

fastlane/metadata/android/es-ES/changelogs/394.txt 🔗

@@ -1,2 +1,2 @@
-* Se ha corregido el problema de las notificaciones que no aparecían en determinadas circunstancias.
+* Se ha corregido el problema de las notificaciones que no aparecían en determinadas circunstancias
 * Se han solucionado problemas de compatibilidad y bloqueos relacionados con las llamadas A/V

fastlane/metadata/android/es-ES/changelogs/407.txt 🔗

@@ -1,3 +1,3 @@
 * Mostrar botón de llamada para contactos desconectados si previamente anunciaron soporte
-* El botón Atrás ya no finaliza la llamada cuando está conectada.
+* El botón Atrás ya no finaliza la llamada cuando está conectada
 * Corrección de errores

fastlane/metadata/android/es-ES/changelogs/42037.txt 🔗

@@ -1,5 +1,5 @@
 Versión 2.10.9
-* Pedir permisos Bluetooth al hacer llamadas A/V (Puede rechazar esto si no utiliza auriculares Bluetooth).
+* Pedir permisos Bluetooth al hacer llamadas A/V (Puede rechazar esto si no utiliza auriculares Bluetooth)
 * Corrección de error al llamar a Movim
 * Corregir avatar incorrecto que se muestra para los chats de grupo
 * Preguntar siempre por las optimizaciones de batería

fastlane/metadata/android/es-ES/changelogs/4213204.txt 🔗

@@ -0,0 +1,4 @@
+* Permitir pausar la grabación de audio tocando el temporizador
+* Corregir las reacciones en mensajes privados de salas de chat (MUC PMs)
+* Dejar de aceptar 'mensajes de respaldo' para reacciones, confirmaciones de lectura y marcadores de visualización
+* Agregar más íconos de vista previa de medios

fastlane/metadata/android/et/changelogs/383.txt 🔗

@@ -0,0 +1,3 @@
+* Selleks, et muud tööriistariba ikoonid oleks järjepidevalt samas kohas, nihutasime kõne ikooni vasakule
+* Häälkõnede ajal näitame kõne kestust
+* Lisasime lahenduse samaaegsete hääl- ja videokõnede jaoks (olukord, kus kaks inimest helistavad samal hetkel üksteisele)

fastlane/metadata/android/et/changelogs/388.txt 🔗

@@ -0,0 +1,3 @@
+* Vähendasime mõnedes seadmetes kaja kõne keskel
+* Parandasime sisselogimisvea, kui salasõnas oli erilisi tähemärke
+* Videokõnede ajal esitame nüüd valjuhääldis valimistooni ja kinnist tooni

fastlane/metadata/android/et/changelogs/393.txt 🔗

@@ -0,0 +1,3 @@
+* Kui hääl- või videokõne ei õnnestu, siis näitame abiteabe nuppu
+* Parandasime mõned tüütud kokkujooksmised
+* Parandasime Jingle'i-põhiste ühenduste (failide teisaldamine + kõned) toimimise ainult JID alusel

fastlane/metadata/android/et/changelogs/398.txt 🔗

@@ -0,0 +1,4 @@
+* Üksikute vestluste otsing
+* Kasutaja teavitus, kui sõnumi edastamine ei õnnestu
+* Jätame meelde Quicksy kasutajate kuvatavad nimed (hüüdnimed) ka peale rakenduse taaskäivitamist
+* Lisasime nupu Orboti (Tor) teavitusest käivitamiseks (kui peaks vaja olema)

fastlane/metadata/android/et/changelogs/403.txt 🔗

@@ -0,0 +1,3 @@
+* Parandasime ühendusvyse vead, kui erinevad kasutajakontod kasutasid erinevaid SCRAMi meetodeid
+* Lisasime SCRAM-SHA-512 toe
+* Failide teisaldamine Jingle'i protokolliga võrdõigusvõrgus (P2P) iseenda kontoga

fastlane/metadata/android/et/changelogs/407.txt 🔗

@@ -0,0 +1,3 @@
+* Kui kasutajate kõnetugi on varem tuvastatud, siis kuvame kõne nupu ka siis, kui kasutaja pole võrgus
+* Tagasi-nupp ühendatud kõne puhul enam ei lõpeta kõnet
+* Veaparandused

fastlane/metadata/android/et/changelogs/42000.txt 🔗

@@ -0,0 +1,4 @@
+* Võimalus valida helinaid
+* Parandasime OpenPGP võtmete tuvastuse OpenKeychain 5.6+puhul
+* Mitte-ASCII tähtedes TLS sertifikaatide korrektne verifitseerimine
+* Stabiilsem RTP-sessioonide loomine (helistamisel)

fastlane/metadata/android/et/changelogs/42037.txt 🔗

@@ -0,0 +1,11 @@
+Versioon 2.10.9
+* Kõnede puhul BT loa küsimine (kui BT kõrvaklappe pole, siis keeldu)
+* Movimile helistamise vea parandus
+* Rühmavestluste avatariviga
+* Küsime akukasutuse optimeerimisest loobumist
+* „x kontot ühendatud“ arvestab vaid kohalikke ühendusi
+* Parandasime liidestuse Google Maps asukoha jagamise lisamooduliga
+* Serveritasu ääremärkus on eemaldatud
+* Failide salvestamine vastavalt Android 11 reeglitele
+* Kõne taastamine peale võrguvahetust
+* Helistaja ja konto JID saabuva kõne vaates

fastlane/metadata/android/gl-ES/changelogs/4213204.txt 🔗

@@ -0,0 +1,4 @@
+* Permitir pausar a gravación de audio tocando no temporizador
+* Arranxo das reaccións nas MUC PMs 
+* Deixar de aceptar 'fallback messages' para reaccións, acuses de recibo e marcadores de posición
+* Engadir máis iconas de previsualización multimedia

libs/annotation-processor/build.gradle 🔗

@@ -15,6 +15,6 @@ dependencies {
 
     annotationProcessor 'com.google.auto.service:auto-service:1.0.1'
     api 'com.google.auto.service:auto-service-annotations:1.0.1'
-    implementation 'com.google.guava:guava:31.1-jre'
+    implementation 'com.google.guava:guava:33.4.0-jre'
 
 }

src/conversations/AndroidManifest.xml 🔗

@@ -5,8 +5,8 @@
         <activity
             android:name=".ui.ManageAccountActivity"
             android:label="@string/title_activity_manage_accounts"
-            android:theme="@style/Theme.Conversations3"
-            android:launchMode="singleTask" />
+            android:launchMode="singleTask"
+            android:theme="@style/Theme.Conversations3" />
         <activity
             android:name=".ui.WelcomeActivity"
             android:label="@string/app_name"
@@ -23,10 +23,5 @@
             android:name=".ui.EasyOnboardingInviteActivity"
             android:label="@string/invite_to_app"
             android:launchMode="singleTask" />
-        <activity
-            android:name=".ui.ImportBackupActivity"
-            android:exported="false"
-            android:label="@string/restore_backup"
-            android:launchMode="singleTask" />
     </application>
 </manifest>

src/conversations/fastlane/metadata/android/iw-IL/full_description.txt 🔗

@@ -0,0 +1,40 @@
+קל לשימוש, אמין, ידידותי לסוללה. עם תמיכה מובנית בתמונות, צ'אטים קבוצתיים והצפנת e2e.
+
+עקרונות עיצוב:
+
+* עיצוב כמה שיותר יפה וקליל לשימוש מבלי להתפשר על אבטחה או פרטיות
+* מסתמך על פרוטוקולים קיימים ומבוססים היטב
+* אין צורך בחשבון Google או ספציפית Google Cloud Messaging (GCM)
+
+* משתמש בכמה שפחות הרשאות
+
+תכונות:
+
+* הצפנה מקצה לקצה באמצעות <a href="http://conversations.im/omemo/">OMEMO</a> או <a href="http://openpgp.org/about/">OpenPGP</a>
+* שליחת וקבלת תמונות
+* שיחות שמע ווידאו מוצפנות (DTLS-SRTP)
+* ממשק משתמש אינטואיטיבי העומד בהנחיות לעיצוב אנדרואיד
+* תמונות / אווטארים עבור אנשי הקשר שלך
+* מסתנכרן עם לקוח שולחן העבודה
+* ועידות (עם תמיכה בסימניות)
+* שילוב ספר כתובות
+* מספר חשבונות / תיבת דואר נכנס מאוחדת
+* השפעה נמוכה מאוד על חיי הסוללה
+
+Conversations מקלה מאוד על יצירת חשבון בשרת conversations.im החינמי. עם זאת, שיחות יעבדו גם עם כל שרת XMPP אחר. שרתי XMPP רבים מנוהלים על ידי מתנדבים והם ללא תשלום.
+
+תכונות XMPP:
+
+שיחות עובדות עם כל שרת XMPP בחוץ. עם זאת XMPP הוא פרוטוקול הניתן להרחבה. הרחבות אלה סטנדרטיות גם במה שנקרא XEP's. שיחות תומכות בכמה כאלה כדי לשפר את חווית המשתמש הכוללת. יש סיכוי ששרת ה-XMPP הנוכחי שלך אינו תומך בהרחבות אלו. לכן כדי להפיק את המרב מיישום זה, עליך לשקול לעבור לשרת XMPP שעושה זאת או - אפילו טוב יותר - ליצור שרת XMPP משלך עבורך ועבור חבריך.
+
+XEPs אלה הם - נכון לעכשיו:
+
+* XEP-0065: SOCKS5 Bytestreams (או mod_proxy65). ישמש להעברת קבצים אם שני הצדדים נמצאים מאחורי חומת אש או NAT.
+* XEP-0163: פרוטוקול אירועים אישיים לאוואטרים
+* XEP-0191: פקודת חסימה מאפשרת לך לרשום שולחי דואר זבל או לחסום אנשי קשר מבלי להסיר אותם מהסגל שלך.
+* XEP-0198: ניהול זרמים מאפשר ל-XMPP לשרוד הפסקות רשת קטנות ושינויים בחיבור ה-TCP הבסיסי.
+* XEP-0280: Message Carbons שמסנכרן אוטומטית את ההודעות שאתה שולח ללקוח שולחן העבודה שלך ובכך מאפשר לך לעבור בצורה חלקה מהלקוח הנייד שלך ללקוח שולחן העבודה שלך ובחזרה תוך שיחה אחת.
+* XEP-0237: גרסת רוסטר בעיקר כדי לחסוך ברוחב פס בחיבורים ניידים גרועים
+* XEP-0313: ניהול ארכיון הודעות סנכרן את היסטוריית ההודעות עם השרת. התעדכן בהודעות שנשלחו בזמן ששיחות היו במצב לא מקוון.
+* XEP-0352: חיווי מצב לקוח מאפשר לשרת לדעת אם שיחות נמצאות ברקע או לא. מאפשר לשרת לחסוך ברוחב פס על ידי מניעת חבילות לא חשובות.
+* XEP-0363: העלאת קבצי HTTP מאפשרת לך לשתף קבצים בוועידות ועם אנשי קשר לא מקוונים. דורש רכיב נוסף בשרת שלך.

src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java 🔗

@@ -1,503 +0,0 @@
-package eu.siacs.conversations.services;
-
-import static eu.siacs.conversations.utils.Compatibility.s;
-
-import android.app.Notification;
-import android.app.NotificationManager;
-import android.app.PendingIntent;
-import android.app.Service;
-import android.content.ContentValues;
-import android.content.Context;
-import android.content.Intent;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.net.Uri;
-import android.os.Binder;
-import android.os.IBinder;
-import android.provider.OpenableColumns;
-import android.util.Log;
-import androidx.core.app.NotificationCompat;
-import androidx.core.app.NotificationManagerCompat;
-import com.google.common.base.Charsets;
-import com.google.common.base.Stopwatch;
-import com.google.common.io.CountingInputStream;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.gson.stream.JsonReader;
-import com.google.gson.stream.JsonToken;
-import eu.siacs.conversations.Config;
-import eu.siacs.conversations.R;
-import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
-import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.entities.Conversation;
-import eu.siacs.conversations.entities.Message;
-import eu.siacs.conversations.persistance.DatabaseBackend;
-import eu.siacs.conversations.persistance.FileBackend;
-import eu.siacs.conversations.ui.ManageAccountActivity;
-import eu.siacs.conversations.utils.BackupFileHeader;
-import eu.siacs.conversations.utils.SerialSingleThreadExecutor;
-import eu.siacs.conversations.worker.ExportBackupWorker;
-import eu.siacs.conversations.xmpp.Jid;
-import java.io.BufferedReader;
-import java.io.DataInputStream;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import java.util.WeakHashMap;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.regex.Pattern;
-import java.util.zip.GZIPInputStream;
-import java.util.zip.ZipException;
-import javax.crypto.BadPaddingException;
-import org.bouncycastle.crypto.engines.AESEngine;
-import org.bouncycastle.crypto.io.CipherInputStream;
-import org.bouncycastle.crypto.modes.AEADBlockCipher;
-import org.bouncycastle.crypto.modes.GCMBlockCipher;
-import org.bouncycastle.crypto.params.AEADParameters;
-import org.bouncycastle.crypto.params.KeyParameter;
-
-public class ImportBackupService extends Service {
-
-    private static final ExecutorService BACKUP_FILE_READER_EXECUTOR =
-            Executors.newSingleThreadExecutor();
-
-    private static final int NOTIFICATION_ID = 21;
-    private static final AtomicBoolean running = new AtomicBoolean(false);
-    private final ImportBackupServiceBinder binder = new ImportBackupServiceBinder();
-    private final SerialSingleThreadExecutor executor =
-            new SerialSingleThreadExecutor(getClass().getSimpleName());
-    private final Set<OnBackupProcessed> mOnBackupProcessedListeners =
-            Collections.newSetFromMap(new WeakHashMap<>());
-    private DatabaseBackend mDatabaseBackend;
-    private NotificationManager notificationManager;
-
-    private static final Collection<String> TABLE_ALLOW_LIST =
-            Arrays.asList(
-                    Account.TABLENAME,
-                    Conversation.TABLENAME,
-                    Message.TABLENAME,
-                    SQLiteAxolotlStore.PREKEY_TABLENAME,
-                    SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
-                    SQLiteAxolotlStore.SESSION_TABLENAME,
-                    SQLiteAxolotlStore.IDENTITIES_TABLENAME);
-    private static final Pattern COLUMN_PATTERN = Pattern.compile("^[a-zA-Z_]+$");
-
-    @Override
-    public void onCreate() {
-        mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext());
-        notificationManager =
-                (android.app.NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
-    }
-
-    @Override
-    public int onStartCommand(Intent intent, int flags, int startId) {
-        if (intent == null) {
-            return START_NOT_STICKY;
-        }
-        final String password = intent.getStringExtra("password");
-        final Uri data = intent.getData();
-        final Uri uri;
-        if (data == null) {
-            final String file = intent.getStringExtra("file");
-            uri = file == null ? null : Uri.fromFile(new File(file));
-        } else {
-            uri = data;
-        }
-
-        if (password == null || password.isEmpty() || uri == null) {
-            return START_NOT_STICKY;
-        }
-        if (running.compareAndSet(false, true)) {
-            executor.execute(
-                    () -> {
-                        startForegroundService();
-                        final boolean success = importBackup(uri, password);
-                        stopForeground(true);
-                        running.set(false);
-                        if (success) {
-                            notifySuccess();
-                        }
-                        stopSelf();
-                    });
-        } else {
-            Log.d(Config.LOGTAG, "backup already running");
-        }
-        return START_NOT_STICKY;
-    }
-
-    public boolean getLoadingState() {
-        return running.get();
-    }
-
-    public void loadBackupFiles(final OnBackupFilesLoaded onBackupFilesLoaded) {
-        executor.execute(
-                () -> {
-                    final List<Jid> accounts = mDatabaseBackend.getAccountJids(false);
-                    final ArrayList<BackupFile> backupFiles = new ArrayList<>();
-                    final Set<String> apps =
-                            new HashSet<>(
-                                    Arrays.asList(
-                                            "Conversations",
-                                            "Quicksy",
-                                            getString(R.string.app_name)));
-                    final List<File> directories = new ArrayList<>();
-                    for (final String app : apps) {
-                        directories.add(FileBackend.getLegacyBackupDirectory(app));
-                    }
-                    directories.add(FileBackend.getBackupDirectory(this));
-                    for (final File directory : directories) {
-                        if (!directory.exists() || !directory.isDirectory()) {
-                            Log.d(
-                                    Config.LOGTAG,
-                                    "directory not found: " + directory.getAbsolutePath());
-                            continue;
-                        }
-                        final File[] files = directory.listFiles();
-                        if (files == null) {
-                            continue;
-                        }
-                        Log.d(Config.LOGTAG, "looking for backups in " + directory);
-                        for (final File file : files) {
-                            if (file.isFile() && file.getName().endsWith(".ceb")) {
-                                try {
-                                    final BackupFile backupFile = BackupFile.read(file);
-                                    if (accounts.contains(backupFile.getHeader().getJid())) {
-                                        Log.d(
-                                                Config.LOGTAG,
-                                                "skipping backup for "
-                                                        + backupFile.getHeader().getJid());
-                                    } else {
-                                        backupFiles.add(backupFile);
-                                    }
-                                } catch (final IOException
-                                        | IllegalArgumentException
-                                        | BackupFileHeader.OutdatedBackupFileVersion e) {
-                                    Log.d(Config.LOGTAG, "unable to read backup file ", e);
-                                }
-                            }
-                        }
-                    }
-                    Collections.sort(
-                            backupFiles, Comparator.comparing(a -> a.header.getJid().toString()));
-                    onBackupFilesLoaded.onBackupFilesLoaded(backupFiles);
-                });
-    }
-
-    private void startForegroundService() {
-        startForeground(NOTIFICATION_ID, createImportBackupNotification(1, 0));
-    }
-
-    private void updateImportBackupNotification(final long total, final long current) {
-        final int max;
-        final int progress;
-        if (total == 0) {
-            max = 1;
-            progress = 0;
-        } else {
-            max = 100;
-            progress = (int) (current * 100 / total);
-        }
-        final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
-        try {
-            notificationManager.notify(
-                    NOTIFICATION_ID, createImportBackupNotification(max, progress));
-        } catch (final RuntimeException e) {
-            Log.d(Config.LOGTAG, "unable to make notification", e);
-        }
-    }
-
-    private Notification createImportBackupNotification(final int max, final int progress) {
-        NotificationCompat.Builder mBuilder =
-                new NotificationCompat.Builder(getBaseContext(), "backup");
-        mBuilder.setContentTitle(getString(R.string.restoring_backup))
-                .setSmallIcon(R.drawable.ic_unarchive_24dp)
-                .setProgress(max, progress, max == 1 && progress == 0);
-        return mBuilder.build();
-    }
-
-    private boolean importBackup(final Uri uri, final String password) {
-        Log.d(Config.LOGTAG, "importing backup from " + uri);
-        final Stopwatch stopwatch = Stopwatch.createStarted();
-        try {
-            final SQLiteDatabase db = mDatabaseBackend.getWritableDatabase();
-            final InputStream inputStream;
-            final String path = uri.getPath();
-            final long fileSize;
-            if ("file".equals(uri.getScheme()) && path != null) {
-                final File file = new File(path);
-                inputStream = new FileInputStream(file);
-                fileSize = file.length();
-            } else {
-                final Cursor returnCursor = getContentResolver().query(uri, null, null, null, null);
-                if (returnCursor == null) {
-                    fileSize = 0;
-                } else {
-                    returnCursor.moveToFirst();
-                    fileSize =
-                            returnCursor.getLong(
-                                    returnCursor.getColumnIndexOrThrow(OpenableColumns.SIZE));
-                    returnCursor.close();
-                }
-                inputStream = getContentResolver().openInputStream(uri);
-            }
-            if (inputStream == null) {
-                synchronized (mOnBackupProcessedListeners) {
-                    for (final OnBackupProcessed l : mOnBackupProcessedListeners) {
-                        l.onBackupRestoreFailed();
-                    }
-                }
-                return false;
-            }
-            final CountingInputStream countingInputStream = new CountingInputStream(inputStream);
-            final DataInputStream dataInputStream = new DataInputStream(countingInputStream);
-            final BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
-            Log.d(Config.LOGTAG, backupFileHeader.toString());
-
-            if (mDatabaseBackend.getAccountJids(false).contains(backupFileHeader.getJid())) {
-                synchronized (mOnBackupProcessedListeners) {
-                    for (OnBackupProcessed l : mOnBackupProcessedListeners) {
-                        l.onAccountAlreadySetup();
-                    }
-                }
-                return false;
-            }
-
-            final byte[] key = ExportBackupWorker.getKey(password, backupFileHeader.getSalt());
-
-            final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
-            cipher.init(
-                    false,
-                    new AEADParameters(new KeyParameter(key), 128, backupFileHeader.getIv()));
-            final CipherInputStream cipherInputStream =
-                    new CipherInputStream(countingInputStream, cipher);
-
-            final GZIPInputStream gzipInputStream = new GZIPInputStream(cipherInputStream);
-            final BufferedReader reader =
-                    new BufferedReader(new InputStreamReader(gzipInputStream, Charsets.UTF_8));
-            final JsonReader jsonReader = new JsonReader(reader);
-            if (jsonReader.peek() == JsonToken.BEGIN_ARRAY) {
-                jsonReader.beginArray();
-            } else {
-                throw new IllegalStateException("Backup file did not begin with array");
-            }
-            db.beginTransaction();
-            while (jsonReader.hasNext()) {
-                if (jsonReader.peek() == JsonToken.BEGIN_OBJECT) {
-                    importRow(db, jsonReader, backupFileHeader.getJid(), password);
-                } else if (jsonReader.peek() == JsonToken.END_ARRAY) {
-                    jsonReader.endArray();
-                    continue;
-                }
-                updateImportBackupNotification(fileSize, countingInputStream.getCount());
-            }
-            db.setTransactionSuccessful();
-            db.endTransaction();
-            final Jid jid = backupFileHeader.getJid();
-            final Cursor countCursor =
-                    db.rawQuery(
-                            "select count(messages.uuid) from messages join conversations on"
-                                + " conversations.uuid=messages.conversationUuid join accounts on"
-                                + " conversations.accountUuid=accounts.uuid where"
-                                + " accounts.username=? and accounts.server=?",
-                            new String[] {jid.getLocal(), jid.getDomain().toString()});
-            countCursor.moveToFirst();
-            final int count = countCursor.getInt(0);
-            Log.d(
-                    Config.LOGTAG,
-                    String.format(
-                            "restored %d messages in %s", count, stopwatch.stop().toString()));
-            countCursor.close();
-            stopBackgroundService();
-            synchronized (mOnBackupProcessedListeners) {
-                for (OnBackupProcessed l : mOnBackupProcessedListeners) {
-                    l.onBackupRestored();
-                }
-            }
-            return true;
-        } catch (final Exception e) {
-            final Throwable throwable = e.getCause();
-            final boolean reasonWasCrypto =
-                    throwable instanceof BadPaddingException || e instanceof ZipException;
-            synchronized (mOnBackupProcessedListeners) {
-                for (OnBackupProcessed l : mOnBackupProcessedListeners) {
-                    if (reasonWasCrypto) {
-                        l.onBackupDecryptionFailed();
-                    } else {
-                        l.onBackupRestoreFailed();
-                    }
-                }
-            }
-            Log.d(Config.LOGTAG, "error restoring backup " + uri, e);
-            return false;
-        }
-    }
-
-    private void importRow(
-            final SQLiteDatabase db,
-            final JsonReader jsonReader,
-            final Jid account,
-            final String passphrase)
-            throws IOException {
-        jsonReader.beginObject();
-        final String firstParameter = jsonReader.nextName();
-        if (!firstParameter.equals("table")) {
-            throw new IllegalStateException("Expected key 'table'");
-        }
-        final String table = jsonReader.nextString();
-        if (!TABLE_ALLOW_LIST.contains(table)) {
-            throw new IOException(String.format("%s is not recognized for import", table));
-        }
-        final ContentValues contentValues = new ContentValues();
-        final String secondParameter = jsonReader.nextName();
-        if (!secondParameter.equals("values")) {
-            throw new IllegalStateException("Expected key 'values'");
-        }
-        jsonReader.beginObject();
-        while (jsonReader.peek() != JsonToken.END_OBJECT) {
-            final String name = jsonReader.nextName();
-            if (COLUMN_PATTERN.matcher(name).matches()) {
-                if (jsonReader.peek() == JsonToken.NULL) {
-                    jsonReader.nextNull();
-                    contentValues.putNull(name);
-                } else if (jsonReader.peek() == JsonToken.NUMBER) {
-                    contentValues.put(name, jsonReader.nextLong());
-                } else {
-                    contentValues.put(name, jsonReader.nextString());
-                }
-            } else {
-                throw new IOException(String.format("Unexpected column name %s", name));
-            }
-        }
-        jsonReader.endObject();
-        jsonReader.endObject();
-        if (Account.TABLENAME.equals(table)) {
-            final Jid jid =
-                    Jid.of(
-                            contentValues.getAsString(Account.USERNAME),
-                            contentValues.getAsString(Account.SERVER),
-                            null);
-            final String password = contentValues.getAsString(Account.PASSWORD);
-            if (jid.equals(account) && passphrase.equals(password)) {
-                Log.d(Config.LOGTAG, "jid and password from backup header had matching row");
-            } else {
-                throw new IOException("jid or password in table did not match backup");
-            }
-        }
-        db.insert(table, null, contentValues);
-    }
-
-    private void notifySuccess() {
-        NotificationCompat.Builder mBuilder =
-                new NotificationCompat.Builder(getBaseContext(), "backup");
-        mBuilder.setContentTitle(getString(R.string.notification_restored_backup_title))
-                .setContentText(getString(R.string.notification_restored_backup_subtitle))
-                .setAutoCancel(true)
-                .setContentIntent(
-                        PendingIntent.getActivity(
-                                this,
-                                145,
-                                new Intent(this, ManageAccountActivity.class),
-                                s()
-                                        ? PendingIntent.FLAG_IMMUTABLE
-                                                | PendingIntent.FLAG_UPDATE_CURRENT
-                                        : PendingIntent.FLAG_UPDATE_CURRENT))
-                .setSmallIcon(R.drawable.ic_unarchive_24dp);
-        notificationManager.notify(NOTIFICATION_ID, mBuilder.build());
-    }
-
-    private void stopBackgroundService() {
-        Intent intent = new Intent(this, XmppConnectionService.class);
-        stopService(intent);
-    }
-
-    public void removeOnBackupProcessedListener(OnBackupProcessed listener) {
-        synchronized (mOnBackupProcessedListeners) {
-            mOnBackupProcessedListeners.remove(listener);
-        }
-    }
-
-    public void addOnBackupProcessedListener(OnBackupProcessed listener) {
-        synchronized (mOnBackupProcessedListeners) {
-            mOnBackupProcessedListeners.add(listener);
-        }
-    }
-
-    public static ListenableFuture<BackupFile> read(final Context context, final Uri uri) {
-        return Futures.submit(() -> BackupFile.read(context, uri), BACKUP_FILE_READER_EXECUTOR);
-    }
-
-    @Override
-    public IBinder onBind(Intent intent) {
-        return this.binder;
-    }
-
-    public interface OnBackupFilesLoaded {
-        void onBackupFilesLoaded(List<BackupFile> files);
-    }
-
-    public interface OnBackupProcessed {
-        void onBackupRestored();
-
-        void onBackupDecryptionFailed();
-
-        void onBackupRestoreFailed();
-
-        void onAccountAlreadySetup();
-    }
-
-    public static class BackupFile {
-        private final Uri uri;
-        private final BackupFileHeader header;
-
-        private BackupFile(Uri uri, BackupFileHeader header) {
-            this.uri = uri;
-            this.header = header;
-        }
-
-        private static BackupFile read(File file) throws IOException {
-            final FileInputStream fileInputStream = new FileInputStream(file);
-            final DataInputStream dataInputStream = new DataInputStream(fileInputStream);
-            BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
-            fileInputStream.close();
-            return new BackupFile(Uri.fromFile(file), backupFileHeader);
-        }
-
-        public static BackupFile read(final Context context, final Uri uri) throws IOException {
-            final InputStream inputStream = context.getContentResolver().openInputStream(uri);
-            if (inputStream == null) {
-                throw new FileNotFoundException();
-            }
-            final DataInputStream dataInputStream = new DataInputStream(inputStream);
-            final BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
-            inputStream.close();
-            return new BackupFile(uri, backupFileHeader);
-        }
-
-        public BackupFileHeader getHeader() {
-            return header;
-        }
-
-        public Uri getUri() {
-            return uri;
-        }
-    }
-
-    public class ImportBackupServiceBinder extends Binder {
-        public ImportBackupService getService() {
-            return ImportBackupService.this;
-        }
-    }
-}

src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java 🔗

@@ -1,379 +0,0 @@
-package eu.siacs.conversations.ui;
-
-import android.Manifest;
-import android.content.ComponentName;
-import android.content.Context;
-import android.content.DialogInterface;
-import android.content.Intent;
-import android.content.ServiceConnection;
-import android.content.pm.PackageManager;
-import android.net.Uri;
-import android.os.Build;
-import android.os.Bundle;
-import android.os.IBinder;
-import android.util.Log;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuItem;
-import android.view.View;
-
-import androidx.activity.result.ActivityResultLauncher;
-import androidx.activity.result.contract.ActivityResultContracts;
-import androidx.annotation.NonNull;
-import androidx.appcompat.app.AlertDialog;
-import androidx.core.content.ContextCompat;
-import androidx.databinding.DataBindingUtil;
-
-import com.google.android.material.dialog.MaterialAlertDialogBuilder;
-import com.google.android.material.snackbar.Snackbar;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.util.concurrent.FutureCallback;
-import com.google.common.util.concurrent.Futures;
-
-import eu.siacs.conversations.Config;
-import eu.siacs.conversations.R;
-import eu.siacs.conversations.databinding.ActivityImportBackupBinding;
-import eu.siacs.conversations.databinding.DialogEnterPasswordBinding;
-import eu.siacs.conversations.services.ImportBackupService;
-import eu.siacs.conversations.ui.adapter.BackupFileAdapter;
-import eu.siacs.conversations.ui.util.MainThreadExecutor;
-import eu.siacs.conversations.utils.BackupFileHeader;
-
-import java.io.IOException;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-
-public class ImportBackupActivity extends ActionBarActivity
-        implements ServiceConnection,
-                ImportBackupService.OnBackupFilesLoaded,
-                BackupFileAdapter.OnItemClickedListener,
-                ImportBackupService.OnBackupProcessed {
-
-    private ActivityImportBackupBinding binding;
-
-    private BackupFileAdapter backupFileAdapter;
-    private ImportBackupService service;
-
-    private boolean mLoadingState = false;
-    private final ActivityResultLauncher<String[]> requestPermissions =
-            registerForActivityResult(
-                    new ActivityResultContracts.RequestMultiplePermissions(),
-                    results -> {
-                        if (results.containsValue(Boolean.TRUE)) {
-                            final var service = this.service;
-                            if (service == null) {
-                                return;
-                            }
-                            service.loadBackupFiles(this);
-                        }
-                    });
-
-    @Override
-    protected void onCreate(final Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        binding = DataBindingUtil.setContentView(this, R.layout.activity_import_backup);
-        Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
-        setSupportActionBar(binding.toolbar);
-        setLoadingState(
-                savedInstanceState != null
-                        && savedInstanceState.getBoolean("loading_state", false));
-        this.backupFileAdapter = new BackupFileAdapter();
-        this.binding.list.setAdapter(this.backupFileAdapter);
-        this.backupFileAdapter.setOnItemClickedListener(this);
-    }
-
-    @Override
-    public boolean onCreateOptionsMenu(final Menu menu) {
-        getMenuInflater().inflate(R.menu.import_backup, menu);
-        final MenuItem openBackup = menu.findItem(R.id.action_open_backup_file);
-        openBackup.setVisible(!this.mLoadingState);
-        return true;
-    }
-
-    @Override
-    public void onSaveInstanceState(Bundle bundle) {
-        bundle.putBoolean("loading_state", this.mLoadingState);
-        super.onSaveInstanceState(bundle);
-    }
-
-    @Override
-    public void onStart() {
-
-        super.onStart();
-        bindService(new Intent(this, ImportBackupService.class), this, Context.BIND_AUTO_CREATE);
-        final Intent intent = getIntent();
-        if (intent != null
-                && Intent.ACTION_VIEW.equals(intent.getAction())
-                && !this.mLoadingState) {
-            Uri uri = intent.getData();
-            if (uri != null) {
-                openBackupFileFromUri(uri, true);
-                return;
-            }
-        }
-        final List<String> desiredPermission;
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
-            desiredPermission =
-                    ImmutableList.of(
-                            Manifest.permission.READ_MEDIA_IMAGES,
-                            Manifest.permission.READ_MEDIA_VIDEO,
-                            Manifest.permission.READ_MEDIA_AUDIO,
-                            Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED);
-        } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.TIRAMISU) {
-            desiredPermission =
-                    ImmutableList.of(
-                            Manifest.permission.READ_MEDIA_IMAGES,
-                            Manifest.permission.READ_MEDIA_VIDEO,
-                            Manifest.permission.READ_MEDIA_AUDIO);
-        } else {
-            desiredPermission = ImmutableList.of(Manifest.permission.READ_EXTERNAL_STORAGE);
-        }
-        final Set<String> declaredPermission = getDeclaredPermission();
-        if (declaredPermission.containsAll(desiredPermission)) {
-            requestPermissions.launch(desiredPermission.toArray(new String[0]));
-        } else {
-            Log.d(Config.LOGTAG, "Manifest is lacking some desired permission. not requesting");
-        }
-    }
-
-    private Set<String> getDeclaredPermission() {
-        final String[] permissions;
-        try {
-            permissions =
-                    getPackageManager()
-                            .getPackageInfo(getPackageName(), PackageManager.GET_PERMISSIONS)
-                            .requestedPermissions;
-        } catch (final PackageManager.NameNotFoundException e) {
-            return Collections.emptySet();
-        }
-        return ImmutableSet.copyOf(permissions);
-    }
-
-    @Override
-    public void onStop() {
-        super.onStop();
-        if (this.service != null) {
-            this.service.removeOnBackupProcessedListener(this);
-        }
-        unbindService(this);
-    }
-
-    @Override
-    public void onServiceConnected(ComponentName name, IBinder service) {
-        ImportBackupService.ImportBackupServiceBinder binder =
-                (ImportBackupService.ImportBackupServiceBinder) service;
-        this.service = binder.getService();
-        this.service.addOnBackupProcessedListener(this);
-        setLoadingState(this.service.getLoadingState());
-        this.service.loadBackupFiles(this);
-    }
-
-    @Override
-    public void onServiceDisconnected(ComponentName name) {
-        this.service = null;
-    }
-
-    @Override
-    public void onBackupFilesLoaded(final List<ImportBackupService.BackupFile> files) {
-        runOnUiThread(() -> backupFileAdapter.setFiles(files));
-    }
-
-    @Override
-    public void onClick(final ImportBackupService.BackupFile backupFile) {
-        showEnterPasswordDialog(backupFile, false);
-    }
-
-    private void openBackupFileFromUri(final Uri uri, final boolean finishOnCancel) {
-        final var backupFileFuture = ImportBackupService.read(this, uri);
-        Futures.addCallback(
-                backupFileFuture,
-                new FutureCallback<>() {
-                    @Override
-                    public void onSuccess(final ImportBackupService.BackupFile backupFile) {
-                        showEnterPasswordDialog(backupFile, finishOnCancel);
-                    }
-
-                    @Override
-                    public void onFailure(@NonNull final Throwable throwable) {
-                        Log.d(Config.LOGTAG, "could not open backup file " + uri, throwable);
-                        showBackupThrowable(throwable);
-                    }
-                },
-                MainThreadExecutor.getInstance());
-    }
-
-    private void showBackupThrowable(final Throwable throwable) {
-        if (throwable instanceof BackupFileHeader.OutdatedBackupFileVersion) {
-            Snackbar.make(
-                            binding.coordinator,
-                            R.string.outdated_backup_file_format,
-                            Snackbar.LENGTH_LONG)
-                    .show();
-        } else if (throwable instanceof IOException
-                || throwable instanceof IllegalArgumentException) {
-            Snackbar.make(binding.coordinator, R.string.not_a_backup_file, Snackbar.LENGTH_LONG)
-                    .show();
-        } else if (throwable instanceof SecurityException e) {
-            Snackbar.make(
-                            binding.coordinator,
-                            R.string.sharing_application_not_grant_permission,
-                            Snackbar.LENGTH_LONG)
-                    .show();
-        }
-    }
-
-    private void showEnterPasswordDialog(
-            final ImportBackupService.BackupFile backupFile, final boolean finishOnCancel) {
-        final DialogEnterPasswordBinding enterPasswordBinding =
-                DataBindingUtil.inflate(
-                        LayoutInflater.from(this), R.layout.dialog_enter_password, null, false);
-        Log.d(Config.LOGTAG, "attempting to import " + backupFile.getUri());
-        enterPasswordBinding.explain.setText(
-                getString(
-                        R.string.enter_password_to_restore,
-                        backupFile.getHeader().getJid().toString()));
-        final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
-        builder.setView(enterPasswordBinding.getRoot());
-        builder.setTitle(R.string.enter_password);
-        builder.setNegativeButton(
-                R.string.cancel,
-                (dialog, which) -> {
-                    if (finishOnCancel) {
-                        finish();
-                    }
-                });
-        builder.setPositiveButton(R.string.restore, null);
-        builder.setCancelable(false);
-        final AlertDialog dialog = builder.create();
-        dialog.setOnShowListener(
-                (d) -> {
-                    dialog.getButton(DialogInterface.BUTTON_POSITIVE)
-                            .setOnClickListener(
-                                    v -> {
-                                        final String password =
-                                                enterPasswordBinding
-                                                        .accountPassword
-                                                        .getEditableText()
-                                                        .toString();
-                                        if (password.isEmpty()) {
-                                            enterPasswordBinding.accountPasswordLayout.setError(
-                                                    getString(R.string.please_enter_password));
-                                            return;
-                                        }
-                                        final Intent intent = getIntent(backupFile, password);
-                                        setLoadingState(true);
-                                        ContextCompat.startForegroundService(this, intent);
-                                        d.dismiss();
-                                    });
-                });
-        dialog.show();
-    }
-
-    @NonNull
-    private Intent getIntent(ImportBackupService.BackupFile backupFile, String password) {
-        final Uri uri = backupFile.getUri();
-        Intent intent = new Intent(this, ImportBackupService.class);
-        intent.setAction(Intent.ACTION_SEND);
-        intent.putExtra("password", password);
-        if ("file".equals(uri.getScheme())) {
-            intent.putExtra("file", uri.getPath());
-        } else {
-            intent.setData(uri);
-            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
-        }
-        return intent;
-    }
-
-    private void setLoadingState(final boolean loadingState) {
-        binding.coordinator.setVisibility(loadingState ? View.GONE : View.VISIBLE);
-        binding.inProgress.setVisibility(loadingState ? View.VISIBLE : View.GONE);
-        setTitle(loadingState ? R.string.restoring_backup : R.string.restore_backup);
-        Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
-        configureActionBar(getSupportActionBar(), !loadingState);
-        this.mLoadingState = loadingState;
-        invalidateOptionsMenu();
-    }
-
-    @Override
-    public void onActivityResult(int requestCode, int resultCode, Intent intent) {
-        super.onActivityResult(requestCode, resultCode, intent);
-        if (resultCode == RESULT_OK) {
-            if (requestCode == 0xbac) {
-                openBackupFileFromUri(intent.getData(), false);
-            }
-        }
-    }
-
-    @Override
-    public void onAccountAlreadySetup() {
-        runOnUiThread(
-                () -> {
-                    setLoadingState(false);
-                    Snackbar.make(
-                                    binding.coordinator,
-                                    R.string.account_already_setup,
-                                    Snackbar.LENGTH_LONG)
-                            .show();
-                });
-    }
-
-    @Override
-    public void onBackupRestored() {
-        runOnUiThread(
-                () -> {
-                    Intent intent = new Intent(this, ConversationActivity.class);
-                    intent.addFlags(
-                            Intent.FLAG_ACTIVITY_CLEAR_TOP
-                                    | Intent.FLAG_ACTIVITY_NEW_TASK
-                                    | Intent.FLAG_ACTIVITY_CLEAR_TASK);
-                    startActivity(intent);
-                    finish();
-                });
-    }
-
-    @Override
-    public void onBackupDecryptionFailed() {
-        runOnUiThread(
-                () -> {
-                    setLoadingState(false);
-                    Snackbar.make(
-                                    binding.coordinator,
-                                    R.string.unable_to_decrypt_backup,
-                                    Snackbar.LENGTH_LONG)
-                            .show();
-                });
-    }
-
-    @Override
-    public void onBackupRestoreFailed() {
-        runOnUiThread(
-                () -> {
-                    setLoadingState(false);
-                    Snackbar.make(
-                                    binding.coordinator,
-                                    R.string.unable_to_restore_backup,
-                                    Snackbar.LENGTH_LONG)
-                            .show();
-                });
-    }
-
-    @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
-        if (item.getItemId() == R.id.action_open_backup_file) {
-            openBackupFile();
-            return true;
-        }
-        return super.onOptionsItemSelected(item);
-    }
-
-    private void openBackupFile() {
-        final Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
-        intent.setType("*/*");
-        intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false);
-        intent.addCategory(Intent.CATEGORY_OPENABLE);
-        startActivityForResult(
-                Intent.createChooser(intent, getString(R.string.open_backup)), 0xbac);
-    }
-}

src/conversations/java/eu/siacs/conversations/ui/adapter/BackupFileAdapter.java 🔗

@@ -1,169 +0,0 @@
-package eu.siacs.conversations.ui.adapter;
-
-import android.content.res.Resources;
-import android.graphics.Bitmap;
-import android.graphics.drawable.BitmapDrawable;
-import android.graphics.drawable.Drawable;
-import android.os.AsyncTask;
-import android.text.format.DateUtils;
-import android.util.DisplayMetrics;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ImageView;
-
-import androidx.annotation.NonNull;
-import androidx.databinding.DataBindingUtil;
-import androidx.recyclerview.widget.RecyclerView;
-
-import java.lang.ref.WeakReference;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.RejectedExecutionException;
-
-import eu.siacs.conversations.R;
-import eu.siacs.conversations.databinding.ItemAccountBinding;
-import eu.siacs.conversations.services.AvatarService;
-import eu.siacs.conversations.services.ImportBackupService;
-import eu.siacs.conversations.utils.BackupFileHeader;
-import eu.siacs.conversations.utils.UIHelper;
-import eu.siacs.conversations.xmpp.Jid;
-
-public class BackupFileAdapter extends RecyclerView.Adapter<BackupFileAdapter.BackupFileViewHolder> {
-
-    private OnItemClickedListener listener;
-
-    private final List<ImportBackupService.BackupFile> files = new ArrayList<>();
-
-
-    @NonNull
-    @Override
-    public BackupFileViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
-        return new BackupFileViewHolder(DataBindingUtil.inflate(LayoutInflater.from(viewGroup.getContext()), R.layout.item_account, viewGroup, false));
-    }
-
-    @Override
-    public void onBindViewHolder(@NonNull BackupFileViewHolder backupFileViewHolder, int position) {
-        final ImportBackupService.BackupFile backupFile = files.get(position);
-        final BackupFileHeader header = backupFile.getHeader();
-        backupFileViewHolder.binding.accountJid.setText(header.getJid().asBareJid().toString());
-        backupFileViewHolder.binding.accountStatus.setText(String.format("%s · %s",header.getApp(), DateUtils.formatDateTime(backupFileViewHolder.binding.getRoot().getContext(), header.getTimestamp(), DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR)));
-        backupFileViewHolder.binding.tglAccountStatus.setVisibility(View.GONE);
-        backupFileViewHolder.binding.getRoot().setOnClickListener(v -> {
-            if (listener != null) {
-                listener.onClick(backupFile);
-            }
-        });
-        loadAvatar(header.getJid(), backupFileViewHolder.binding.accountImage);
-    }
-
-    @Override
-    public int getItemCount() {
-        return files.size();
-    }
-
-    public void setFiles(List<ImportBackupService.BackupFile> files) {
-        this.files.clear();
-        this.files.addAll(files);
-        notifyDataSetChanged();
-    }
-
-    public void setOnItemClickedListener(OnItemClickedListener listener) {
-        this.listener = listener;
-    }
-
-    static class BackupFileViewHolder extends RecyclerView.ViewHolder {
-        private final ItemAccountBinding binding;
-
-        BackupFileViewHolder(ItemAccountBinding binding) {
-            super(binding.getRoot());
-            this.binding = binding;
-        }
-
-    }
-
-    public interface OnItemClickedListener {
-        void onClick(ImportBackupService.BackupFile backupFile);
-    }
-
-    static class BitmapWorkerTask extends AsyncTask<Jid, Void, Bitmap> {
-        private final WeakReference<ImageView> imageViewReference;
-        private Jid jid  = null;
-        private final int size;
-
-        BitmapWorkerTask(final ImageView imageView) {
-            imageViewReference = new WeakReference<>(imageView);
-            DisplayMetrics metrics = imageView.getContext().getResources().getDisplayMetrics();
-		this.size = ((int) (48 * metrics.density));
-        }
-
-        @Override
-        protected Bitmap doInBackground(Jid... params) {
-            this.jid = params[0];
-            return AvatarService.get(this.jid, size);
-        }
-
-        @Override
-        protected void onPostExecute(Bitmap bitmap) {
-            if (bitmap != null && !isCancelled()) {
-                final ImageView imageView = imageViewReference.get();
-                if (imageView != null) {
-                    imageView.setImageBitmap(bitmap);
-                    imageView.setBackgroundColor(0x00000000);
-                }
-            }
-        }
-    }
-
-    private void loadAvatar(Jid jid, ImageView imageView) {
-        if (cancelPotentialWork(jid, imageView)) {
-            imageView.setBackgroundColor(UIHelper.getColorForName(jid.asBareJid().toString()));
-            imageView.setImageDrawable(null);
-            final BitmapWorkerTask task = new BitmapWorkerTask(imageView);
-            final AsyncDrawable asyncDrawable = new AsyncDrawable(imageView.getContext().getResources(), null, task);
-            imageView.setImageDrawable(asyncDrawable);
-            try {
-                task.execute(jid);
-            } catch (final RejectedExecutionException ignored) {
-            }
-        }
-    }
-
-    private static boolean cancelPotentialWork(Jid jid, ImageView imageView) {
-        final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView);
-
-        if (bitmapWorkerTask != null) {
-            final Jid oldJid = bitmapWorkerTask.jid;
-            if (oldJid == null || jid != oldJid) {
-                bitmapWorkerTask.cancel(true);
-            } else {
-                return false;
-            }
-        }
-        return true;
-    }
-
-    private static BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) {
-        if (imageView != null) {
-            final Drawable drawable = imageView.getDrawable();
-            if (drawable instanceof AsyncDrawable asyncDrawable) {
-                return asyncDrawable.getBitmapWorkerTask();
-            }
-        }
-        return null;
-    }
-
-    static class AsyncDrawable extends BitmapDrawable {
-        private final WeakReference<BitmapWorkerTask> bitmapWorkerTaskReference;
-
-        AsyncDrawable(Resources res, Bitmap bitmap, BitmapWorkerTask bitmapWorkerTask) {
-            super(res, bitmap);
-            bitmapWorkerTaskReference = new WeakReference<>(bitmapWorkerTask);
-        }
-
-        BitmapWorkerTask getBitmapWorkerTask() {
-            return bitmapWorkerTaskReference.get();
-        }
-    }
-
-}

src/conversations/res/layout/dialog_enter_password.xml 🔗

@@ -1,53 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<layout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto">
-
-    <ScrollView
-        android:layout_width="match_parent"
-        android:layout_height="match_parent">
-
-        <LinearLayout
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:orientation="vertical"
-            android:padding="?dialogPreferredPadding">
-            
-            <TextView
-                android:id="@+id/explain"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:text="@string/enter_password_to_restore"
-                android:textAppearance="?textAppearanceBodyMedium" />
-
-            <TextView
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="18sp"
-                android:text="@string/restore_warning"
-                android:textAppearance="?textAppearanceBodyMedium" />
-
-            <TextView
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="18sp"
-                android:text="@string/restore_warning_continued"
-                android:textAppearance="?textAppearanceBodyMedium" />
-
-            <com.google.android.material.textfield.TextInputLayout
-                android:id="@+id/account_password_layout"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="8dp"
-                app:endIconMode="password_toggle">
-
-                <eu.siacs.conversations.ui.widget.TextInputEditText
-                    android:id="@+id/account_password"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:hint="@string/password"
-                    android:inputType="textPassword" />
-
-            </com.google.android.material.textfield.TextInputLayout>
-        </LinearLayout>
-    </ScrollView>
-</layout>

src/conversations/res/values-iw/strings.xml 🔗

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="pick_a_server">בחר את ספק ה-XMPP שלך</string>
+    <string name="use_conversations.im">השתמש ב-conversations.im</string>
+    <string name="create_new_account">צור חשבון חדש</string>
+    <string name="do_you_have_an_account">האם כבר יש לך חשבון XMPP? זה עשוי להיות המקרה אם אתה כבר משתמש בלקוח XMPP אחר או שהשתמשת ברפליקציה זו בעבר. אם לא, אתה יכול ליצור חשבון XMPP חדש כבר עכשיו.\nטיפ: ספקי דוא\"ל מסוימים מספקים גם חשבונות XMPP.</string>
+    <string name="server_select_text">XMPP היא רשת הודעות מיידיות שאינן תלויות בספק. אתה יכול להשתמש באפליקציה זו עם כל שרת XMPP שתבחר.\nעם זאת, לנוחיותך הקלנו ליצור חשבון ב-conversations.im; ספק המתאים במיוחד לשימוש עם שיחות.</string>
+    <string name="magic_create_text_on_x">הוזמנת ל-%1$s. אנו נדריך אותך בתהליך יצירת החשבון.\nבעת בחירת %1$s כספק, תוכל לתקשר עם משתמשים של ספקים אחרים על ידי מתן כתובת ה-XMPP המלאה שלך.</string>
+    <string name="magic_create_text_fixed">הוזמנת ל-%1$s. שם משתמש כבר נבחר עבורך. אנו נדריך אותך בתהליך יצירת החשבון.\nתוכל לתקשר עם משתמשים של ספקים אחרים על ידי מתן כתובת ה-XMPP המלאה שלך.</string>
+    <string name="your_server_invitation">הזמנת השרת שלך</string>
+    <string name="improperly_formatted_provisioning">קוד הקצאה בפורמט שגוי</string>
+    <string name="tap_share_button_send_invite">הקש על כפתור השיתוף כדי לשלוח לאיש הקשר שלך הזמנה אל %1$s.</string>
+    <string name="if_contact_is_nearby_use_qr">אם איש הקשר שלך נמצא בקרבת מקום, הוא יכול גם לסרוק את הקוד למטה כדי לקבל הזמנה.</string>
+    <string name="easy_invite_share_text">הצטרף ל-%1$s ושוחח איתי: %2$s</string>
+    <string name="share_invite_with">שתף הזמנה באמצעות…</string>
+</resources>

src/conversations/res/values-kab/strings.xml 🔗

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="use_conversations.im">Seqdec conversations.im</string>
+    <string name="create_new_account">Snulfu-d amiḍan amaynut</string>
+</resources>

src/conversations/res/values-zh-rCN/strings.xml 🔗

@@ -3,14 +3,14 @@
     <string name="pick_a_server">选择 XMPP 提供者</string>
     <string name="use_conversations.im">使用 conversations.im</string>
     <string name="create_new_account">创建新账号</string>
-    <string name="do_you_have_an_account">您已经有 XMPP 账号了吗?如果之前使用过 Conversations 或其他 XMPP 客户端,那么已经有账号了。如果没有,现在可以创建账号。\n提示:一些邮件服务提供者也提供 XMPP 账号。</string>
-    <string name="server_select_text">XMPP 是独立于提供者的即时通讯网络。您选择的任何 XMPP 服务器都可以使用此应用。\n不过,您可以轻松地在 conversations.im 上创建账号,这是专门适用于 Conversations 的提供者。</string>
-    <string name="magic_create_text_on_x">您已受邀加入 %1$s。我们将指导您创建账号。\n选择 %1$s 作为提供者时,向别人提供您的完整 XMPP 地址,就能和对方交流。</string>
-    <string name="magic_create_text_fixed">您已受邀加入 %1$s。已为您选择了用户名。我们将指导您创建账号。\n向别人提供您的完整 XMPP 地址,就能和对方交流。</string>
+    <string name="do_you_have_an_account">您是否已有 XMPP 账号?如果您已经在使用其他 XMPP 客户端或之前使用过 Conversations,可能已经有账号了。如果没有,您现在就可以创建新账号。\n提示:部分电子邮件服务提供者也会提供 XMPP 账号。</string>
+    <string name="server_select_text">XMPP 是独立于服务提供者的即时通讯网络。您可以选择任意 XMPP 服务器使用本应用。\n不过,为了方便起见,我们简化了在 conversations.im(专为 Conversations 优化的提供者)上创建账号的过程。</string>
+    <string name="magic_create_text_on_x">您已受邀加入 %1$s。我们将引导您完成创建账号的流程。\n当选择 %1$s 作为服务提供者时,您只需向其他服务提供者的用户提供您的完整 XMPP 地址,即可与对方互通消息。</string>
+    <string name="magic_create_text_fixed">您已受邀加入 %1$s。已为您选择了用户名。我们将引导您完成创建账号的流程。\n您只需向其他服务提供者的用户提供您的完整 XMPP 地址,即可与对方互通消息。</string>
     <string name="your_server_invitation">您的服务器邀请</string>
     <string name="improperly_formatted_provisioning">配置代码格式不正确</string>
     <string name="tap_share_button_send_invite">点按分享按钮,向您的联系人发送 %1$s 的邀请。</string>
     <string name="if_contact_is_nearby_use_qr">如果联系人在附近,也可以扫描下方二维码接受邀请。</string>
     <string name="easy_invite_share_text">加入 %1$s 和我聊天:%2$s</string>
     <string name="share_invite_with">分享邀请…</string>
-</resources>
+</resources>

src/main/AndroidManifest.xml 🔗

@@ -70,10 +70,6 @@
         <intent>
             <action android:name="eu.siacs.conversations.location.show" />
         </intent>
-        <intent>
-            <action android:name="android.intent.action.VIEW" />
-            <data android:mimeType="resource/folder" />
-        </intent>
         <intent>
             <action android:name="android.intent.action.VIEW" />
         </intent>
@@ -82,7 +78,6 @@
         </intent>
     </queries>
 
-
     <application
         android:name=".Conversations"
         android:allowBackup="true"
@@ -120,11 +115,6 @@
             android:foregroundServiceType="dataSync"
             tools:node="merge" />
 
-        <service
-            android:name=".services.ImportBackupService"
-            android:exported="false"
-            android:foregroundServiceType="dataSync" />
-
         <service
             android:name=".services.CallIntegrationConnectionService"
             android:exported="true"
@@ -370,14 +360,13 @@
         <activity
             android:name=".ui.AboutActivity"
             android:exported="false"
-            android:parentActivityName=".ui.SettingsActivity">
+            android:parentActivityName=".ui.activity.SettingsActivity">
             <meta-data
                 android:name="android.support.PARENT_ACTIVITY"
-                android:value="eu.siacs.conversations.ui.SettingsActivity" />
+                android:value="eu.siacs.conversations.ui.activity.SettingsActivity" />
         </activity>
         <activity
             android:name="com.canhub.cropper.CropImageActivity"
-            android:exported="false"
             android:theme="@style/Base.Theme.AppCompat" />
         <activity
             android:name=".ui.MemorizingActivity"
@@ -425,6 +414,60 @@
             android:autoRemoveFromRecents="true"
             android:launchMode="singleInstance"
             android:supportsPictureInPicture="true" />
+        <activity
+            android:name=".ui.ImportBackupActivity"
+            android:exported="true"
+            android:label="@string/restore_backup"
+            android:launchMode="singleTask">
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+                <category android:name="android.intent.category.DEFAULT" />
+
+                <data android:mimeType="application/vnd.conversations.backup" />
+                <data android:scheme="content" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+                <category android:name="android.intent.category.DEFAULT" />
+
+                <data android:mimeType="application/vnd.conversations.backup" />
+                <data android:scheme="file" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data android:scheme="content" />
+                <data android:host="*" />
+                <data android:mimeType="*/*" />
+                <data android:pathPattern=".*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\..*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\..*\\.ceb" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data android:scheme="file" />
+                <data android:host="*" />
+                <data android:mimeType="*/*" />
+                <data android:pathPattern=".*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\..*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\..*\\.ceb" />
+            </intent-filter>
+        </activity>
     </application>
 
 </manifest>

src/main/java/eu/siacs/conversations/crypto/CombiningTrustManager.java → src/main/java/de/gultsch/common/CombiningTrustManager.java 🔗

@@ -1,9 +1,8 @@
-package eu.siacs.conversations.crypto;
+package de.gultsch.common;
 
+import android.annotation.SuppressLint;
 import android.util.Log;
-
 import com.google.common.collect.ImmutableList;
-
 import java.security.KeyStoreException;
 import java.security.NoSuchAlgorithmException;
 import java.security.cert.CertificateException;
@@ -11,12 +10,10 @@ import java.security.cert.X509Certificate;
 import java.util.Arrays;
 import java.util.Iterator;
 import java.util.List;
-
 import javax.net.ssl.X509TrustManager;
 
-import eu.siacs.conversations.Config;
-
-public class CombiningTrustManager implements X509TrustManager {
+@SuppressLint("CustomX509TrustManager")
+public final class CombiningTrustManager implements X509TrustManager {
 
     private final List<X509TrustManager> trustManagers;
 
@@ -32,6 +29,7 @@ public class CombiningTrustManager implements X509TrustManager {
             final X509TrustManager trustManager = iterator.next();
             try {
                 trustManager.checkClientTrusted(chain, authType);
+                return;
             } catch (final CertificateException certificateException) {
                 if (iterator.hasNext()) {
                     continue;
@@ -39,40 +37,29 @@ public class CombiningTrustManager implements X509TrustManager {
                 throw certificateException;
             }
         }
+        throw new CertificateException("No trust managers configured");
     }
 
     @Override
     public void checkServerTrusted(final X509Certificate[] chain, final String authType)
             throws CertificateException {
         Log.d(
-                Config.LOGTAG,
-                CombiningTrustManager.class.getSimpleName()
-                        + " is configured with "
-                        + this.trustManagers.size()
-                        + " TrustManagers");
-        int i = 0;
+                CombiningTrustManager.class.getSimpleName(),
+                "configured with " + this.trustManagers.size() + " TrustManagers");
         for (final Iterator<X509TrustManager> iterator = this.trustManagers.iterator();
                 iterator.hasNext(); ) {
             final X509TrustManager trustManager = iterator.next();
             try {
                 trustManager.checkServerTrusted(chain, authType);
-                Log.d(
-                        Config.LOGTAG,
-                        "certificate check passed on " + trustManager.getClass().getName()+". chain length was "+chain.length);
                 return;
             } catch (final CertificateException certificateException) {
-                Log.d(
-                        Config.LOGTAG,
-                        "failed to verify in [" + i + "]/" + trustManager.getClass().getName(),
-                        certificateException);
                 if (iterator.hasNext()) {
                     continue;
                 }
                 throw certificateException;
-            } finally {
-                ++i;
             }
         }
+        throw new CertificateException("No trust managers configured");
     }
 
     @Override
@@ -86,7 +73,7 @@ public class CombiningTrustManager implements X509TrustManager {
         return certificates.build().toArray(new X509Certificate[0]);
     }
 
-    public static X509TrustManager combineWithDefault(final X509TrustManager... trustManagers)
+    static X509TrustManager combineWithDefault(final X509TrustManager... trustManagers)
             throws NoSuchAlgorithmException, KeyStoreException {
         final ImmutableList.Builder<X509TrustManager> builder = ImmutableList.builder();
         builder.addAll(Arrays.asList(trustManagers));

src/main/java/eu/siacs/conversations/ui/util/MyLinkify.java → src/main/java/de/gultsch/common/Linkify.java 🔗

@@ -27,126 +27,62 @@
  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  */
 
-package eu.siacs.conversations.ui.util;
+package de.gultsch.common;
 
 import android.net.Uri;
-import android.os.Build;
 import android.text.Editable;
 import android.text.Spanned;
 import android.text.style.TypefaceSpan;
 import android.text.style.URLSpan;
-import android.text.util.Linkify;
-
+import com.google.common.base.Splitter;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-
-import java.lang.IndexOutOfBoundsException;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.List;
-import java.util.Locale;
-import java.util.Objects;
-
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.ListItem;
-import eu.siacs.conversations.entities.Roster;
-import eu.siacs.conversations.ui.text.FixedURLSpan;
-import eu.siacs.conversations.utils.GeoHelper;
-import eu.siacs.conversations.utils.Patterns;
 import eu.siacs.conversations.utils.StylingHelper;
 import eu.siacs.conversations.utils.XmppUri;
 import eu.siacs.conversations.xmpp.Jid;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
 
-public class MyLinkify {
+public class Linkify {
 
-    private static final Linkify.TransformFilter WEBURL_TRANSFORM_FILTER = (matcher, url) -> {
-        if (url == null) {
-            return null;
-        }
-        final String lcUrl = url.toLowerCase(Locale.US);
-        if (lcUrl.startsWith("http://") || lcUrl.startsWith("https://")) {
-            return removeTrailingBracket(url);
-        } else {
-            return "http://" + removeTrailingBracket(url);
-        }
-    };
+    private static final android.text.util.Linkify.MatchFilter MATCH_FILTER =
+            (s, start, end) -> isPassAdditionalValidation(s.subSequence(start, end).toString());
 
-    private static String removeTrailingBracket(final String url) {
-        int numOpenBrackets = 0;
-        for (char c : url.toCharArray()) {
-            if (c == '(') {
-                ++numOpenBrackets;
-            } else if (c == ')') {
-                --numOpenBrackets;
-            }
+    private static boolean isPassAdditionalValidation(final String match) {
+        final var scheme = Iterables.getFirst(Splitter.on(':').limit(2).splitToList(match), null);
+        if (scheme == null) {
+            return false;
         }
-        if (numOpenBrackets != 0 && url.charAt(url.length() - 1) == ')') {
-            return url.substring(0, url.length() - 1);
-        } else {
-            return url;
-        }
-    }
-
-    private static final Linkify.MatchFilter WEBURL_MATCH_FILTER = (cs, start, end) -> {
-        if (start > 0) {
-            if (cs.charAt(start - 1) == '@' || cs.charAt(start - 1) == '.'
-                    || cs.subSequence(Math.max(0, start - 3), start).equals("://")) {
-                return false;
+        return switch (scheme) {
+            case "tel" -> Patterns.URI_TEL.matcher(match).matches();
+            case "http", "https" -> Patterns.URI_HTTP.matcher(match).matches();
+            case "geo" -> Patterns.URI_GEO.matcher(match).matches();
+            case "xmpp" -> new XmppUri(Uri.parse(match)).isValidJid();
+            case "web+ap" -> {
+                if (Patterns.URI_WEB_AP.matcher(match).matches()) {
+                    final var webAp = new MiniUri(match);
+                    // TODO once we have fragment support check that there aren't any
+                    yield Objects.nonNull(webAp.getAuthority()) && webAp.getParameter().isEmpty();
+                } else {
+                    yield false;
+                }
             }
-        }
-
-        if (end < cs.length()) {
-            // Reject strings that were probably matched only because they contain a dot followed by
-            // by some known TLD (see also comment for WORD_BOUNDARY in Patterns.java)
-            return !isAlphabetic(cs.charAt(end - 1)) || !isAlphabetic(cs.charAt(end));
-        }
-
-        return true;
-    };
-
-    private static final Linkify.MatchFilter XMPPURI_MATCH_FILTER = (s, start, end) -> {
-        XmppUri uri = new XmppUri(s.subSequence(start, end).toString());
-        return uri.isValidJid();
-    };
-
-    private static boolean isAlphabetic(final int code) {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
-            return Character.isAlphabetic(code);
-        }
-
-        switch (Character.getType(code)) {
-            case Character.UPPERCASE_LETTER:
-            case Character.LOWERCASE_LETTER:
-            case Character.TITLECASE_LETTER:
-            case Character.MODIFIER_LETTER:
-            case Character.OTHER_LETTER:
-            case Character.LETTER_NUMBER:
-                return true;
-            default:
-                return false;
-        }
+            default -> true;
+        };
     }
 
-    public static void addLinks(Editable body, boolean includeGeo) {
-        Linkify.addLinks(body, Patterns.XMPP_PATTERN, "xmpp", XMPPURI_MATCH_FILTER, null);
-        Linkify.addLinks(body, Patterns.TEL_URI, "tel");
-        Linkify.addLinks(body, Patterns.SMS_URI, "sms");
-        Linkify.addLinks(body, Patterns.BITCOIN_URI, "bitcoin");
-        Linkify.addLinks(body, Patterns.BITCOINCASH_URI, "bitcoincash");
-        Linkify.addLinks(body, Patterns.ETHEREUM_URI, "ethereum");
-        Linkify.addLinks(body, Patterns.MONERO_URI, "monero");
-        Linkify.addLinks(body, Patterns.WOWNERO_URI, "wownero");
-        Linkify.addLinks(body, Patterns.AUTOLINK_WEB_URL, "http", WEBURL_MATCH_FILTER, WEBURL_TRANSFORM_FILTER);
-        if (includeGeo) {
-            Linkify.addLinks(body, GeoHelper.GEO_URI, "geo");
-        }
-        FixedURLSpan.fix(body);
+    public static void addLinks(final Editable body) {
+        android.text.util.Linkify.addLinks(body, Patterns.URI_GENERIC, null, MATCH_FILTER, null);
     }
 
-    public static void addLinks(Editable body, Account account, Jid context) {
-        addLinks(body, true);
-        Roster roster = account.getRoster();
+    public static void addLinks(final Editable body, final Account account, final Jid context) {
+        addLinks(body);
+        final var roster = account.getRoster();
         urlspan:
         for (final URLSpan urlspan : body.getSpans(0, body.length() - 1, URLSpan.class)) {
             final var start = body.getSpanStart(urlspan);
@@ -188,11 +124,23 @@ public class MyLinkify {
         }
     }
 
-    public static List<String> extractLinks(final Editable body) {
-        MyLinkify.addLinks(body, false);
-        final Collection<URLSpan> spans =
+    public static List<MiniUri> getLinks(final String body) {
+        final var builder = new ImmutableList.Builder<MiniUri>();
+        final var matcher = Patterns.URI_GENERIC.matcher(body);
+        while (matcher.find()) {
+            final var match = matcher.group();
+            if (isPassAdditionalValidation(match)) {
+                builder.add(new MiniUri(match));
+            }
+        }
+        return builder.build();
+    }
+
+	public static List<String> extractLinks(final Editable body) {
+        addLinks(body);
+        final var spans =
                 Arrays.asList(body.getSpans(0, body.length() - 1, URLSpan.class));
-        final Collection<UrlWrapper> urlWrappers =
+        final var urlWrappers =
                 Collections2.filter(
                         Collections2.transform(
                                 spans,

src/main/java/de/gultsch/common/MiniUri.java 🔗

@@ -0,0 +1,129 @@
+package de.gultsch.common;
+
+import android.net.Uri;
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import org.checkerframework.checker.nullness.qual.NonNull;
+
+public class MiniUri {
+
+    private static final String EMPTY_STRING = "";
+
+    private final String raw;
+    private final String scheme;
+    private final String authority;
+    private final String path;
+    private final Map<String, String> parameter;
+
+    public MiniUri(final String uri) {
+        this.raw = uri;
+        final var schemeAndRest = Splitter.on(':').limit(2).splitToList(uri);
+        if (schemeAndRest.size() < 2) {
+            this.scheme = uri;
+            this.authority = null;
+            this.path = null;
+            this.parameter = Collections.emptyMap();
+            return;
+        }
+        this.scheme = schemeAndRest.get(0);
+        final var rest = schemeAndRest.get(1);
+        // TODO add fragment parser
+        final var authorityPathAndQuery = Splitter.on('?').limit(2).splitToList(rest);
+        final var authorityPath = authorityPathAndQuery.get(0);
+        System.out.println("authorityPath " + authorityPath);
+        if (authorityPath.length() >= 2 && authorityPath.startsWith("//")) {
+            final var authorityPathParts =
+                    Splitter.on('/').limit(2).splitToList(authorityPath.substring(2));
+            this.authority = authorityPathParts.get(0);
+            this.path = authorityPathParts.size() == 2 ? authorityPathParts.get(1) : null;
+        } else {
+            this.authority = null;
+            // TODO path ; style path components from something like geo uri
+            this.path = authorityPath;
+        }
+        if (authorityPathAndQuery.size() == 2) {
+            this.parameter = parseParameters(authorityPathAndQuery.get(1), getDelimiter(scheme));
+        } else {
+            this.parameter = Collections.emptyMap();
+        }
+    }
+
+    private static char getDelimiter(final String scheme) {
+        return switch (scheme) {
+            case "xmpp", "geo" -> ';';
+            default -> '&';
+        };
+    }
+
+    private static Map<String, String> parseParameters(final String query, final char separator) {
+        final ImmutableMap.Builder<String, String> builder = new ImmutableMap.Builder<>();
+        for (final String pair : Splitter.on(separator).split(query)) {
+            final String[] parts = pair.split("=", 2);
+            if (parts.length == 0) {
+                continue;
+            }
+            final String key = parts[0].toLowerCase(Locale.US);
+            if (parts.length == 2) {
+                try {
+                    builder.put(key, URLDecoder.decode(parts[1], "UTF-8"));
+                } catch (final UnsupportedEncodingException e) {
+                    builder.put(key, EMPTY_STRING);
+                }
+            } else {
+                builder.put(key, EMPTY_STRING);
+            }
+        }
+        return builder.build();
+    }
+
+    @NonNull
+    @Override
+    public String toString() {
+        return MoreObjects.toStringHelper(this)
+                .add("scheme", scheme)
+                .add("authority", authority)
+                .add("path", path)
+                .add("parameter", parameter)
+                .toString();
+    }
+
+    public String getScheme() {
+        return this.scheme;
+    }
+
+    public String getAuthority() {
+        return this.authority;
+    }
+
+    public String getPath() {
+        return Strings.isNullOrEmpty(this.path) || this.authority == null
+                ? this.path
+                : '/' + this.path;
+    }
+
+    public List<String> getPathSegments() {
+        return Strings.isNullOrEmpty(this.path)
+                ? Collections.emptyList()
+                : Splitter.on('/').splitToList(this.path);
+    }
+
+    public String getRaw() {
+        return this.raw;
+    }
+
+    public Uri asUri() {
+        return Uri.parse(this.raw);
+    }
+
+    public Map<String, String> getParameter() {
+        return this.parameter;
+    }
+}

src/main/java/de/gultsch/common/Patterns.java 🔗

@@ -0,0 +1,84 @@
+package de.gultsch.common;
+
+import java.util.regex.Pattern;
+
+public class Patterns {
+
+    public static final Pattern URI_GENERIC =
+            Pattern.compile(
+                    "(?<=^|\\p{Z}|\\s|\\p{P})(tel|xmpp|http|https|geo|mailto|web\\+ap|gemini|bitcoin|bitcoincash|ethereum|monero|wownero):[\\p{L}\\p{M}\\p{N}\\-._~:/?#\\[\\]@!$&'*+,;=%]+(\\([\\p{L}\\p{M}\\p{N}\\-._~:/?#\\[\\]@!$&'*+,;=%]+\\))*[\\p{L}\\p{M}\\p{N}\\-._~:/?#\\[\\]@!$&'*+,;=%]*");
+
+    public static final Pattern URI_TEL =
+            Pattern.compile("^tel:\\+?(\\d{1,4}[-./()\\s]?)*\\d{1,4}(;.*)?$");
+
+    public static final Pattern URI_HTTP = Pattern.compile("https?://\\S+");
+
+    public static final Pattern URI_WEB_AP = Pattern.compile("web\\+ap://\\S+");
+
+    public static Pattern URI_GEO =
+            Pattern.compile(
+                    "geo:(-?\\d+(?:\\.\\d+)?),(-?\\d+(?:\\.\\d+)?)(?:,-?\\d+(?:\\.\\d+)?)?(?:;crs=[\\w-]+)?(?:;u=\\d+(?:\\.\\d+)?)?(?:;[\\w-]+=(?:[\\w-_.!~*'()]|%[\\da-f][\\da-f])+)*(\\?z=\\d+)?",
+                    Pattern.CASE_INSENSITIVE);
+
+    public static final Pattern IPV4 =
+            Pattern.compile(
+                    "\\A(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
+    public static final Pattern IPV6 =
+            Pattern.compile("\\A(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\z");
+    public static final Pattern IPV6_HEX4_DECOMPRESSED =
+            Pattern.compile(
+                    "\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)"
+                        + " ::((?:[0-9A-Fa-f]{1,4}:)*)(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
+    public static final Pattern IPV6_6HEX4DEC =
+            Pattern.compile(
+                    "\\A((?:[0-9A-Fa-f]{1,4}:){6,6})(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
+    public static final Pattern IPV6_HEX_COMPRESSED =
+            Pattern.compile(
+                    "\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)\\z");
+
+    public static final Pattern BITCOIN_URI = Pattern
+            .compile("bitcoin\\:(?:[13][a-km-zA-HJ-NP-Z1-9]{25,34}|[bB][cC]1[pPqQ][a-zA-Z0-9]{38,58})(?:\\?(?:(?:["
+                    + Patterns.GOOD_IRI_CHAR
+                    + "\\;\\/\\?\\@\\&\\=\\#\\~\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])"
+                    + "|(?:\\%[a-fA-F0-9]{2}))+)?");
+
+    public static final Pattern BITCOINCASH_URI = Pattern
+            .compile("bitcoincash\\:(?:[13][a-km-zA-HJ-NP-Z1-9]{33}|[qp][a-z0-9]{41})(?:\\?(?:(?:["
+                    + Patterns.GOOD_IRI_CHAR
+                    + "\\;\\/\\?\\@\\&\\=\\#\\~\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])"
+                    + "|(?:\\%[a-fA-F0-9]{2}))+)?");
+
+    public static final Pattern ETHEREUM_URI = Pattern
+            .compile("ethereum\\:(?:pay\\-)?(0x[0-9a-f]{40})(?:@[0-9]+)?(?:/(?:(?:["
+                    + Patterns.GOOD_IRI_CHAR
+                    + "\\;\\/\\?\\@\\&\\=\\#\\~\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])"
+                    + "|(?:\\%[a-fA-F0-9]{2}))+)?(?:\\?(?:(?:["
+                    + Patterns.GOOD_IRI_CHAR
+                    + "\\;\\/\\?\\@\\&\\=\\#\\~\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])"
+                    + "|(?:\\%[a-fA-F0-9]{2}))+)?");
+
+    public static final Pattern MONERO_URI = Pattern
+            .compile("monero\\:(?:[48][0-9AB][1-9A-HJ-NP-Za-km-z]{93})(?:\\?(?:(?:["
+                    + Patterns.GOOD_IRI_CHAR
+                    + "\\;\\/\\?\\@\\&\\=\\#\\~\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])"
+                    + "|(?:\\%[a-fA-F0-9]{2}))+)?");
+
+    public static final Pattern WOWNERO_URI = Pattern
+            .compile("wownero\\:(?:W(?:[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{96}|[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{106}|[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{187}))(?:\\?(?:(?:["
+                    + Patterns.GOOD_IRI_CHAR
+                    + "\\;\\/\\?\\@\\&\\=\\#\\~\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])"
+                    + "|(?:\\%[a-fA-F0-9]{2}))+)?");
+
+    /**
+     * Kept for backward compatibility reasons.
+     *
+     * @deprecated Deprecated since it does not include all IRI characters defined in RFC 3987
+     */
+    @Deprecated
+    public static final String GOOD_IRI_CHAR =
+            "a-zA-Z0-9\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF";
+
+    private Patterns() {
+        throw new AssertionError("Do not instantiate me");
+    }
+}

src/main/java/eu/siacs/conversations/crypto/TrustManagers.java → src/main/java/de/gultsch/common/TrustManagers.java 🔗

@@ -1,25 +1,24 @@
-package eu.siacs.conversations.crypto;
+package de.gultsch.common;
 
 import android.content.Context;
-
+import android.os.Build;
 import androidx.annotation.Nullable;
-
 import com.google.common.collect.Iterables;
-
+import eu.siacs.conversations.R;
 import java.io.IOException;
+import java.io.InputStream;
 import java.security.KeyStore;
 import java.security.KeyStoreException;
 import java.security.NoSuchAlgorithmException;
 import java.security.cert.CertificateException;
 import java.util.Arrays;
-
 import javax.net.ssl.TrustManagerFactory;
 import javax.net.ssl.X509TrustManager;
 
-import eu.siacs.conversations.R;
-
 public final class TrustManagers {
 
+    private static final char[] BUNDLED_KEYSTORE_PASSWORD = "letsencrypt".toCharArray();
+
     private TrustManagers() {
         throw new IllegalStateException("Do not instantiate me");
     }
@@ -35,21 +34,31 @@ public final class TrustManagers {
                         X509TrustManager.class));
     }
 
+    public static X509TrustManager createForAndroidVersion(final Context context)
+            throws NoSuchAlgorithmException, KeyStoreException, CertificateException, IOException {
+        if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) {
+            return TrustManagers.createDefaultWithBundledLetsEncrypt(context);
+        } else {
+            return TrustManagers.createDefaultTrustManager();
+        }
+    }
+
     public static X509TrustManager createDefaultTrustManager()
             throws NoSuchAlgorithmException, KeyStoreException {
         return createTrustManager(null);
     }
 
-    public static X509TrustManager defaultWithBundledLetsEncrypt(final Context context)
+    private static X509TrustManager createDefaultWithBundledLetsEncrypt(final Context context)
             throws NoSuchAlgorithmException, KeyStoreException, CertificateException, IOException {
-        final BundledTrustManager bundleTrustManager =
-                BundledTrustManager.builder()
-                        .loadKeyStore(
-                                context.getResources().openRawResource(R.raw.letsencrypt),
-                                "letsencrypt")
-                        .build();
+        final var bundleTrustManager =
+                createWithKeyStore(context.getResources().openRawResource(R.raw.letsencrypt));
         return CombiningTrustManager.combineWithDefault(bundleTrustManager);
     }
 
-
+    private static X509TrustManager createWithKeyStore(final InputStream inputStream)
+            throws CertificateException, IOException, NoSuchAlgorithmException, KeyStoreException {
+        final KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
+        keyStore.load(inputStream, BUNDLED_KEYSTORE_PASSWORD);
+        return TrustManagers.createTrustManager(keyStore);
+    }
 }

src/main/java/eu/siacs/conversations/AppSettings.java 🔗

@@ -3,10 +3,14 @@ package eu.siacs.conversations;
 import android.content.Context;
 import android.content.SharedPreferences;
 import android.net.Uri;
+import android.os.Environment;
 import androidx.annotation.BoolRes;
 import androidx.annotation.NonNull;
 import androidx.preference.PreferenceManager;
+import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
+import eu.siacs.conversations.persistance.FileBackend;
 import eu.siacs.conversations.services.QuickConversationsService;
 import java.security.SecureRandom;
 
@@ -47,10 +51,14 @@ public class AppSettings {
     public static final String SHOW_AVATARS = "show_avatars";
     public static final String CALL_INTEGRATION = "call_integration";
     public static final String ALIGN_START = "align_start";
+    public static final String BACKUP_LOCATION = "backup_location";
 
     private static final String ACCEPT_INVITES_FROM_STRANGERS = "accept_invites_from_strangers";
     private static final String INSTALLATION_ID = "im.conversations.android.install_id";
 
+    private static final String EXTERNAL_STORAGE_AUTHORITY =
+            "com.android.externalstorage.documents";
+
     private final Context context;
 
     public AppSettings(final Context context) {
@@ -150,6 +158,50 @@ public class AppSettings {
                 OMEMO, context.getString(R.string.omemo_setting_default));
     }
 
+    public Uri getBackupLocation() {
+        final SharedPreferences sharedPreferences =
+                PreferenceManager.getDefaultSharedPreferences(context);
+        final String location = sharedPreferences.getString(BACKUP_LOCATION, null);
+        if (Strings.isNullOrEmpty(location)) {
+            final var directory = FileBackend.getBackupDirectory(context);
+            return Uri.fromFile(directory);
+        }
+        return Uri.parse(location);
+    }
+
+    public String getBackupLocationAsPath() {
+        return asPath(getBackupLocation());
+    }
+
+    public static String asPath(final Uri uri) {
+        final var scheme = uri.getScheme();
+        final var path = uri.getPath();
+        if (path == null) {
+            return uri.toString();
+        }
+        if ("file".equalsIgnoreCase(scheme)) {
+            return path;
+        } else if ("content".equalsIgnoreCase(scheme)) {
+            if (EXTERNAL_STORAGE_AUTHORITY.equalsIgnoreCase(uri.getAuthority())) {
+                final var parts = Splitter.on(':').limit(2).splitToList(path);
+                if (parts.size() == 2 && "/tree/primary".equals(parts.get(0))) {
+                    return Joiner.on('/')
+                            .join(Environment.getExternalStorageDirectory(), parts.get(1));
+                }
+            }
+        }
+        return uri.toString();
+    }
+
+    public void setBackupLocation(final Uri uri) {
+        final SharedPreferences sharedPreferences =
+                PreferenceManager.getDefaultSharedPreferences(context);
+        sharedPreferences
+                .edit()
+                .putString(BACKUP_LOCATION, uri == null ? "" : uri.toString())
+                .apply();
+    }
+
     public boolean isSendCrashReports() {
         return getBooleanPreference(SEND_CRASH_REPORTS, R.bool.send_crash_reports);
     }

src/main/java/eu/siacs/conversations/crypto/BundledTrustManager.java 🔗

@@ -1,65 +0,0 @@
-package eu.siacs.conversations.crypto;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.security.KeyStore;
-import java.security.KeyStoreException;
-import java.security.NoSuchAlgorithmException;
-import java.security.cert.CertificateException;
-import java.security.cert.X509Certificate;
-
-import javax.net.ssl.X509TrustManager;
-
-public class BundledTrustManager implements X509TrustManager {
-
-    private final X509TrustManager delegate;
-
-    private BundledTrustManager(final KeyStore keyStore)
-            throws NoSuchAlgorithmException, KeyStoreException {
-        this.delegate = TrustManagers.createTrustManager(keyStore);
-    }
-
-    public static Builder builder() throws KeyStoreException {
-        return new Builder();
-    }
-
-    @Override
-    public void checkClientTrusted(final X509Certificate[] chain, final String authType)
-            throws CertificateException {
-        this.delegate.checkClientTrusted(chain, authType);
-    }
-
-    @Override
-    public void checkServerTrusted(final X509Certificate[] chain, final String authType)
-            throws CertificateException {
-        this.delegate.checkServerTrusted(chain, authType);
-    }
-
-    @Override
-    public X509Certificate[] getAcceptedIssuers() {
-        return this.delegate.getAcceptedIssuers();
-    }
-
-    public static class Builder {
-
-        private KeyStore keyStore;
-
-        private Builder() {}
-
-        public Builder loadKeyStore(final InputStream inputStream, final String password)
-                throws CertificateException, IOException, NoSuchAlgorithmException,
-                        KeyStoreException {
-            if (this.keyStore != null) {
-                throw new IllegalStateException("KeyStore has already been loaded");
-            }
-            final KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
-            keyStore.load(inputStream, password.toCharArray());
-            this.keyStore = keyStore;
-            return this;
-        }
-
-        public BundledTrustManager build() throws NoSuchAlgorithmException, KeyStoreException {
-            return new BundledTrustManager(keyStore);
-        }
-    }
-}

src/main/java/eu/siacs/conversations/crypto/DomainHostnameVerifier.java 🔗

@@ -1,11 +0,0 @@
-package eu.siacs.conversations.crypto;
-
-import javax.net.ssl.HostnameVerifier;
-import javax.net.ssl.SSLPeerUnverifiedException;
-import javax.net.ssl.SSLSession;
-
-public interface DomainHostnameVerifier extends HostnameVerifier {
-
-    boolean verify(String domain, String hostname, SSLSession sslSession) throws SSLPeerUnverifiedException;
-
-}

src/main/java/eu/siacs/conversations/crypto/XmppDomainVerifier.java 🔗

@@ -2,22 +2,8 @@ package eu.siacs.conversations.crypto;
 
 import android.util.Log;
 import android.util.Pair;
-
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
-
-import org.bouncycastle.asn1.ASN1Object;
-import org.bouncycastle.asn1.ASN1Primitive;
-import org.bouncycastle.asn1.ASN1TaggedObject;
-import org.bouncycastle.asn1.DERIA5String;
-import org.bouncycastle.asn1.DERUTF8String;
-import org.bouncycastle.asn1.DLSequence;
-import org.bouncycastle.asn1.x500.RDN;
-import org.bouncycastle.asn1.x500.X500Name;
-import org.bouncycastle.asn1.x500.style.BCStyle;
-import org.bouncycastle.asn1.x500.style.IETFUtils;
-import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
-
 import java.io.IOException;
 import java.net.IDN;
 import java.security.cert.Certificate;
@@ -28,9 +14,19 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Locale;
-
 import javax.net.ssl.SSLPeerUnverifiedException;
 import javax.net.ssl.SSLSession;
+import org.bouncycastle.asn1.ASN1Object;
+import org.bouncycastle.asn1.ASN1Primitive;
+import org.bouncycastle.asn1.ASN1TaggedObject;
+import org.bouncycastle.asn1.DERIA5String;
+import org.bouncycastle.asn1.DERUTF8String;
+import org.bouncycastle.asn1.DLSequence;
+import org.bouncycastle.asn1.x500.RDN;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x500.style.BCStyle;
+import org.bouncycastle.asn1.x500.style.IETFUtils;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
 
 public class XmppDomainVerifier {
 
@@ -82,16 +78,11 @@ public class XmppDomainVerifier {
     public static boolean matchDomain(final String needle, final List<String> haystack) {
         for (final String entry : haystack) {
             if (entry.startsWith("*.")) {
-                int offset = 0;
-                while (offset < needle.length()) {
-                    int i = needle.indexOf('.', offset);
-                    if (i < 0) {
-                        break;
-                    }
-                    if (needle.substring(i).equalsIgnoreCase(entry.substring(1))) {
-                        return true;
-                    }
-                    offset = i + 1;
+                // https://www.rfc-editor.org/rfc/rfc6125#section-6.4.3
+                // wild cards can only be in the left most label and don’t match '.'
+                final int i = needle.indexOf('.');
+                if (i != -1 && needle.substring(i).equalsIgnoreCase(entry.substring(1))) {
+                    return true;
                 }
             } else {
                 if (entry.equalsIgnoreCase(needle)) {

src/main/java/eu/siacs/conversations/crypto/axolotl/SQLiteAxolotlStore.java 🔗

@@ -2,7 +2,14 @@ package eu.siacs.conversations.crypto.axolotl;
 
 import android.util.Log;
 import android.util.LruCache;
-
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.utils.CryptoHelper;
+import java.security.cert.X509Certificate;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
 import org.whispersystems.libsignal.IdentityKey;
 import org.whispersystems.libsignal.IdentityKeyPair;
 import org.whispersystems.libsignal.InvalidKeyIdException;
@@ -15,463 +22,495 @@ import org.whispersystems.libsignal.state.SignalProtocolStore;
 import org.whispersystems.libsignal.state.SignedPreKeyRecord;
 import org.whispersystems.libsignal.util.KeyHelper;
 
-import java.security.cert.X509Certificate;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-import eu.siacs.conversations.Config;
-import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.services.XmppConnectionService;
-import eu.siacs.conversations.utils.CryptoHelper;
-
 public class SQLiteAxolotlStore implements SignalProtocolStore {
 
-	public static final String PREKEY_TABLENAME = "prekeys";
-	public static final String SIGNED_PREKEY_TABLENAME = "signed_prekeys";
-	public static final String SESSION_TABLENAME = "sessions";
-	public static final String IDENTITIES_TABLENAME = "identities";
-	public static final String ACCOUNT = "account";
-	public static final String DEVICE_ID = "device_id";
-	public static final String ID = "id";
-	public static final String KEY = "key";
-	public static final String FINGERPRINT = "fingerprint";
-	public static final String NAME = "name";
-	public static final String TRUSTED = "trusted"; //no longer used
-	public static final String TRUST = "trust";
-	public static final String ACTIVE = "active";
-	public static final String LAST_ACTIVATION = "last_activation";
-	public static final String OWN = "ownkey";
-	public static final String CERTIFICATE = "certificate";
-
-	public static final String JSONKEY_REGISTRATION_ID = "axolotl_reg_id";
-	public static final String JSONKEY_CURRENT_PREKEY_ID = "axolotl_cur_prekey_id";
-
-	private static final int NUM_TRUSTS_TO_CACHE = 100;
-
-	private final Account account;
-	private final XmppConnectionService mXmppConnectionService;
-
-	private IdentityKeyPair identityKeyPair;
-	private int localRegistrationId;
-	private int currentPreKeyId = 0;
-
-	private final HashSet<Integer> preKeysMarkedForRemoval = new HashSet<>();
-
-	private final LruCache<String, FingerprintStatus> trustCache =
-			new LruCache<String, FingerprintStatus>(NUM_TRUSTS_TO_CACHE) {
-				@Override
-				protected FingerprintStatus create(String fingerprint) {
-					return mXmppConnectionService.databaseBackend.getFingerprintStatus(account, fingerprint);
-				}
-			};
-
-	private static IdentityKeyPair generateIdentityKeyPair() {
-		Log.i(Config.LOGTAG, AxolotlService.LOGPREFIX + " : " + "Generating axolotl IdentityKeyPair...");
-		ECKeyPair identityKeyPairKeys = Curve.generateKeyPair();
-		return new IdentityKeyPair(new IdentityKey(identityKeyPairKeys.getPublicKey()),
-				identityKeyPairKeys.getPrivateKey());
-	}
-
-	private static int generateRegistrationId() {
-		Log.i(Config.LOGTAG, AxolotlService.LOGPREFIX + " : " + "Generating axolotl registration ID...");
-		return KeyHelper.generateRegistrationId(true);
-	}
-
-	public SQLiteAxolotlStore(Account account, XmppConnectionService service) {
-		this.account = account;
-		this.mXmppConnectionService = service;
-		this.localRegistrationId = loadRegistrationId();
-		this.currentPreKeyId = loadCurrentPreKeyId();
-	}
-
-	public int getCurrentPreKeyId() {
-		return currentPreKeyId;
-	}
-
-	// --------------------------------------
-	// IdentityKeyStore
-	// --------------------------------------
-
-	private IdentityKeyPair loadIdentityKeyPair() {
-		synchronized (mXmppConnectionService) {
-			IdentityKeyPair ownKey = mXmppConnectionService.databaseBackend.loadOwnIdentityKeyPair(account);
-
-			if (ownKey != null) {
-				return ownKey;
-			} else {
-				Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Could not retrieve own IdentityKeyPair");
-				ownKey = generateIdentityKeyPair();
-				mXmppConnectionService.databaseBackend.storeOwnIdentityKeyPair(account, ownKey);
-			}
-			return ownKey;
-		}
-	}
-
-	private int loadRegistrationId() {
-		return loadRegistrationId(false);
-	}
-
-	private int loadRegistrationId(boolean regenerate) {
-		String regIdString = this.account.getKey(JSONKEY_REGISTRATION_ID);
-		int reg_id;
-		if (!regenerate && regIdString != null) {
-			reg_id = Integer.valueOf(regIdString);
-		} else {
-			Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Could not retrieve axolotl registration id for account " + account.getJid());
-			reg_id = generateRegistrationId();
-			boolean success = this.account.setKey(JSONKEY_REGISTRATION_ID, Integer.toString(reg_id));
-			if (success) {
-				mXmppConnectionService.databaseBackend.updateAccount(account);
-			} else {
-				Log.e(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Failed to write new key to the database!");
-			}
-		}
-		return reg_id;
-	}
-
-	private int loadCurrentPreKeyId() {
-		String prekeyIdString = this.account.getKey(JSONKEY_CURRENT_PREKEY_ID);
-		int prekey_id;
-		if (prekeyIdString != null) {
-			prekey_id = Integer.valueOf(prekeyIdString);
-		} else {
-			Log.w(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Could not retrieve current prekey id for account " + account.getJid());
-			prekey_id = 0;
-		}
-		return prekey_id;
-	}
-
-	public void regenerate() {
-		mXmppConnectionService.databaseBackend.wipeAxolotlDb(account);
-		trustCache.evictAll();
-		account.setKey(JSONKEY_CURRENT_PREKEY_ID, Integer.toString(0));
-		identityKeyPair = loadIdentityKeyPair();
-		localRegistrationId = loadRegistrationId(true);
-		currentPreKeyId = 0;
-		mXmppConnectionService.updateAccountUi();
-	}
-
-	/**
-	 * Get the local client's identity key pair.
-	 *
-	 * @return The local client's persistent identity key pair.
-	 */
-	@Override
-	public IdentityKeyPair getIdentityKeyPair() {
-		if (identityKeyPair == null) {
-			identityKeyPair = loadIdentityKeyPair();
-		}
-		return identityKeyPair;
-	}
-
-	/**
-	 * Return the local client's registration ID.
-	 * <p/>
-	 * Clients should maintain a registration ID, a random number
-	 * between 1 and 16380 that's generated once at install time.
-	 *
-	 * @return the local client's registration ID.
-	 */
-	@Override
-	public int getLocalRegistrationId() {
-		return localRegistrationId;
-	}
-
-	/**
-	 * Save a remote client's identity key
-	 * <p/>
-	 * Store a remote client's identity key as trusted.
-	 *
-	 * @param address     The address of the remote client.
-	 * @param identityKey The remote client's identity key.
-	 * @return true on success
-	 */
-	@Override
-	public boolean saveIdentity(SignalProtocolAddress address, IdentityKey identityKey) {
-		if (!mXmppConnectionService.databaseBackend.loadIdentityKeys(account, address.getName()).contains(identityKey)) {
-			String fingerprint = CryptoHelper.bytesToHex(identityKey.getPublicKey().serialize());
-			FingerprintStatus status = getFingerprintStatus(fingerprint);
-			if (status == null) {
-				if (mXmppConnectionService.blindTrustBeforeVerification() && !account.getAxolotlService().hasVerifiedKeys(address.getName())) {
-					Log.d(Config.LOGTAG,account.getJid().asBareJid()+": blindly trusted "+fingerprint+" of "+address.getName());
-					status = FingerprintStatus.createActiveTrusted();
-				} else {
-					status = FingerprintStatus.createActiveUndecided();
-				}
-			} else {
-				status = status.toActive();
-			}
-			mXmppConnectionService.databaseBackend.storeIdentityKey(account, address.getName(), identityKey, status);
-			trustCache.remove(fingerprint);
-		}
-		return true;
-	}
-
-	/**
-	 * Verify a remote client's identity key.
-	 * <p/>
-	 * Determine whether a remote client's identity is trusted.  Convention is
-	 * that the TextSecure protocol is 'trust on first use.'  This means that
-	 * an identity key is considered 'trusted' if there is no entry for the recipient
-	 * in the local store, or if it matches the saved key for a recipient in the local
-	 * store.  Only if it mismatches an entry in the local store is it considered
-	 * 'untrusted.'
-	 *
-	 * @param identityKey The identity key to verify.
-	 * @return true if trusted, false if untrusted.
-	 */
-	@Override
-	public boolean isTrustedIdentity(SignalProtocolAddress address, IdentityKey identityKey, Direction direction) {
-		return true;
-	}
-
-	public FingerprintStatus getFingerprintStatus(String fingerprint) {
-		return (fingerprint == null)? null : trustCache.get(fingerprint);
-	}
-
-	public void setFingerprintStatus(String fingerprint, FingerprintStatus status) {
-		mXmppConnectionService.databaseBackend.setIdentityKeyTrust(account, fingerprint, status);
-		trustCache.remove(fingerprint);
-	}
-
-	public void setFingerprintCertificate(String fingerprint, X509Certificate x509Certificate) {
-		mXmppConnectionService.databaseBackend.setIdentityKeyCertificate(account, fingerprint, x509Certificate);
-	}
-
-	public X509Certificate getFingerprintCertificate(String fingerprint) {
-		return mXmppConnectionService.databaseBackend.getIdentityKeyCertifcate(account, fingerprint);
-	}
-
-	public Set<IdentityKey> getContactKeysWithTrust(String bareJid, FingerprintStatus status) {
-		return mXmppConnectionService.databaseBackend.loadIdentityKeys(account, bareJid, status);
-	}
-
-	public long getContactNumTrustedKeys(String bareJid) {
-		return mXmppConnectionService.databaseBackend.numTrustedKeys(account, bareJid);
-	}
-
-	// --------------------------------------
-	// SessionStore
-	// --------------------------------------
-
-	/**
-	 * Returns a copy of the {@link SessionRecord} corresponding to the recipientId + deviceId tuple,
-	 * or a new SessionRecord if one does not currently exist.
-	 * <p/>
-	 * It is important that implementations return a copy of the current durable information.  The
-	 * returned SessionRecord may be modified, but those changes should not have an effect on the
-	 * durable session state (what is returned by subsequent calls to this method) without the
-	 * store method being called here first.
-	 *
-	 * @param address The name and device ID of the remote client.
-	 * @return a copy of the SessionRecord corresponding to the recipientId + deviceId tuple, or
-	 * a new SessionRecord if one does not currently exist.
-	 */
-	@Override
-	public SessionRecord loadSession(SignalProtocolAddress address) {
-		SessionRecord session = mXmppConnectionService.databaseBackend.loadSession(this.account, address);
-		return (session != null) ? session : new SessionRecord();
-	}
-
-	/**
-	 * Returns all known devices with active sessions for a recipient
-	 *
-	 * @param name the name of the client.
-	 * @return all known sub-devices with active sessions.
-	 */
-	@Override
-	public List<Integer> getSubDeviceSessions(String name) {
-		return mXmppConnectionService.databaseBackend.getSubDeviceSessions(account,
-				new SignalProtocolAddress(name, 0));
-	}
-
-
-	public List<String> getKnownAddresses() {
-		return mXmppConnectionService.databaseBackend.getKnownSignalAddresses(account);
-	}
-	/**
-	 * Commit to storage the {@link SessionRecord} for a given recipientId + deviceId tuple.
-	 *
-	 * @param address the address of the remote client.
-	 * @param record  the current SessionRecord for the remote client.
-	 */
-	@Override
-	public void storeSession(SignalProtocolAddress address, SessionRecord record) {
-		mXmppConnectionService.databaseBackend.storeSession(account, address, record);
-	}
-
-	/**
-	 * Determine whether there is a committed {@link SessionRecord} for a recipientId + deviceId tuple.
-	 *
-	 * @param address the address of the remote client.
-	 * @return true if a {@link SessionRecord} exists, false otherwise.
-	 */
-	@Override
-	public boolean containsSession(SignalProtocolAddress address) {
-		return mXmppConnectionService.databaseBackend.containsSession(account, address);
-	}
-
-	/**
-	 * Remove a {@link SessionRecord} for a recipientId + deviceId tuple.
-	 *
-	 * @param address the address of the remote client.
-	 */
-	@Override
-	public void deleteSession(SignalProtocolAddress address) {
-		mXmppConnectionService.databaseBackend.deleteSession(account, address);
-	}
-
-	/**
-	 * Remove the {@link SessionRecord}s corresponding to all devices of a recipientId.
-	 *
-	 * @param name the name of the remote client.
-	 */
-	@Override
-	public void deleteAllSessions(String name) {
-		SignalProtocolAddress address = new SignalProtocolAddress(name, 0);
-		mXmppConnectionService.databaseBackend.deleteAllSessions(account,
-				address);
-	}
-
-	// --------------------------------------
-	// PreKeyStore
-	// --------------------------------------
-
-	/**
-	 * Load a local PreKeyRecord.
-	 *
-	 * @param preKeyId the ID of the local PreKeyRecord.
-	 * @return the corresponding PreKeyRecord.
-	 * @throws InvalidKeyIdException when there is no corresponding PreKeyRecord.
-	 */
-	@Override
-	public PreKeyRecord loadPreKey(int preKeyId) throws InvalidKeyIdException {
-		PreKeyRecord record = mXmppConnectionService.databaseBackend.loadPreKey(account, preKeyId);
-		if (record == null) {
-			throw new InvalidKeyIdException("No such PreKeyRecord: " + preKeyId);
-		}
-		return record;
-	}
-
-	/**
-	 * Store a local PreKeyRecord.
-	 *
-	 * @param preKeyId the ID of the PreKeyRecord to store.
-	 * @param record   the PreKeyRecord.
-	 */
-	@Override
-	public void storePreKey(int preKeyId, PreKeyRecord record) {
-		mXmppConnectionService.databaseBackend.storePreKey(account, record);
-		currentPreKeyId = preKeyId;
-		boolean success = this.account.setKey(JSONKEY_CURRENT_PREKEY_ID, Integer.toString(preKeyId));
-		if (success) {
-			mXmppConnectionService.databaseBackend.updateAccount(account);
-		} else {
-			Log.e(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Failed to write new prekey id to the database!");
-		}
-	}
-
-	/**
-	 * @param preKeyId A PreKeyRecord ID.
-	 * @return true if the store has a record for the preKeyId, otherwise false.
-	 */
-	@Override
-	public boolean containsPreKey(int preKeyId) {
-		return mXmppConnectionService.databaseBackend.containsPreKey(account, preKeyId);
-	}
-
-	/**
-	 * Delete a PreKeyRecord from local storage.
-	 *
-	 * @param preKeyId The ID of the PreKeyRecord to remove.
-	 */
-	@Override
-	public void removePreKey(int preKeyId) {
-		Log.d(Config.LOGTAG,"mark prekey for removal "+preKeyId);
-		synchronized (preKeysMarkedForRemoval) {
-			preKeysMarkedForRemoval.add(preKeyId);
-		}
-	}
-
-
-	public boolean flushPreKeys() {
-		Log.d(Config.LOGTAG,"flushing pre keys");
-		int count = 0;
-		synchronized (preKeysMarkedForRemoval) {
-			for(Integer preKeyId : preKeysMarkedForRemoval) {
-				count += mXmppConnectionService.databaseBackend.deletePreKey(account, preKeyId);
-			}
-			preKeysMarkedForRemoval.clear();
-		}
-		return count > 0;
-	}
-
-	// --------------------------------------
-	// SignedPreKeyStore
-	// --------------------------------------
-
-	/**
-	 * Load a local SignedPreKeyRecord.
-	 *
-	 * @param signedPreKeyId the ID of the local SignedPreKeyRecord.
-	 * @return the corresponding SignedPreKeyRecord.
-	 * @throws InvalidKeyIdException when there is no corresponding SignedPreKeyRecord.
-	 */
-	@Override
-	public SignedPreKeyRecord loadSignedPreKey(int signedPreKeyId) throws InvalidKeyIdException {
-		SignedPreKeyRecord record = mXmppConnectionService.databaseBackend.loadSignedPreKey(account, signedPreKeyId);
-		if (record == null) {
-			throw new InvalidKeyIdException("No such SignedPreKeyRecord: " + signedPreKeyId);
-		}
-		return record;
-	}
-
-	/**
-	 * Load all local SignedPreKeyRecords.
-	 *
-	 * @return All stored SignedPreKeyRecords.
-	 */
-	@Override
-	public List<SignedPreKeyRecord> loadSignedPreKeys() {
-		return mXmppConnectionService.databaseBackend.loadSignedPreKeys(account);
-	}
-
-	public int getSignedPreKeysCount() {
-		return mXmppConnectionService.databaseBackend.getSignedPreKeysCount(account);
-	}
-
-	/**
-	 * Store a local SignedPreKeyRecord.
-	 *
-	 * @param signedPreKeyId the ID of the SignedPreKeyRecord to store.
-	 * @param record         the SignedPreKeyRecord.
-	 */
-	@Override
-	public void storeSignedPreKey(int signedPreKeyId, SignedPreKeyRecord record) {
-		mXmppConnectionService.databaseBackend.storeSignedPreKey(account, record);
-	}
-
-	/**
-	 * @param signedPreKeyId A SignedPreKeyRecord ID.
-	 * @return true if the store has a record for the signedPreKeyId, otherwise false.
-	 */
-	@Override
-	public boolean containsSignedPreKey(int signedPreKeyId) {
-		return mXmppConnectionService.databaseBackend.containsSignedPreKey(account, signedPreKeyId);
-	}
-
-	/**
-	 * Delete a SignedPreKeyRecord from local storage.
-	 *
-	 * @param signedPreKeyId The ID of the SignedPreKeyRecord to remove.
-	 */
-	@Override
-	public void removeSignedPreKey(int signedPreKeyId) {
-		mXmppConnectionService.databaseBackend.deleteSignedPreKey(account, signedPreKeyId);
-	}
-
-	public void preVerifyFingerprint(Account account, String name, String fingerprint) {
-		mXmppConnectionService.databaseBackend.storePreVerification(account,name,fingerprint,FingerprintStatus.createInactiveVerified());
-	}
+    public static final String PREKEY_TABLENAME = "prekeys";
+    public static final String SIGNED_PREKEY_TABLENAME = "signed_prekeys";
+    public static final String SESSION_TABLENAME = "sessions";
+    public static final String IDENTITIES_TABLENAME = "identities";
+    public static final String ACCOUNT = "account";
+    public static final String DEVICE_ID = "device_id";
+    public static final String ID = "id";
+    public static final String KEY = "key";
+    public static final String FINGERPRINT = "fingerprint";
+    public static final String NAME = "name";
+    public static final String TRUSTED = "trusted"; // no longer used
+    public static final String TRUST = "trust";
+    public static final String ACTIVE = "active";
+    public static final String LAST_ACTIVATION = "last_activation";
+    public static final String OWN = "ownkey";
+    public static final String CERTIFICATE = "certificate";
+
+    public static final String JSONKEY_REGISTRATION_ID = "axolotl_reg_id";
+    public static final String JSONKEY_CURRENT_PREKEY_ID = "axolotl_cur_prekey_id";
+
+    private static final int NUM_TRUSTS_TO_CACHE = 100;
+
+    private final Account account;
+    private final XmppConnectionService mXmppConnectionService;
+
+    private IdentityKeyPair identityKeyPair;
+    private int localRegistrationId;
+    private int currentPreKeyId = 0;
+
+    private final HashSet<Integer> preKeysMarkedForRemoval = new HashSet<>();
+
+    private final LruCache<String, FingerprintStatus> trustCache =
+            new LruCache<String, FingerprintStatus>(NUM_TRUSTS_TO_CACHE) {
+                @Override
+                protected FingerprintStatus create(String fingerprint) {
+                    return mXmppConnectionService.databaseBackend.getFingerprintStatus(
+                            account, fingerprint);
+                }
+            };
+
+    private static IdentityKeyPair generateIdentityKeyPair() {
+        Log.i(
+                Config.LOGTAG,
+                AxolotlService.LOGPREFIX + " : " + "Generating axolotl IdentityKeyPair...");
+        ECKeyPair identityKeyPairKeys = Curve.generateKeyPair();
+        return new IdentityKeyPair(
+                new IdentityKey(identityKeyPairKeys.getPublicKey()),
+                identityKeyPairKeys.getPrivateKey());
+    }
+
+    private static int generateRegistrationId() {
+        Log.i(
+                Config.LOGTAG,
+                AxolotlService.LOGPREFIX + " : " + "Generating axolotl registration ID...");
+        return KeyHelper.generateRegistrationId(true);
+    }
+
+    public SQLiteAxolotlStore(Account account, XmppConnectionService service) {
+        this.account = account;
+        this.mXmppConnectionService = service;
+        this.localRegistrationId = loadRegistrationId();
+        this.currentPreKeyId = loadCurrentPreKeyId();
+    }
+
+    public int getCurrentPreKeyId() {
+        return currentPreKeyId;
+    }
+
+    // --------------------------------------
+    // IdentityKeyStore
+    // --------------------------------------
+
+    private IdentityKeyPair loadIdentityKeyPair() {
+        synchronized (mXmppConnectionService) {
+            IdentityKeyPair ownKey =
+                    mXmppConnectionService.databaseBackend.loadOwnIdentityKeyPair(account);
+
+            if (ownKey != null) {
+                return ownKey;
+            } else {
+                Log.i(
+                        Config.LOGTAG,
+                        AxolotlService.getLogprefix(account)
+                                + "Could not retrieve own IdentityKeyPair");
+                ownKey = generateIdentityKeyPair();
+                mXmppConnectionService.databaseBackend.storeOwnIdentityKeyPair(account, ownKey);
+            }
+            return ownKey;
+        }
+    }
+
+    private int loadRegistrationId() {
+        return loadRegistrationId(false);
+    }
+
+    private int loadRegistrationId(boolean regenerate) {
+        String regIdString = this.account.getKey(JSONKEY_REGISTRATION_ID);
+        int reg_id;
+        if (!regenerate && regIdString != null) {
+            reg_id = Integer.valueOf(regIdString);
+        } else {
+            Log.i(
+                    Config.LOGTAG,
+                    AxolotlService.getLogprefix(account)
+                            + "Could not retrieve axolotl registration id for account "
+                            + account.getJid());
+            reg_id = generateRegistrationId();
+            boolean success =
+                    this.account.setKey(JSONKEY_REGISTRATION_ID, Integer.toString(reg_id));
+            if (success) {
+                mXmppConnectionService.databaseBackend.updateAccount(account);
+            } else {
+                Log.e(
+                        Config.LOGTAG,
+                        AxolotlService.getLogprefix(account)
+                                + "Failed to write new key to the database!");
+            }
+        }
+        return reg_id;
+    }
+
+    private int loadCurrentPreKeyId() {
+        String prekeyIdString = this.account.getKey(JSONKEY_CURRENT_PREKEY_ID);
+        int prekey_id;
+        if (prekeyIdString != null) {
+            prekey_id = Integer.valueOf(prekeyIdString);
+        } else {
+            Log.w(
+                    Config.LOGTAG,
+                    AxolotlService.getLogprefix(account)
+                            + "Could not retrieve current prekey id for account "
+                            + account.getJid());
+            prekey_id = 0;
+        }
+        return prekey_id;
+    }
+
+    public void regenerate() {
+        mXmppConnectionService.databaseBackend.wipeAxolotlDb(account);
+        trustCache.evictAll();
+        account.setKey(JSONKEY_CURRENT_PREKEY_ID, Integer.toString(0));
+        identityKeyPair = loadIdentityKeyPair();
+        localRegistrationId = loadRegistrationId(true);
+        currentPreKeyId = 0;
+        mXmppConnectionService.updateAccountUi();
+    }
+
+    /**
+     * Get the local client's identity key pair.
+     *
+     * @return The local client's persistent identity key pair.
+     */
+    @Override
+    public IdentityKeyPair getIdentityKeyPair() {
+        if (identityKeyPair == null) {
+            identityKeyPair = loadIdentityKeyPair();
+        }
+        return identityKeyPair;
+    }
+
+    /**
+     * Return the local client's registration ID.
+     *
+     * <p>Clients should maintain a registration ID, a random number between 1 and 16380 that's
+     * generated once at install time.
+     *
+     * @return the local client's registration ID.
+     */
+    @Override
+    public int getLocalRegistrationId() {
+        return localRegistrationId;
+    }
+
+    /**
+     * Save a remote client's identity key
+     *
+     * <p>Store a remote client's identity key as trusted.
+     *
+     * @param address The address of the remote client.
+     * @param identityKey The remote client's identity key.
+     * @return true on success
+     */
+    @Override
+    public boolean saveIdentity(
+            final SignalProtocolAddress address, final IdentityKey identityKey) {
+        if (!mXmppConnectionService
+                .databaseBackend
+                .loadIdentityKeys(account, address.getName())
+                .contains(identityKey)) {
+            String fingerprint = CryptoHelper.bytesToHex(identityKey.getPublicKey().serialize());
+            FingerprintStatus status = getFingerprintStatus(fingerprint);
+            if (status == null) {
+                if (mXmppConnectionService.getAppSettings().isBTBVEnabled()
+                        && !account.getAxolotlService().hasVerifiedKeys(address.getName())) {
+                    Log.d(
+                            Config.LOGTAG,
+                            account.getJid().asBareJid()
+                                    + ": blindly trusted "
+                                    + fingerprint
+                                    + " of "
+                                    + address.getName());
+                    status = FingerprintStatus.createActiveTrusted();
+                } else {
+                    status = FingerprintStatus.createActiveUndecided();
+                }
+            } else {
+                status = status.toActive();
+            }
+            mXmppConnectionService.databaseBackend.storeIdentityKey(
+                    account, address.getName(), identityKey, status);
+            trustCache.remove(fingerprint);
+        }
+        return true;
+    }
+
+    /**
+     * Verify a remote client's identity key.
+     *
+     * <p>Determine whether a remote client's identity is trusted. Convention is that the TextSecure
+     * protocol is 'trust on first use.' This means that an identity key is considered 'trusted' if
+     * there is no entry for the recipient in the local store, or if it matches the saved key for a
+     * recipient in the local store. Only if it mismatches an entry in the local store is it
+     * considered 'untrusted.'
+     *
+     * @param identityKey The identity key to verify.
+     * @return true if trusted, false if untrusted.
+     */
+    @Override
+    public boolean isTrustedIdentity(
+            SignalProtocolAddress address, IdentityKey identityKey, Direction direction) {
+        return true;
+    }
+
+    public FingerprintStatus getFingerprintStatus(String fingerprint) {
+        return (fingerprint == null) ? null : trustCache.get(fingerprint);
+    }
+
+    public void setFingerprintStatus(String fingerprint, FingerprintStatus status) {
+        mXmppConnectionService.databaseBackend.setIdentityKeyTrust(account, fingerprint, status);
+        trustCache.remove(fingerprint);
+    }
+
+    public void setFingerprintCertificate(String fingerprint, X509Certificate x509Certificate) {
+        mXmppConnectionService.databaseBackend.setIdentityKeyCertificate(
+                account, fingerprint, x509Certificate);
+    }
+
+    public X509Certificate getFingerprintCertificate(String fingerprint) {
+        return mXmppConnectionService.databaseBackend.getIdentityKeyCertifcate(
+                account, fingerprint);
+    }
+
+    public Set<IdentityKey> getContactKeysWithTrust(String bareJid, FingerprintStatus status) {
+        return mXmppConnectionService.databaseBackend.loadIdentityKeys(account, bareJid, status);
+    }
+
+    public long getContactNumTrustedKeys(String bareJid) {
+        return mXmppConnectionService.databaseBackend.numTrustedKeys(account, bareJid);
+    }
+
+    // --------------------------------------
+    // SessionStore
+    // --------------------------------------
+
+    /**
+     * Returns a copy of the {@link SessionRecord} corresponding to the recipientId + deviceId
+     * tuple, or a new SessionRecord if one does not currently exist.
+     *
+     * <p>It is important that implementations return a copy of the current durable information. The
+     * returned SessionRecord may be modified, but those changes should not have an effect on the
+     * durable session state (what is returned by subsequent calls to this method) without the store
+     * method being called here first.
+     *
+     * @param address The name and device ID of the remote client.
+     * @return a copy of the SessionRecord corresponding to the recipientId + deviceId tuple, or a
+     *     new SessionRecord if one does not currently exist.
+     */
+    @Override
+    public SessionRecord loadSession(SignalProtocolAddress address) {
+        SessionRecord session =
+                mXmppConnectionService.databaseBackend.loadSession(this.account, address);
+        return (session != null) ? session : new SessionRecord();
+    }
+
+    /**
+     * Returns all known devices with active sessions for a recipient
+     *
+     * @param name the name of the client.
+     * @return all known sub-devices with active sessions.
+     */
+    @Override
+    public List<Integer> getSubDeviceSessions(String name) {
+        return mXmppConnectionService.databaseBackend.getSubDeviceSessions(
+                account, new SignalProtocolAddress(name, 0));
+    }
+
+    public List<String> getKnownAddresses() {
+        return mXmppConnectionService.databaseBackend.getKnownSignalAddresses(account);
+    }
+
+    /**
+     * Commit to storage the {@link SessionRecord} for a given recipientId + deviceId tuple.
+     *
+     * @param address the address of the remote client.
+     * @param record the current SessionRecord for the remote client.
+     */
+    @Override
+    public void storeSession(SignalProtocolAddress address, SessionRecord record) {
+        mXmppConnectionService.databaseBackend.storeSession(account, address, record);
+    }
+
+    /**
+     * Determine whether there is a committed {@link SessionRecord} for a recipientId + deviceId
+     * tuple.
+     *
+     * @param address the address of the remote client.
+     * @return true if a {@link SessionRecord} exists, false otherwise.
+     */
+    @Override
+    public boolean containsSession(SignalProtocolAddress address) {
+        return mXmppConnectionService.databaseBackend.containsSession(account, address);
+    }
+
+    /**
+     * Remove a {@link SessionRecord} for a recipientId + deviceId tuple.
+     *
+     * @param address the address of the remote client.
+     */
+    @Override
+    public void deleteSession(SignalProtocolAddress address) {
+        mXmppConnectionService.databaseBackend.deleteSession(account, address);
+    }
+
+    /**
+     * Remove the {@link SessionRecord}s corresponding to all devices of a recipientId.
+     *
+     * @param name the name of the remote client.
+     */
+    @Override
+    public void deleteAllSessions(String name) {
+        SignalProtocolAddress address = new SignalProtocolAddress(name, 0);
+        mXmppConnectionService.databaseBackend.deleteAllSessions(account, address);
+    }
+
+    // --------------------------------------
+    // PreKeyStore
+    // --------------------------------------
+
+    /**
+     * Load a local PreKeyRecord.
+     *
+     * @param preKeyId the ID of the local PreKeyRecord.
+     * @return the corresponding PreKeyRecord.
+     * @throws InvalidKeyIdException when there is no corresponding PreKeyRecord.
+     */
+    @Override
+    public PreKeyRecord loadPreKey(int preKeyId) throws InvalidKeyIdException {
+        PreKeyRecord record = mXmppConnectionService.databaseBackend.loadPreKey(account, preKeyId);
+        if (record == null) {
+            throw new InvalidKeyIdException("No such PreKeyRecord: " + preKeyId);
+        }
+        return record;
+    }
+
+    /**
+     * Store a local PreKeyRecord.
+     *
+     * @param preKeyId the ID of the PreKeyRecord to store.
+     * @param record the PreKeyRecord.
+     */
+    @Override
+    public void storePreKey(int preKeyId, PreKeyRecord record) {
+        mXmppConnectionService.databaseBackend.storePreKey(account, record);
+        currentPreKeyId = preKeyId;
+        boolean success =
+                this.account.setKey(JSONKEY_CURRENT_PREKEY_ID, Integer.toString(preKeyId));
+        if (success) {
+            mXmppConnectionService.databaseBackend.updateAccount(account);
+        } else {
+            Log.e(
+                    Config.LOGTAG,
+                    AxolotlService.getLogprefix(account)
+                            + "Failed to write new prekey id to the database!");
+        }
+    }
+
+    /**
+     * @param preKeyId A PreKeyRecord ID.
+     * @return true if the store has a record for the preKeyId, otherwise false.
+     */
+    @Override
+    public boolean containsPreKey(int preKeyId) {
+        return mXmppConnectionService.databaseBackend.containsPreKey(account, preKeyId);
+    }
+
+    /**
+     * Delete a PreKeyRecord from local storage.
+     *
+     * @param preKeyId The ID of the PreKeyRecord to remove.
+     */
+    @Override
+    public void removePreKey(int preKeyId) {
+        Log.d(Config.LOGTAG, "mark prekey for removal " + preKeyId);
+        synchronized (preKeysMarkedForRemoval) {
+            preKeysMarkedForRemoval.add(preKeyId);
+        }
+    }
+
+    public boolean flushPreKeys() {
+        Log.d(Config.LOGTAG, "flushing pre keys");
+        int count = 0;
+        synchronized (preKeysMarkedForRemoval) {
+            for (Integer preKeyId : preKeysMarkedForRemoval) {
+                count += mXmppConnectionService.databaseBackend.deletePreKey(account, preKeyId);
+            }
+            preKeysMarkedForRemoval.clear();
+        }
+        return count > 0;
+    }
+
+    // --------------------------------------
+    // SignedPreKeyStore
+    // --------------------------------------
+
+    /**
+     * Load a local SignedPreKeyRecord.
+     *
+     * @param signedPreKeyId the ID of the local SignedPreKeyRecord.
+     * @return the corresponding SignedPreKeyRecord.
+     * @throws InvalidKeyIdException when there is no corresponding SignedPreKeyRecord.
+     */
+    @Override
+    public SignedPreKeyRecord loadSignedPreKey(int signedPreKeyId) throws InvalidKeyIdException {
+        SignedPreKeyRecord record =
+                mXmppConnectionService.databaseBackend.loadSignedPreKey(account, signedPreKeyId);
+        if (record == null) {
+            throw new InvalidKeyIdException("No such SignedPreKeyRecord: " + signedPreKeyId);
+        }
+        return record;
+    }
+
+    /**
+     * Load all local SignedPreKeyRecords.
+     *
+     * @return All stored SignedPreKeyRecords.
+     */
+    @Override
+    public List<SignedPreKeyRecord> loadSignedPreKeys() {
+        return mXmppConnectionService.databaseBackend.loadSignedPreKeys(account);
+    }
+
+    public int getSignedPreKeysCount() {
+        return mXmppConnectionService.databaseBackend.getSignedPreKeysCount(account);
+    }
+
+    /**
+     * Store a local SignedPreKeyRecord.
+     *
+     * @param signedPreKeyId the ID of the SignedPreKeyRecord to store.
+     * @param record the SignedPreKeyRecord.
+     */
+    @Override
+    public void storeSignedPreKey(int signedPreKeyId, SignedPreKeyRecord record) {
+        mXmppConnectionService.databaseBackend.storeSignedPreKey(account, record);
+    }
+
+    /**
+     * @param signedPreKeyId A SignedPreKeyRecord ID.
+     * @return true if the store has a record for the signedPreKeyId, otherwise false.
+     */
+    @Override
+    public boolean containsSignedPreKey(int signedPreKeyId) {
+        return mXmppConnectionService.databaseBackend.containsSignedPreKey(account, signedPreKeyId);
+    }
+
+    /**
+     * Delete a SignedPreKeyRecord from local storage.
+     *
+     * @param signedPreKeyId The ID of the SignedPreKeyRecord to remove.
+     */
+    @Override
+    public void removeSignedPreKey(int signedPreKeyId) {
+        mXmppConnectionService.databaseBackend.deleteSignedPreKey(account, signedPreKeyId);
+    }
+
+    public void preVerifyFingerprint(Account account, String name, String fingerprint) {
+        mXmppConnectionService.databaseBackend.storePreVerification(
+                account, name, fingerprint, FingerprintStatus.createInactiveVerified());
+    }
 }

src/main/java/eu/siacs/conversations/entities/Account.java 🔗

@@ -161,13 +161,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         this.password = password;
         this.options = options;
         this.rosterVersion = rosterVersion;
-        JSONObject tmp;
-        try {
-            tmp = new JSONObject(keys);
-        } catch (JSONException e) {
-            tmp = new JSONObject();
-        }
-        this.keys = tmp;
+        this.keys = parseKeys(keys);
         this.avatar = avatar;
         this.displayName = displayName;
         this.hostname = hostname;
@@ -180,6 +174,17 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         this.fastToken = fastToken;
     }
 
+    public static JSONObject parseKeys(final String keys) {
+        if (Strings.isNullOrEmpty(keys)) {
+            return new JSONObject();
+        }
+        try {
+            return new JSONObject(keys);
+        } catch (final JSONException e) {
+            return new JSONObject();
+        }
+    }
+
     public static Account fromCursor(final Cursor cursor) {
         final Jid jid;
         try {

src/main/java/eu/siacs/conversations/entities/DownloadableFile.java 🔗

@@ -1,94 +1,87 @@
 package eu.siacs.conversations.entities;
 
 import android.util.Log;
-
-import java.io.File;
-
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.utils.MimeUtils;
+import java.io.File;
 
 public class DownloadableFile extends File {
 
-	private static final long serialVersionUID = 2247012619505115863L;
-
-	private long expectedSize = 0;
-	private byte[] sha1sum;
-	private byte[] aeskey;
-	private byte[] iv;
-
-	public DownloadableFile(final File parent, final String file) {
-		super(parent, file);
-	}
-
-	public DownloadableFile(String path) {
-		super(path);
-	}
-
-	public long getSize() {
-		return super.length();
-	}
-
-	public long getExpectedSize() {
-		return this.expectedSize;
-	}
-
-	public String getMimeType() {
-		String path = this.getAbsolutePath();
-		int start = path.lastIndexOf('.') + 1;
-		if (start < path.length()) {
-			String mime = MimeUtils.guessMimeTypeFromExtension(path.substring(start));
-			return mime == null ? "" : mime;
-		} else {
-			return "";
-		}
-	}
-
-	public void setExpectedSize(long size) {
-		this.expectedSize = size;
-	}
-
-	public byte[] getSha1Sum() {
-		return this.sha1sum;
-	}
-
-	public void setSha1Sum(byte[] sum) {
-		this.sha1sum = sum;
-	}
-
-	public void setKeyAndIv(byte[] keyIvCombo) {
-		// originally, we used a 16 byte IV, then found for aes-gcm a 12 byte IV is ideal
-		// this code supports reading either length, with sending 12 byte IV to be done in future
-		if (keyIvCombo.length == 48) {
-			this.aeskey = new byte[32];
-			this.iv = new byte[16];
-			System.arraycopy(keyIvCombo, 0, this.iv, 0, 16);
-			System.arraycopy(keyIvCombo, 16, this.aeskey, 0, 32);
-		} else if (keyIvCombo.length == 44) {
-			this.aeskey = new byte[32];
-			this.iv = new byte[12];
-			System.arraycopy(keyIvCombo, 0, this.iv, 0, 12);
-			System.arraycopy(keyIvCombo, 12, this.aeskey, 0, 32);
-		} else if (keyIvCombo.length >= 32) {
-			this.aeskey = new byte[32];
-			this.iv = new byte[]{ 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0xf };
-			System.arraycopy(keyIvCombo, 0, aeskey, 0, 32);
-		}
-		Log.d(Config.LOGTAG,"using "+this.iv.length+"-byte IV for file transmission");
-	}
-
-	public void setKey(byte[] key) {
-		this.aeskey = key;
-	}
-
-	public void setIv(byte[] iv) {
-		this.iv = iv;
-	}
-
-	public byte[] getKey() {
-		return this.aeskey;
-	}
-
-	public byte[] getIv() {
-		return this.iv;
-	}
+    private static final long serialVersionUID = 2247012619505115863L;
+
+    private long expectedSize = 0;
+    private byte[] aeskey;
+    private byte[] iv;
+
+    public DownloadableFile(final File parent, final String file) {
+        super(parent, file);
+    }
+
+    public DownloadableFile(String path) {
+        super(path);
+    }
+
+    public long getSize() {
+        return super.length();
+    }
+
+    public long getExpectedSize() {
+        return this.expectedSize;
+    }
+
+    public String getMimeType() {
+        String path = this.getAbsolutePath();
+        int start = path.lastIndexOf('.') + 1;
+        if (start < path.length()) {
+            String mime = MimeUtils.guessMimeTypeFromExtension(path.substring(start));
+            return mime == null ? "" : mime;
+        } else {
+            return "";
+        }
+    }
+
+    public void setExpectedSize(long size) {
+        this.expectedSize = size;
+    }
+
+    public void setKeyAndIv(byte[] keyIvCombo) {
+        // originally, we used a 16 byte IV, then found for aes-gcm a 12 byte IV is ideal
+        // this code supports reading either length, with sending 12 byte IV to be done in future
+        if (keyIvCombo.length == 48) {
+            this.aeskey = new byte[32];
+            this.iv = new byte[16];
+            System.arraycopy(keyIvCombo, 0, this.iv, 0, 16);
+            System.arraycopy(keyIvCombo, 16, this.aeskey, 0, 32);
+        } else if (keyIvCombo.length == 44) {
+            this.aeskey = new byte[32];
+            this.iv = new byte[12];
+            System.arraycopy(keyIvCombo, 0, this.iv, 0, 12);
+            System.arraycopy(keyIvCombo, 12, this.aeskey, 0, 32);
+        } else if (keyIvCombo.length >= 32) {
+            this.aeskey = new byte[32];
+            this.iv =
+                    new byte[] {
+                        0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b,
+                        0x0c, 0x0d, 0x0e, 0xf
+                    };
+            System.arraycopy(keyIvCombo, 0, aeskey, 0, 32);
+        }
+        Log.d(Config.LOGTAG, "using " + this.iv.length + "-byte IV for file transmission");
+    }
+
+    public void setKey(byte[] key) {
+        this.aeskey = key;
+    }
+
+    public void setIv(byte[] iv) {
+        this.iv = iv;
+    }
+
+    public byte[] getKey() {
+        return this.aeskey;
+    }
+
+    public byte[] getIv() {
+        return this.iv;
+    }
 }

src/main/java/eu/siacs/conversations/entities/Message.java 🔗

@@ -48,19 +48,18 @@ import java.util.stream.Collectors;
 
 import io.ipfs.cid.Cid;
 
+import de.gultsch.common.Patterns;
+import de.gultsch.common.Linkify;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
 import eu.siacs.conversations.http.URL;
 import eu.siacs.conversations.services.AvatarService;
 import eu.siacs.conversations.ui.text.FixedURLSpan;
-import eu.siacs.conversations.ui.util.MyLinkify;
 import eu.siacs.conversations.ui.util.PresenceSelector;
 import eu.siacs.conversations.ui.util.QuoteHelper;
 import eu.siacs.conversations.utils.CryptoHelper;
 import eu.siacs.conversations.utils.Emoticons;
-import eu.siacs.conversations.utils.GeoHelper;
-import eu.siacs.conversations.utils.Patterns;
 import eu.siacs.conversations.utils.MessageUtils;
 import eu.siacs.conversations.utils.MimeUtils;
 import eu.siacs.conversations.utils.StringUtils;
@@ -1249,7 +1248,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         SpannableStringBuilder text = new SpannableStringBuilder(
             getBody(true).replaceAll("^>.*", "") // Remove quotes
         );
-        return MyLinkify.extractLinks(text).stream().map((url) -> {
+        return Linkify.extractLinks(text).stream().map((url) -> {
             try {
                 return new URI(url);
             } catch (final URISyntaxException e) {
@@ -1400,7 +1399,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
 
     public synchronized boolean isGeoUri() {
         if (isGeoUri == null) {
-            isGeoUri = GeoHelper.GEO_URI.matcher(body).matches();
+            isGeoUri = Patterns.URI_GEO.matcher(body).matches();
         }
         return isGeoUri;
     }

src/main/java/eu/siacs/conversations/http/HttpConnectionManager.java 🔗

@@ -8,23 +8,15 @@ import android.util.Log;
 
 import androidx.core.util.Consumer;
 
+import de.gultsch.common.TrustManagers;
 import eu.siacs.conversations.BuildConfig;
 import eu.siacs.conversations.Config;
-import eu.siacs.conversations.crypto.TrustManagers;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.DownloadableFile;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.services.AbstractConnectionManager;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.utils.TLSSocketFactory;
-
-import okhttp3.HttpUrl;
-import okhttp3.OkHttpClient;
-import okhttp3.Request;
-import okhttp3.ResponseBody;
-
-import org.apache.http.conn.ssl.StrictHostnameVerifier;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.InetAddress;
@@ -40,9 +32,13 @@ import java.util.List;
 import java.util.concurrent.Executor;
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
-
 import javax.net.ssl.SSLSocketFactory;
 import javax.net.ssl.X509TrustManager;
+import okhttp3.HttpUrl;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.ResponseBody;
+import org.apache.http.conn.ssl.StrictHostnameVerifier;
 
 public class HttpConnectionManager extends AbstractConnectionManager {
 
@@ -54,18 +50,20 @@ public class HttpConnectionManager extends AbstractConnectionManager {
     private static final OkHttpClient OK_HTTP_CLIENT;
 
     static {
-        OK_HTTP_CLIENT = new OkHttpClient.Builder()
-                .addInterceptor(chain -> {
-                    final Request original = chain.request();
-                    final Request modified = original.newBuilder()
-                            .header("User-Agent", getUserAgent())
-                            .build();
-                    return chain.proceed(modified);
-                })
-                .build();
+        OK_HTTP_CLIENT =
+                new OkHttpClient.Builder()
+                        .addInterceptor(
+                                chain -> {
+                                    final Request original = chain.request();
+                                    final Request modified =
+                                            original.newBuilder()
+                                                    .header("User-Agent", getUserAgent())
+                                                    .build();
+                                    return chain.proceed(modified);
+                                })
+                        .build();
     }
 
-
     public static String getUserAgent() {
         return String.format("%s/%s", BuildConfig.APP_NAME, BuildConfig.VERSION_NAME);
     }
@@ -77,7 +75,7 @@ public class HttpConnectionManager extends AbstractConnectionManager {
     public static Proxy getProxy() {
         final InetAddress localhost;
         try {
-            localhost = InetAddress.getByAddress(new byte[]{127, 0, 0, 1});
+            localhost = InetAddress.getByAddress(new byte[] {127, 0, 0, 1});
         } catch (final UnknownHostException e) {
             throw new IllegalStateException(e);
         }
@@ -100,7 +98,10 @@ public class HttpConnectionManager extends AbstractConnectionManager {
         synchronized (this.downloadConnections) {
             for (HttpDownloadConnection connection : this.downloadConnections) {
                 if (connection.getMessage() == message) {
-                    Log.d(Config.LOGTAG, message.getConversation().getAccount().getJid().asBareJid() + ": download already in progress");
+                    Log.d(
+                            Config.LOGTAG,
+                            message.getConversation().getAccount().getJid().asBareJid()
+                                    + ": download already in progress");
                     return;
                 }
             }
@@ -118,11 +119,18 @@ public class HttpConnectionManager extends AbstractConnectionManager {
         synchronized (this.uploadConnections) {
             for (HttpUploadConnection connection : this.uploadConnections) {
                 if (connection.getMessage() == message) {
-                    Log.d(Config.LOGTAG, message.getConversation().getAccount().getJid().asBareJid() + ": upload already in progress");
+                    Log.d(
+                            Config.LOGTAG,
+                            message.getConversation().getAccount().getJid().asBareJid()
+                                    + ": upload already in progress");
                     return;
                 }
             }
-            HttpUploadConnection connection = new HttpUploadConnection(message, Method.determine(message.getConversation().getAccount()), this, cb);
+            HttpUploadConnection connection =
+                    new HttpUploadConnection(
+                            message,
+                            Method.determine(message.getConversation().getAccount()),
+                            this, cb);
             connection.init(delay);
             this.uploadConnections.add(connection);
         }
@@ -144,7 +152,8 @@ public class HttpConnectionManager extends AbstractConnectionManager {
         return buildHttpClient(url, account, 30, interactive);
     }
 
-    public OkHttpClient buildHttpClient(final HttpUrl url, final Account account, int readTimeout, boolean interactive) {
+    public OkHttpClient buildHttpClient(
+            final HttpUrl url, final Account account, int readTimeout, boolean interactive) {
         final String slotHostname = url.host();
         final boolean onionSlot = slotHostname.endsWith(".onion");
         final OkHttpClient.Builder builder = newBuilder(mXmppConnectionService.useTorToConnect() || account.isOnion() || onionSlot);
@@ -161,7 +170,8 @@ public class HttpConnectionManager extends AbstractConnectionManager {
             trustManager = mXmppConnectionService.getMemorizingTrustManager().getNonInteractive();
         }
         try {
-            final SSLSocketFactory sf = new TLSSocketFactory(new X509TrustManager[]{trustManager}, SECURE_RANDOM);
+            final SSLSocketFactory sf =
+                    new TLSSocketFactory(new X509TrustManager[] {trustManager}, SECURE_RANDOM);
             builder.sslSocketFactory(sf, trustManager);
             builder.hostnameVerifier(new StrictHostnameVerifier());
         } catch (final KeyManagementException | NoSuchAlgorithmException ignored) {
@@ -222,20 +232,15 @@ public class HttpConnectionManager extends AbstractConnectionManager {
     public static OkHttpClient okHttpClient(final Context context) {
         final OkHttpClient.Builder builder = HttpConnectionManager.OK_HTTP_CLIENT.newBuilder();
         try {
-            final X509TrustManager trustManager;
-            if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) {
-                trustManager = TrustManagers.defaultWithBundledLetsEncrypt(context);
-            } else {
-                trustManager = TrustManagers.createDefaultTrustManager();
-            }
+            final X509TrustManager trustManager = TrustManagers.createForAndroidVersion(context);
             final SSLSocketFactory socketFactory =
                     new TLSSocketFactory(new X509TrustManager[] {trustManager}, SECURE_RANDOM);
             builder.sslSocketFactory(socketFactory, trustManager);
         } catch (final IOException
-                       | KeyManagementException
-                       | NoSuchAlgorithmException
-                       | KeyStoreException
-                       | CertificateException e) {
+                | KeyManagementException
+                | NoSuchAlgorithmException
+                | KeyStoreException
+                | CertificateException e) {
             Log.d(Config.LOGTAG, "not reconfiguring service to work with bundled LetsEncrypt");
             throw new RuntimeException(e);
         }

src/main/java/eu/siacs/conversations/parser/PresenceParser.java 🔗

@@ -95,6 +95,13 @@ public class PresenceParser extends AbstractParser
                                         && jid.equals(
                                                 Jid.Invalid.getNullForInvalid(
                                                         item.getAttributeAsJid("jid"))))) {
+                            Log.d(
+                                    Config.LOGTAG,
+                                    account.getJid().asBareJid()
+                                            + ": got self-presence from "
+                                            + user.getFullJid()
+                                            + ". occupant-id="
+                                            + occupantId);
                             if (mucOptions.setOnline()) {
                                 mXmppConnectionService.getAvatarService().clear(mucOptions);
                             }

src/main/java/eu/siacs/conversations/persistance/FileBackend.java 🔗

@@ -1880,7 +1880,7 @@ public class FileBackend {
         return Uri.fromFile(getAvatarFile(avatar));
     }
 
-    public Drawable cropCenterSquareDrawable(Uri image, int size) {
+    public Drawable cropCenterSquareDrawable(final Uri image, final int size) {
         if (android.os.Build.VERSION.SDK_INT >= 28) {
             try {
                 ImageDecoder.Source source = ImageDecoder.createSource(mXmppConnectionService.getContentResolver(), image);
@@ -1904,34 +1904,36 @@ public class FileBackend {
         }
     }
 
-    public Bitmap cropCenterSquare(Uri image, int size) {
+    public Bitmap cropCenterSquare(final Uri image, final int size) {
         if (image == null) {
             return null;
         }
-        InputStream is = null;
+        final BitmapFactory.Options options = new BitmapFactory.Options();
         try {
-            BitmapFactory.Options options = new BitmapFactory.Options();
             options.inSampleSize = calcSampleSize(image, size);
-            is = openInputStream(image);
+        } catch (final IOException | SecurityException e) {
+            Log.d(Config.LOGTAG, "unable to calculate sample size for " + image, e);
+            return null;
+        }
+        try (final InputStream is =
+                openInputStream(image)) {
             if (is == null) {
                 return null;
             }
-            Bitmap input = BitmapFactory.decodeStream(is, null, options);
-            if (input == null) {
+            final var originalBitmap = BitmapFactory.decodeStream(is, null, options);
+            if (originalBitmap == null) {
                 return null;
             } else {
-                input = rotate(input, getRotation(image));
-                return cropCenterSquare(input, size);
+                final var bitmap = rotate(originalBitmap, getRotation(image));
+                return cropCenterSquare(bitmap, size);
             }
-        } catch (SecurityException  | IOException e) {
-            Log.d(Config.LOGTAG, "unable to open file " + image.toString(), e);
+        } catch (final SecurityException | IOException e) {
+            Log.d(Config.LOGTAG, "unable to open file " + image, e);
             return null;
-        } finally {
-            close(is);
         }
     }
 
-    public Bitmap cropCenter(Uri image, int newHeight, int newWidth) {
+    public Bitmap cropCenter(final Uri image, final int newHeight, final int newWidth) {
         if (image == null) {
             return null;
         }
@@ -1939,7 +1941,7 @@ public class FileBackend {
         try {
             BitmapFactory.Options options = new BitmapFactory.Options();
             options.inSampleSize = calcSampleSize(image, Math.max(newHeight, newWidth));
-            is = mXmppConnectionService.getContentResolver().openInputStream(image);
+            is = openInputStream(image);
             if (is == null) {
                 return null;
             }
@@ -1995,14 +1997,14 @@ public class FileBackend {
         return output;
     }
 
-    private int calcSampleSize(Uri image, int size)
-            throws IOException, SecurityException {
+    private int calcSampleSize(final Uri image, int size) throws IOException, SecurityException {
         final BitmapFactory.Options options = new BitmapFactory.Options();
         options.inJustDecodeBounds = true;
-        final InputStream inputStream = openInputStream(image);
-        BitmapFactory.decodeStream(inputStream, null, options);
-        close(inputStream);
-        return calcSampleSize(options, size);
+        try (final InputStream inputStream =
+                openInputStream(image)) {
+            BitmapFactory.decodeStream(inputStream, null, options);
+            return calcSampleSize(options, size);
+        }
     }
 
     public void updateFileParams(final Message message) {

src/main/java/eu/siacs/conversations/services/MemorizingTrustManager.java 🔗

@@ -33,26 +33,20 @@ import android.content.Context;
 import android.content.Intent;
 import android.content.SharedPreferences;
 import android.net.Uri;
-import android.os.Build;
 import android.os.Handler;
 import android.preference.PreferenceManager;
 import android.util.Base64;
 import android.util.Log;
 import android.util.SparseArray;
-
 import androidx.appcompat.app.AppCompatActivity;
-
 import com.google.common.base.Charsets;
 import com.google.common.base.Joiner;
 import com.google.common.base.Preconditions;
 import com.google.common.io.ByteStreams;
 import com.google.common.io.CharStreams;
-
+import de.gultsch.common.TrustManagers;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
-import eu.siacs.conversations.crypto.BundledTrustManager;
-import eu.siacs.conversations.crypto.CombiningTrustManager;
-import eu.siacs.conversations.crypto.TrustManagers;
 import eu.siacs.conversations.crypto.XmppDomainVerifier;
 import eu.siacs.conversations.entities.MTMDecision;
 import eu.siacs.conversations.http.HttpConnectionManager;
@@ -89,10 +83,12 @@ import java.util.function.Consumer;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 import java.util.regex.Pattern;
-
 import javax.net.ssl.TrustManager;
 import javax.net.ssl.TrustManagerFactory;
 import javax.net.ssl.X509TrustManager;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
 
 /**
  * A X509 trust manager implementation which asks the user about invalid certificates and memorizes
@@ -118,7 +114,8 @@ public class MemorizingTrustManager {
                     "\\A(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
     private static final Pattern PATTERN_IPV6_HEX4DECCOMPRESSED =
             Pattern.compile(
-                    "\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) ::((?:[0-9A-Fa-f]{1,4}:)*)(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
+                    "\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)"
+                        + " ::((?:[0-9A-Fa-f]{1,4}:)*)(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
     private static final Pattern PATTERN_IPV6_6HEX4DEC =
             Pattern.compile(
                     "\\A((?:[0-9A-Fa-f]{1,4}:){6,6})(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
@@ -180,11 +177,7 @@ public class MemorizingTrustManager {
         this.appTrustManager = getTrustManager(appKeyStore);
         this.daneVerifier = new DaneVerifier();
         try {
-            if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) {
-                this.defaultTrustManager = TrustManagers.defaultWithBundledLetsEncrypt(context);
-            } else {
-                this.defaultTrustManager = TrustManagers.createDefaultTrustManager();
-            }
+            this.defaultTrustManager = TrustManagers.createForAndroidVersion(context);
         } catch (final NoSuchAlgorithmException
                 | KeyStoreException
                 | CertificateException

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -4183,7 +4183,7 @@ public class XmppConnectionService extends Service {
     }
 
     private void joinMuc(
-            Conversation conversation,
+            final Conversation conversation,
             final OnConferenceJoined onConferenceJoined,
             final boolean followedInvite) {
         final Account account = conversation.getAccount();
@@ -4933,6 +4933,7 @@ public class XmppConnectionService extends Service {
                                         bookmark == null ? null : bookmark.getBookmarkName(),
                                         mucOptions.getName());
 
+                        final var hadOccupantId = mucOptions.occupantId();
                         if (mucOptions.updateConfiguration(new ServiceDiscoveryResult(response))) {
                             Log.d(
                                     Config.LOGTAG,
@@ -4942,6 +4943,25 @@ public class XmppConnectionService extends Service {
                             updateConversation(conversation);
                         }
 
+                        final var hasOccupantId = mucOptions.occupantId();
+
+                        if (!hadOccupantId && hasOccupantId && mucOptions.online()) {
+                            final var me = mucOptions.getSelf().getFullJid();
+                            Log.d(
+                                    Config.LOGTAG,
+                                    account.getJid().asBareJid()
+                                            + ": gained support for occupant-id in "
+                                            + me
+                                            + ". resending presence");
+                            final var packet =
+                                    mPresenceGenerator.selfPresence(
+                                            account,
+                                            Presence.Status.ONLINE,
+                                            mucOptions.nonanonymous(), mucOptions.getSelf().getNick());
+                            packet.setTo(me);
+                            sendPresencePacket(account, packet);
+                        }
+
                         if (bookmark != null
                                 && (sameBefore || bookmark.getBookmarkName() == null)) {
                             if (bookmark.setBookmarkName(
@@ -6401,13 +6421,13 @@ public class XmppConnectionService extends Service {
             if (conversation.getMode() == Conversational.MODE_MULTI && !isPrivateMessage) {
                 final var mucOptions = conversation.getMucOptions();
                 if (!mucOptions.participating()) {
-                    Log.d(Config.LOGTAG, "not participating in MUC");
+                    Log.e(Config.LOGTAG, "not participating in MUC");
                     return false;
                 }
                 final var self = mucOptions.getSelf();
                 final String occupantId = self.getOccupantId();
                 if (Strings.isNullOrEmpty(occupantId)) {
-                    Log.d(Config.LOGTAG, "occupant id not found for reaction in MUC");
+                    Log.e(Config.LOGTAG, "occupant id not found for reaction in MUC");
                     return false;
                 }
                 final var existingRaw =
@@ -6453,6 +6473,7 @@ public class XmppConnectionService extends Service {
                                 null);
             }
             if (reactTo == null || Strings.isNullOrEmpty(reactToId)) {
+                Log.e(Config.LOGTAG, "could not find id to react to");
                 return false;
             }
 
@@ -7102,10 +7123,6 @@ public class XmppConnectionService extends Service {
         return verifiedSomething;
     }
 
-    public boolean blindTrustBeforeVerification() {
-        return getBooleanPreference(AppSettings.BLIND_TRUST_BEFORE_VERIFICATION, R.bool.btbv);
-    }
-
     public ShortcutService getShortcutService() {
         return mShortcutService;
     }

src/main/java/eu/siacs/conversations/ui/ChooseAccountForProfilePictureActivity.java 🔗

@@ -24,7 +24,7 @@ public class ChooseAccountForProfilePictureActivity extends XmppActivity {
     }
 
     @Override
-    protected void onCreate(Bundle savedInstanceState) {
+    protected void onCreate(final Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         final ActivityManageAccountsBinding binding =
                 DataBindingUtil.setContentView(this, R.layout.activity_manage_accounts);
@@ -64,11 +64,12 @@ public class ChooseAccountForProfilePictureActivity extends XmppActivity {
         }
     }
 
-    private void goToProfilePictureActivity(Account account) {
+    private void goToProfilePictureActivity(final Account account) {
         final Intent startIntent = getIntent();
         final Uri uri = startIntent == null ? null : startIntent.getData();
         if (uri != null) {
             Intent intent = new Intent(this, PublishProfilePictureActivity.class);
+            intent.setAction(Intent.ACTION_ATTACH_DATA);
             intent.putExtra(EXTRA_ACCOUNT, account.getJid().asBareJid().toString());
             intent.setData(uri);
             intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);

src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java 🔗

@@ -49,6 +49,7 @@ import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Collectors;
 
 import eu.siacs.conversations.Config;
+import de.gultsch.common.Linkify;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.ActivityMucDetailsBinding;
 import eu.siacs.conversations.databinding.ThreadRowBinding;
@@ -65,13 +66,13 @@ import eu.siacs.conversations.services.XmppConnectionService.OnMucRosterUpdate;
 import eu.siacs.conversations.ui.adapter.MediaAdapter;
 import eu.siacs.conversations.ui.adapter.UserPreviewAdapter;
 import eu.siacs.conversations.ui.interfaces.OnMediaLoaded;
+import eu.siacs.conversations.ui.text.FixedURLSpan;
 import eu.siacs.conversations.ui.util.Attachment;
 import eu.siacs.conversations.ui.util.AvatarWorkerTask;
 import eu.siacs.conversations.ui.util.GridManager;
 import eu.siacs.conversations.ui.util.MenuDoubleTabUtil;
 import eu.siacs.conversations.ui.util.MucConfiguration;
 import eu.siacs.conversations.ui.util.MucDetailsContextMenuHelper;
-import eu.siacs.conversations.ui.util.MyLinkify;
 import eu.siacs.conversations.ui.util.SoftKeyboardUtils;
 import eu.siacs.conversations.utils.AccountUtils;
 import eu.siacs.conversations.utils.Compatibility;
@@ -679,7 +680,8 @@ public class ConferenceDetailsActivity extends XmppActivity
         if (printableValue(subject)) {
             SpannableStringBuilder spannable = new SpannableStringBuilder(subject);
             StylingHelper.format(spannable, this.binding.mucSubject.getCurrentTextColor());
-            MyLinkify.addLinks(spannable, false);
+            Linkify.addLinks(spannable);
+            FixedURLSpan.fix(spannable);
             this.binding.mucSubject.setText(spannable);
             this.binding.mucSubject.setTextAppearance(
                     subject.length() > (hasTitle ? 128 : 196)

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java 🔗

@@ -39,9 +39,9 @@ import android.os.SystemClock;
 import android.preference.PreferenceManager;
 import android.provider.MediaStore;
 import android.text.Editable;
-import android.text.SpannableStringBuilder;
 import android.text.TextUtils;
 import android.text.TextWatcher;
+import android.text.SpannableStringBuilder;
 import android.text.style.ImageSpan;
 import android.util.DisplayMetrics;
 import android.util.Log;
@@ -132,6 +132,9 @@ import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
+import com.google.common.collect.Iterables;
+import de.gultsch.common.Linkify;
+import de.gultsch.common.Patterns;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
@@ -997,7 +1000,7 @@ public class ConversationFragment extends XmppFragment
                 message = new Message(conversation, body.toString(), conversation.getNextEncryption());
                 message.setBody(hasSubject && body.length() == 0 ? null : body);
                 if (message.bodyIsOnlyEmojis()) {
-                    SpannableStringBuilder spannable = message.getSpannableBody(null, null);
+                    var spannable = message.getSpannableBody(null, null);
                     ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
                     for (ImageSpan span : imageSpans) {
                         final int start = spannable.getSpanStart(span);
@@ -1758,7 +1761,7 @@ public class ConversationFragment extends XmppFragment
             return;
         }
 
-        SpannableStringBuilder body = message.getSpannableBody(null, null);
+        var body = message.getSpannableBody(null, null);
         if (message.isFileOrImage() || message.isOOb()) body.append(" 🖼️");
         messageListAdapter.handleTextQuotes(binding.contextPreviewText, body);
         binding.contextPreviewText.setText(body);
@@ -1914,13 +1917,22 @@ public class ConversationFragment extends XmppFragment
                     && t == null) {
                 copyMessage.setVisible(true);
                 quoteMessage.setVisible(!showError && !MessageUtils.prepareQuote(m).isEmpty());
-                final String scheme =
-                        ShareUtil.getLinkScheme(new SpannableStringBuilder(m.getBody()));
-                if ("xmpp".equals(scheme)) {
-                    copyLink.setTitle(R.string.copy_jabber_id);
-                    copyLink.setVisible(true);
-                } else if (scheme != null) {
+                final var firstUri = Iterables.getFirst(Linkify.getLinks(m.getBody()), null);
+                if (firstUri != null) {
+                    final var scheme = firstUri.getScheme();
+                    final @StringRes int resForScheme =
+                            switch (scheme) {
+                                case "xmpp" -> R.string.copy_jabber_id;
+                                case "http", "https", "gemini" -> R.string.copy_link;
+                                case "geo" -> R.string.copy_geo_uri;
+                                case "tel" -> R.string.copy_telephone_number;
+                                case "mailto" -> R.string.copy_email_address;
+                                default -> R.string.copy_URI;
+                            };
+                    copyLink.setTitle(resForScheme);
                     copyLink.setVisible(true);
+                } else {
+                    copyLink.setVisible(false);
                 }
             }
             quoteMessage.setVisible(!encrypted && !showError);
@@ -3008,12 +3020,14 @@ public class ConversationFragment extends XmppFragment
         builder.setNegativeButton(
                 R.string.copy_to_clipboard,
                 (dialog, which) -> {
-                    activity.copyTextToClipboard(displayError, R.string.error_message);
-                    Toast.makeText(
-                                    activity,
-                                    R.string.error_message_copied_to_clipboard,
-                                    Toast.LENGTH_SHORT)
-                            .show();
+                    if (activity.copyTextToClipboard(displayError, R.string.error_message)
+                            && Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+                        Toast.makeText(
+                                        activity,
+                                        R.string.error_message_copied_to_clipboard,
+                                        Toast.LENGTH_SHORT)
+                                .show();
+                    }
                 });
         builder.setPositiveButton(R.string.confirm, null);
         builder.create().show();
@@ -3633,7 +3647,7 @@ public class ConversationFragment extends XmppFragment
                 }
             }
         } else {
-            if (text != null && GeoHelper.GEO_URI.matcher(text).matches()) {
+            if (text != null && Patterns.URI_GEO.matcher(text).matches()) {
                 mediaPreviewAdapter.addMediaPreviews(
                         Attachment.of(getActivity(), Uri.parse(text), Attachment.Type.LOCATION));
                 toggleInputMethod();

src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java 🔗

@@ -1329,6 +1329,10 @@ public class EditAccountActivity extends OmemoActivity
         this.binding.accountPassword.setFocusableInTouchMode(editPassword);
         this.binding.accountPassword.setCursorVisible(editPassword);
         this.binding.accountPassword.setEnabled(editPassword);
+        if (!editPassword && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            this.binding.accountJid.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO);
+            this.binding.accountPassword.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO);
+        }
 
         if (!mInitMode) {
             this.binding.avater.setVisibility(View.VISIBLE);
@@ -1463,6 +1467,13 @@ public class EditAccountActivity extends OmemoActivity
                     this.mAccount.getAxolotlService().getOwnFingerprint();
             if (ownAxolotlFingerprint != null && Config.supportOmemo()) {
                 this.binding.axolotlFingerprintBox.setVisibility(View.VISIBLE);
+                this.binding.axolotlFingerprintBox.setOnCreateContextMenuListener(
+                        (menu, v, menuInfo) -> {
+                            getMenuInflater().inflate(R.menu.omemo_key_context, menu);
+                            menu.findItem(R.id.verify_scan).setVisible(false);
+                            menu.findItem(R.id.distrust_key).setVisible(false);
+                            this.mSelectedFingerprint = ownAxolotlFingerprint;
+                        });
                 if (ownAxolotlFingerprint.equals(messageFingerprint)) {
                     this.binding.ownFingerprintDesc.setTextColor(
                             MaterialColors.getColor(

src/main/java/eu/siacs/conversations/ui/OmemoActivity.java 🔗

@@ -1,12 +1,14 @@
 package eu.siacs.conversations.ui;
 
 import android.content.Intent;
+import android.os.Build;
 import android.view.ContextMenu;
 import android.view.MenuItem;
 import android.view.View;
 import android.widget.CompoundButton;
 import android.widget.LinearLayout;
 import android.widget.Toast;
+import androidx.annotation.NonNull;
 import androidx.databinding.DataBindingUtil;
 import com.google.android.material.color.MaterialColors;
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
@@ -22,7 +24,7 @@ import eu.siacs.conversations.utils.XmppUri;
 public abstract class OmemoActivity extends XmppActivity {
 
     private Account mSelectedAccount;
-    private String mSelectedFingerprint;
+    protected String mSelectedFingerprint;
 
     protected XmppUri mPendingFingerprintVerificationUri = null;
 
@@ -50,25 +52,27 @@ public abstract class OmemoActivity extends XmppActivity {
                 distrust.setVisible(
                         status.isVerified() || (!status.isActive() && status.isTrusted()));
             }
+            // TODO can we rework this into using Intents?
             this.mSelectedAccount = (Account) account;
             this.mSelectedFingerprint = (String) fingerprint;
         }
     }
 
     @Override
-    public boolean onContextItemSelected(MenuItem item) {
-        switch (item.getItemId()) {
-            case R.id.distrust_key:
-                showPurgeKeyDialog(mSelectedAccount, mSelectedFingerprint);
-                break;
-            case R.id.copy_omemo_key:
-                copyOmemoFingerprint(mSelectedFingerprint);
-                break;
-            case R.id.verify_scan:
-                ScanActivity.scan(this);
-                break;
+    public boolean onContextItemSelected(final MenuItem item) {
+        final var itemId = item.getItemId();
+        if (itemId == R.id.distrust_key) {
+            showPurgeKeyDialog(mSelectedAccount, mSelectedFingerprint);
+            return true;
+        } else if (itemId == R.id.copy_omemo_key) {
+            copyOmemoFingerprint(mSelectedFingerprint);
+            return true;
+        } else if (itemId == R.id.verify_scan) {
+            ScanActivity.scan(this);
+            return true;
+        } else {
+            return super.onContextItemSelected(item);
         }
-        return true;
     }
 
     @Override
@@ -89,8 +93,9 @@ public abstract class OmemoActivity extends XmppActivity {
 
     protected void copyOmemoFingerprint(String fingerprint) {
         if (copyTextToClipboard(
-                CryptoHelper.prettifyFingerprint(fingerprint.substring(2)),
-                R.string.omemo_fingerprint)) {
+                        CryptoHelper.prettifyFingerprint(fingerprint.substring(2)),
+                        R.string.omemo_fingerprint)
+                && Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
             Toast.makeText(this, R.string.toast_message_omemo_fingerprint, Toast.LENGTH_SHORT)
                     .show();
         }
@@ -240,7 +245,9 @@ public abstract class OmemoActivity extends XmppActivity {
 
     @Override
     public void onRequestPermissionsResult(
-            int requestCode, String[] permissions, int[] grantResults) {
+            final int requestCode,
+            @NonNull final String[] permissions,
+            @NonNull final int[] grantResults) {
         super.onRequestPermissionsResult(requestCode, permissions, grantResults);
         ScanActivity.onRequestPermissionResult(this, requestCode, grantResults);
     }

src/main/java/eu/siacs/conversations/ui/PublishGroupChatProfilePictureActivity.java 🔗

@@ -29,9 +29,6 @@
 
 package eu.siacs.conversations.ui;
 
-import static eu.siacs.conversations.ui.PublishProfilePictureActivity.REQUEST_CHOOSE_PICTURE;
-
-import android.content.Intent;
 import android.graphics.Bitmap;
 import android.graphics.drawable.AnimatedImageDrawable;
 import android.graphics.drawable.BitmapDrawable;
@@ -42,9 +39,11 @@ import android.os.Bundle;
 import android.util.Log;
 import android.view.View;
 import android.widget.Toast;
+import androidx.activity.result.ActivityResultLauncher;
 import androidx.annotation.StringRes;
 import androidx.databinding.DataBindingUtil;
-import com.canhub.cropper.CropImage;
+import com.canhub.cropper.CropImageContract;
+import com.canhub.cropper.CropImageContractOptions;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.ActivityPublishProfilePictureBinding;
@@ -60,6 +59,15 @@ public class PublishGroupChatProfilePictureActivity extends XmppActivity
     private Conversation conversation;
     private Uri uri;
 
+    final ActivityResultLauncher<CropImageContractOptions> cropImage =
+            registerForActivityResult(
+                    new CropImageContract(),
+                    cropResult -> {
+                        if (cropResult.isSuccessful()) {
+                            onAvatarPicked(cropResult.getUriContent());
+                        }
+                    });
+
     @Override
     protected void refreshUiReal() {}
 
@@ -103,8 +111,8 @@ public class PublishGroupChatProfilePictureActivity extends XmppActivity
         configureActionBar(getSupportActionBar());
         this.binding.cancelButton.setOnClickListener((v) -> this.finish());
         this.binding.secondaryHint.setVisibility(View.GONE);
-        this.binding.accountImage.setOnClickListener(
-                (v) -> PublishProfilePictureActivity.chooseAvatar(this));
+        this.binding.accountImage.setOnClickListener((v) -> pickAvatar());
+
         final var intent = getIntent();
         final var uuid = intent == null ? null : intent.getStringExtra("uuid");
         if (uuid != null) {
@@ -120,42 +128,17 @@ public class PublishGroupChatProfilePictureActivity extends XmppActivity
         xmppConnectionService.publishMucAvatar(conversation, uri, this);
     }
 
-    @Override
-    public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
-        super.onActivityResult(requestCode, resultCode, data);
-        if (requestCode == CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE) {
-            final CropImage.ActivityResult result = CropImage.getActivityResult(data);
-            if (resultCode == RESULT_OK) {
-                this.uri = result == null ? null : result.getUri();
-                if (xmppConnectionServiceBound) {
-                    reloadAvatar();
-                }
-            } else if (resultCode == CropImage.CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE) {
-                final var error = result == null ? null : result.getError();
-                if (error != null) {
-                    Toast.makeText(this, error.getMessage(), Toast.LENGTH_SHORT).show();
-                }
-            }
-        } else if (requestCode == REQUEST_CHOOSE_PICTURE) {
-            if (resultCode == RESULT_OK) {
-                cropUri(data.getData());
-            }
-        }
+    public void pickAvatar() {
+        this.cropImage.launch(
+                new CropImageContractOptions(
+                        null, PublishProfilePictureActivity.getCropImageOptions()));
     }
 
-    public void cropUri(final Uri uri) {
-        if (Build.VERSION.SDK_INT >= 28) {
-            this.uri = uri;
+    private void onAvatarPicked(final Uri uri) {
+        this.uri = uri;
+        if (xmppConnectionServiceBound) {
             reloadAvatar();
-            if (this.binding.accountImage.getDrawable() instanceof AnimatedImageDrawable || this.binding.accountImage.getDrawable() instanceof FileBackend.SVGDrawable) {
-                return;
-            }
         }
-
-        CropImage.activity(uri).setOutputCompressFormat(Bitmap.CompressFormat.PNG)
-                .setAspectRatio(1, 1)
-                .setMinCropResultSize(Config.AVATAR_SIZE, Config.AVATAR_SIZE)
-                .start(this);
     }
 
     @Override

src/main/java/eu/siacs/conversations/ui/PublishProfilePictureActivity.java 🔗

@@ -1,6 +1,5 @@
 package eu.siacs.conversations.ui;
 
-import android.app.Activity;
 import android.content.Intent;
 import android.graphics.drawable.AnimatedImageDrawable;
 import android.graphics.drawable.BitmapDrawable;
@@ -15,10 +14,13 @@ import android.view.MenuItem;
 import android.view.View;
 import android.view.View.OnLongClickListener;
 import android.widget.Toast;
+import androidx.activity.result.ActivityResultLauncher;
 import androidx.annotation.NonNull;
 import androidx.annotation.StringRes;
 import androidx.databinding.DataBindingUtil;
-import com.canhub.cropper.CropImage;
+import com.canhub.cropper.CropImageContract;
+import com.canhub.cropper.CropImageContractOptions;
+import com.canhub.cropper.CropImageOptions;
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
@@ -28,7 +30,6 @@ import eu.siacs.conversations.persistance.FileBackend;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.ui.interfaces.OnAvatarPublication;
 import eu.siacs.conversations.utils.PhoneHelper;
-import java.util.concurrent.atomic.AtomicBoolean;
 
 public class PublishProfilePictureActivity extends XmppActivity
         implements XmppConnectionService.OnAccountUpdate, OnAvatarPublication {
@@ -41,7 +42,6 @@ public class PublishProfilePictureActivity extends XmppActivity
     private Account account;
     private boolean support = false;
     private boolean publishing = false;
-    private final AtomicBoolean handledExternalUri = new AtomicBoolean(false);
     private final OnLongClickListener backToDefaultListener =
             new OnLongClickListener() {
 
@@ -54,6 +54,15 @@ public class PublishProfilePictureActivity extends XmppActivity
             };
     private boolean mInitialAccountSetup;
 
+    final ActivityResultLauncher<CropImageContractOptions> cropImage =
+            registerForActivityResult(
+                    new CropImageContract(),
+                    cropResult -> {
+                        if (cropResult.isSuccessful()) {
+                            onAvatarPicked(cropResult.getUriContent());
+                        }
+                    });
+
     @Override
     public void onAvatarPublicationSucceeded() {
         runOnUiThread(
@@ -89,6 +98,7 @@ public class PublishProfilePictureActivity extends XmppActivity
 
     @Override
     public void onCreate(final Bundle savedInstanceState) {
+
         super.onCreate(savedInstanceState);
 
         this.binding =
@@ -124,12 +134,10 @@ public class PublishProfilePictureActivity extends XmppActivity
                     }
                     finish();
                 });
-        this.binding.accountImage.setOnClickListener(v -> chooseAvatar(this));
+        this.binding.accountImage.setOnClickListener(v -> pickAvatar(null));
         this.defaultUri = PhoneHelper.getProfilePictureUri(getApplicationContext());
         if (savedInstanceState != null) {
             this.avatarUri = savedInstanceState.getParcelable("uri");
-            this.handledExternalUri.set(
-                    savedInstanceState.getBoolean("handle_external_uri", false));
         }
     }
 
@@ -172,47 +180,32 @@ public class PublishProfilePictureActivity extends XmppActivity
         if (this.avatarUri != null) {
             outState.putParcelable("uri", this.avatarUri);
         }
-        outState.putBoolean("handle_external_uri", handledExternalUri.get());
         super.onSaveInstanceState(outState);
     }
 
-    @Override
-    public void onActivityResult(int requestCode, int resultCode, Intent data) {
-        super.onActivityResult(requestCode, resultCode, data);
-        if (requestCode == CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE) {
-            CropImage.ActivityResult result = CropImage.getActivityResult(data);
-            if (resultCode == RESULT_OK) {
-                this.avatarUri = result.getUri();
-                if (xmppConnectionServiceBound) {
-                    loadImageIntoPreview(this.avatarUri);
-                }
-            } else if (resultCode == CropImage.CROP_IMAGE_ACTIVITY_RESULT_ERROR_CODE) {
-                Exception error = result.getError();
-                if (error != null) {
-                    Toast.makeText(this, error.getMessage(), Toast.LENGTH_SHORT).show();
-                }
-            }
-        } else if (requestCode == REQUEST_CHOOSE_PICTURE) {
-            if (resultCode == RESULT_OK) {
-                cropUri(this, data.getData());
-            }
-        }
+    public void pickAvatar(final Uri image) {
+        this.cropImage.launch(new CropImageContractOptions(image, getCropImageOptions()));
     }
 
-    public static void chooseAvatar(final Activity activity) {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
-            final Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
-            intent.setType("image/*");
-            activity.startActivityForResult(
-                    Intent.createChooser(
-                            intent, activity.getString(R.string.attach_choose_picture)),
-                    REQUEST_CHOOSE_PICTURE);
+    public static CropImageOptions getCropImageOptions() {
+        final var cropImageOptions = new CropImageOptions();
+        cropImageOptions.aspectRatioX = 1;
+        cropImageOptions.aspectRatioY = 1;
+        cropImageOptions.fixAspectRatio = true;
+        cropImageOptions.outputCompressFormat = Bitmap.CompressFormat.PNG;
+        cropImageOptions.imageSourceIncludeCamera = false;
+        cropImageOptions.minCropResultHeight = Config.AVATAR_SIZE;
+        cropImageOptions.minCropResultWidth = Config.AVATAR_SIZE;
+        return cropImageOptions;
+    }
+
+    private void onAvatarPicked(final Uri uri) {
+        Log.d(Config.LOGTAG, "onAvatarPicked(" + uri + ")");
+        this.avatarUri = uri;
+        if (xmppConnectionServiceBound) {
+            loadImageIntoPreview(uri);
         } else {
-            CropImage.activity()
-                    .setOutputCompressFormat(Bitmap.CompressFormat.PNG)
-                    .setAspectRatio(1, 1)
-                    .setMinCropResultSize(Config.AVATAR_SIZE, Config.AVATAR_SIZE)
-                    .start(activity);
+            Log.d(Config.LOGTAG, "not ready during avatarPick");
         }
     }
 
@@ -244,40 +237,30 @@ public class PublishProfilePictureActivity extends XmppActivity
     public void onStart() {
         super.onStart();
         final Intent intent = getIntent();
-        this.mInitialAccountSetup = intent != null && intent.getBooleanExtra("setup", false);
-
-        final Uri uri = intent != null ? intent.getData() : null;
-
-        if (uri != null && handledExternalUri.compareAndSet(false, true)) {
-            cropUri(this, uri);
+        if (intent == null) {
+            return;
+        }
+        this.mInitialAccountSetup = intent.getBooleanExtra("setup", false);
+
+        final var data = intent.getData();
+        final var account = intent.getStringExtra(EXTRA_ACCOUNT);
+        if (Intent.ACTION_ATTACH_DATA.equals(intent.getAction())
+                && data != null
+                && account != null) {
+            pickAvatar(data);
+            final var replacement = new Intent(Intent.ACTION_MAIN);
+            replacement.putExtra(EXTRA_ACCOUNT, account);
+            setIntent(replacement);
             return;
         }
 
         if (this.mInitialAccountSetup) {
             this.binding.cancelButton.setText(R.string.skip);
         }
-        configureActionBar(
-                getSupportActionBar(), !this.mInitialAccountSetup && !handledExternalUri.get());
-    }
-
-    public void cropUri(final Activity activity, final Uri uri) {
-        if (Build.VERSION.SDK_INT >= 28) {
-            loadImageIntoPreview(uri);
-            if (binding.accountImage.getDrawable() instanceof AnimatedImageDrawable || binding.accountImage.getDrawable() instanceof FileBackend.SVGDrawable) {
-                this.avatarUri = uri;
-                return;
-            }
-        }
-
-        CropImage.activity(uri)
-                .setOutputCompressFormat(Bitmap.CompressFormat.PNG)
-                .setAspectRatio(1, 1)
-                .setMinCropResultSize(Config.AVATAR_SIZE, Config.AVATAR_SIZE)
-                .start(this);
+        configureActionBar(getSupportActionBar(), !this.mInitialAccountSetup);
     }
 
     protected void loadImageIntoPreview(final Uri uri) {
-
         Drawable bm = null;
         if (uri == null) {
             bm =

src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java 🔗

@@ -61,7 +61,7 @@ public class AccountAdapter extends ArrayAdapter<Account> {
                 viewHolder.binding.accountStatus.setTextColor(
                         MaterialColors.getColor(
                                 viewHolder.binding.accountStatus,
-                                com.google.android.material.R.attr.colorPrimary));
+                                androidx.appcompat.R.attr.colorPrimary));
                 break;
             case DISABLED:
             case LOGGED_OUT:
@@ -75,7 +75,7 @@ public class AccountAdapter extends ArrayAdapter<Account> {
                 viewHolder.binding.accountStatus.setTextColor(
                         MaterialColors.getColor(
                                 viewHolder.binding.accountStatus,
-                                com.google.android.material.R.attr.colorError));
+                                androidx.appcompat.R.attr.colorError));
                 break;
         }
         if (account.isOnlineAndConnected()) {

src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java 🔗

@@ -7,17 +7,14 @@ import android.view.MenuItem;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
-
 import androidx.annotation.DrawableRes;
 import androidx.annotation.NonNull;
 import androidx.core.graphics.ColorUtils;
 import androidx.core.widget.ImageViewCompat;
 import androidx.databinding.DataBindingUtil;
 import androidx.recyclerview.widget.RecyclerView;
-
 import com.google.android.material.color.MaterialColors;
 import com.google.common.base.Optional;
-
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.ItemConversationBinding;
 import eu.siacs.conversations.entities.Conversation;
@@ -31,7 +28,6 @@ import eu.siacs.conversations.utils.IrregularUnicodeDetector;
 import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.jingle.OngoingRtpSession;
-
 import java.util.List;
 
 public class ConversationAdapter
@@ -109,14 +105,14 @@ public class ConversationAdapter
                         ColorStateList.valueOf(
                                 MaterialColors.getColor(
                                         viewHolder.binding.messageStatus,
-                                        com.google.android.material.R.attr.colorPrimary)));
+                                        androidx.appcompat.R.attr.colorPrimary)));
             } else {
                 ImageViewCompat.setImageTintList(
                         viewHolder.binding.messageStatus,
                         ColorStateList.valueOf(
                                 MaterialColors.getColor(
                                         viewHolder.binding.messageStatus,
-                                        com.google.android.material.R.attr.colorControlNormal)));
+                                        androidx.appcompat.R.attr.colorControlNormal)));
             }
             viewHolder.binding.messageStatus.setVisibility(View.VISIBLE);
         }

src/main/java/eu/siacs/conversations/ui/adapter/MediaAdapter.java 🔗

@@ -18,11 +18,13 @@ import androidx.databinding.DataBindingUtil;
 import androidx.recyclerview.widget.RecyclerView;
 import com.google.android.material.color.MaterialColors;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.ItemMediaBinding;
 import eu.siacs.conversations.ui.XmppActivity;
 import eu.siacs.conversations.ui.util.Attachment;
 import eu.siacs.conversations.ui.util.ViewUtil;
+import eu.siacs.conversations.utils.MimeUtils;
 import eu.siacs.conversations.worker.ExportBackupWorker;
 
 import java.io.File;
@@ -35,13 +37,12 @@ import java.util.concurrent.RejectedExecutionException;
 public class MediaAdapter extends RecyclerView.Adapter<MediaAdapter.MediaViewHolder> {
 
     public static final List<String> DOCUMENT_MIMES =
-            Arrays.asList(
-                    "application/pdf",
-                    "application/vnd.oasis.opendocument.text",
-                    "application/msword",
-                    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
-                    "text/x-tex",
-                    "text/plain");
+            new ImmutableList.Builder<String>()
+                    .add("application/pdf")
+                    .add("text/x-tex")
+                    .add("text/plain")
+                    .addAll(MimeUtils.WORD_DOCUMENT_MIMES)
+                    .build();
     public static final List<String> SPREAD_SHEET_MIMES =
             Arrays.asList(
                     "text/comma-separated-values",

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java 🔗

@@ -97,6 +97,7 @@ import me.saket.bettermovementmethod.BetterLinkMovementMethod;
 
 import net.fellbaum.jemoji.EmojiManager;
 
+import de.gultsch.common.Linkify;
 import eu.siacs.conversations.AppSettings;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
@@ -134,7 +135,6 @@ import eu.siacs.conversations.ui.text.FixedURLSpan;
 import eu.siacs.conversations.ui.text.QuoteSpan;
 import eu.siacs.conversations.ui.util.Attachment;
 import eu.siacs.conversations.ui.util.AvatarWorkerTask;
-import eu.siacs.conversations.ui.util.MyLinkify;
 import eu.siacs.conversations.ui.util.QuoteHelper;
 import eu.siacs.conversations.ui.util.ShareUtil;
 import eu.siacs.conversations.ui.util.ViewUtil;
@@ -335,8 +335,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                     .time()
                     .setTextColor(
                             MaterialColors.getColor(
-                                    viewHolder.time(),
-                                    com.google.android.material.R.attr.colorError));
+                                    viewHolder.time(), androidx.appcompat.R.attr.colorError));
         } else {
             setTextColor(viewHolder.time(), bubbleColor);
         }
@@ -638,7 +637,8 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             body.append("…");
         }
         if (processMarkup) StylingHelper.format(body, viewHolder.messageBody().getCurrentTextColor());
-        MyLinkify.addLinks(body, message.getConversation().getAccount(), message.getConversation().getJid());
+        Linkify.addLinks(body, message.getConversation().getAccount(), message.getConversation().getJid());
+        FixedURLSpan.fix(body);
         boolean startsWithQuote = processMarkup ? handleTextQuotes(viewHolder.messageBody(), body, bubbleColor, true) : false;
         for (final android.text.style.QuoteSpan quote : body.getSpans(0, body.length(), android.text.style.QuoteSpan.class)) {
             int start = body.getSpanStart(quote);
@@ -1951,8 +1951,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         ImageViewCompat.setImageTintList(
                 imageView,
                 ColorStateList.valueOf(
-                        MaterialColors.getColor(
-                                imageView, com.google.android.material.R.attr.colorError)));
+                        MaterialColors.getColor(imageView, androidx.appcompat.R.attr.colorError)));
     }
 
     public static void setTextColor(final TextView textView, final BubbleColor bubbleColor) {
@@ -1960,8 +1959,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         textView.setTextColor(color);
         if (BubbleColor.SURFACES.contains(bubbleColor)) {
             textView.setLinkTextColor(
-                    MaterialColors.getColor(
-                            textView, com.google.android.material.R.attr.colorPrimary));
+                    MaterialColors.getColor(textView, androidx.appcompat.R.attr.colorPrimary));
         } else {
             textView.setLinkTextColor(color);
         }

src/main/java/eu/siacs/conversations/ui/fragment/settings/BackupSettingsFragment.java 🔗

@@ -1,12 +1,13 @@
 package eu.siacs.conversations.ui.fragment.settings;
 
 import android.Manifest;
+import android.content.Intent;
 import android.content.pm.PackageManager;
+import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
 import android.util.Log;
 import android.widget.Toast;
-
 import androidx.activity.result.ActivityResultLauncher;
 import androidx.activity.result.contract.ActivityResultContracts;
 import androidx.annotation.NonNull;
@@ -23,17 +24,15 @@ import androidx.work.OneTimeWorkRequest;
 import androidx.work.OutOfQuotaPolicy;
 import androidx.work.PeriodicWorkRequest;
 import androidx.work.WorkManager;
-
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 import com.google.common.base.Strings;
 import com.google.common.primitives.Longs;
-
+import eu.siacs.conversations.AppSettings;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.persistance.FileBackend;
 import eu.siacs.conversations.worker.ExportBackupWorker;
-
 import java.util.concurrent.TimeUnit;
 
 public class BackupSettingsFragment extends XmppPreferenceFragment {
@@ -58,21 +57,34 @@ public class BackupSettingsFragment extends XmppPreferenceFragment {
                         }
                     });
 
+    private final ActivityResultLauncher<Uri> pickBackupLocationLauncher =
+            registerForActivityResult(
+                    new ActivityResultContracts.OpenDocumentTree(),
+                    uri -> {
+                        if (uri == null) {
+                            Log.d(Config.LOGTAG, "no backup location selected");
+                            return;
+                        }
+                        submitBackupLocationPreference(uri);
+                    });
+
     @Override
     public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) {
         setPreferencesFromResource(R.xml.preferences_backup, rootKey);
         final var createOneOffBackup = findPreference(CREATE_ONE_OFF_BACKUP);
         final var export = findPreference("export");
         final ListPreference recurringBackup = findPreference(RECURRING_BACKUP);
-        final var backupDirectory = findPreference("backup_directory");
-        if (createOneOffBackup == null || recurringBackup == null || backupDirectory == null) {
+        final var backupLocation = findPreference(AppSettings.BACKUP_LOCATION);
+        if (createOneOffBackup == null || recurringBackup == null || backupLocation == null) {
             throw new IllegalStateException(
                     "The preference resource file is missing some preferences");
         }
-        backupDirectory.setSummary(
+        final var appSettings = new AppSettings(requireContext());
+        backupLocation.setSummary(
                 getString(
                         R.string.pref_create_backup_summary,
-                        FileBackend.getBackupDirectory(requireContext()).getAbsolutePath()));
+                        appSettings.getBackupLocationAsPath()));
+        backupLocation.setOnPreferenceClickListener(this::onBackupLocationPreferenceClicked);
         createOneOffBackup.setOnPreferenceClickListener(this::onBackupPreferenceClicked);
         export.setOnPreferenceClickListener(this::onExportClicked);
         setValues(
@@ -81,6 +93,26 @@ public class BackupSettingsFragment extends XmppPreferenceFragment {
                 value -> timeframeValueToName(requireContext(), value));
     }
 
+    private boolean onBackupLocationPreferenceClicked(final Preference preference) {
+        this.pickBackupLocationLauncher.launch(null);
+        return false;
+    }
+
+    private void submitBackupLocationPreference(final Uri uri) {
+        final var contentResolver = requireContext().getContentResolver();
+        contentResolver.takePersistableUriPermission(
+                uri,
+                Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+        final var appSettings = new AppSettings(requireContext());
+        appSettings.setBackupLocation(uri);
+        final var preference = findPreference(AppSettings.BACKUP_LOCATION);
+        if (preference == null) {
+            return;
+        }
+        preference.setSummary(
+                getString(R.string.pref_create_backup_summary, AppSettings.asPath(uri)));
+    }
+
     @Override
     protected void onSharedPreferenceChanged(@NonNull String key) {
         super.onSharedPreferenceChanged(key);

src/main/java/eu/siacs/conversations/ui/text/FixedURLSpan.java 🔗

@@ -34,10 +34,10 @@ import android.content.ActivityNotFoundException;
 import android.content.Context;
 import android.content.Intent;
 import android.net.Uri;
-import android.os.Build;
 import android.text.Editable;
 import android.text.Spanned;
 import android.text.style.URLSpan;
+import android.util.Log;
 import android.view.SoundEffectConstants;
 import android.view.View;
 import android.widget.Toast;
@@ -46,37 +46,41 @@ import com.cheogram.android.BrowserHelper;
 
 import java.util.Arrays;
 
+import com.google.common.base.Joiner;
+import de.gultsch.common.MiniUri;
+import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.ui.ConversationsActivity;
 import eu.siacs.conversations.ui.ShowLocationActivity;
+import java.util.Arrays;
 
 @SuppressLint("ParcelCreator")
 public class FixedURLSpan extends URLSpan {
 
 	protected final Account account;
 
-	public FixedURLSpan(String url) {
+	public FixedURLSpan(final String url) {
 		this(url, null);
 	}
 
-	public FixedURLSpan(String url, Account account) {
+	public FixedURLSpan(final String url, Account account) {
 		super(url);
 		this.account = account;
 	}
 
-    public static void fix(final Editable editable) {
-        for (final URLSpan urlspan : editable.getSpans(0, editable.length() - 1, URLSpan.class)) {
-            final int start = editable.getSpanStart(urlspan);
-            final int end = editable.getSpanEnd(urlspan);
-            editable.removeSpan(urlspan);
-            editable.setSpan(
-                    new FixedURLSpan(urlspan.getURL()),
-                    start,
-                    end,
-                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
-        }
-    }
+	public static void fix(final Editable editable) {
+		for (final URLSpan urlspan : editable.getSpans(0, editable.length() - 1, URLSpan.class)) {
+			final int start = editable.getSpanStart(urlspan);
+			final int end = editable.getSpanEnd(urlspan);
+			editable.removeSpan(urlspan);
+			editable.setSpan(
+					new FixedURLSpan(urlspan.getURL()),
+					start,
+					end,
+					Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+		}
+	}
 
 	@Override
 	public void onClick(View widget) {
@@ -107,7 +111,19 @@ public class FixedURLSpan extends URLSpan {
 			return;
 		}
 
-		final Intent intent = new Intent(Intent.ACTION_VIEW, uri);
+		var intent = new Intent(Intent.ACTION_VIEW, uri);
+		if ("web+ap".equals(uri.getScheme())) {
+			if (intent.resolveActivity(context.getPackageManager()) == null) {
+				Log.d(Config.LOGTAG, "no app found to handle web+ap");
+				final var webApAsHttps =
+						Uri.parse(
+								String.format(
+										"https://%s/%s",
+										uri.getAuthority(),
+										Joiner.on('/').join(uri.getPathSegments())));
+				intent = new Intent(Intent.ACTION_VIEW, webApAsHttps);
+			}
+		}
 		if ("geo".equalsIgnoreCase(uri.getScheme())) {
 			intent.setClass(context, ShowLocationActivity.class);
 		} else {
@@ -118,10 +134,10 @@ public class FixedURLSpan extends URLSpan {
 			widget.playSoundEffect(SoundEffectConstants.CLICK);
 		} catch (ActivityNotFoundException e) {
 			if ("bitcoin".equals(uri.getScheme()) || "bitcoincash".equals(uri.getScheme()) || "monero".equals(uri.getScheme())) {
-			    Toast.makeText(context, "No compatible wallet app found", Toast.LENGTH_SHORT).show();
+				Toast.makeText(context, "No compatible wallet app found", Toast.LENGTH_SHORT).show();
 			} else {
-			    Toast.makeText(context, R.string.no_application_found_to_open_link, Toast.LENGTH_SHORT).show();
-		    }
+				Toast.makeText(context, R.string.no_application_found_to_open_link, Toast.LENGTH_SHORT).show();
+			}
 		}
 	}
 }

src/main/java/eu/siacs/conversations/ui/util/ShareUtil.java 🔗

@@ -41,18 +41,28 @@ import android.widget.Toast;
 
 import java.util.regex.Matcher;
 
+import android.os.Build;
+import android.widget.Toast;
+import androidx.annotation.StringRes;
+import com.google.common.collect.Iterables;
+import de.gultsch.common.Linkify;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.DownloadableFile;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.persistance.FileBackend;
 import eu.siacs.conversations.ui.ConversationsActivity;
 import eu.siacs.conversations.ui.XmppActivity;
-import eu.siacs.conversations.utils.Patterns;
 import eu.siacs.conversations.utils.XmppUri;
 import eu.siacs.conversations.xmpp.Jid;
+import java.util.Arrays;
+import java.util.Collection;
 
 public class ShareUtil {
 
+    private static final Collection<String> SCHEMES_COPY_PATH_ONLY =
+            Arrays.asList("xmpp", "mailto", "tel");
+
+
     public static void share(XmppActivity activity, Message message) {
         Intent shareIntent = new Intent();
         shareIntent.setAction(Intent.ACTION_SEND);
@@ -101,14 +111,26 @@ public class ShareUtil {
         }
     }
 
-    public static void copyToClipboard(XmppActivity activity, Message message) {
-        if (activity.copyTextToClipboard(message.getQuoteableBody(), R.string.message)) {
+    public static void copyToClipboard(final XmppActivity activity, final Message message) {
+        if (activity.copyTextToClipboard(message.getQuoteableBody(), R.string.message)
+                && Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
             Toast.makeText(activity, R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT)
                     .show();
         }
     }
 
-    public static void copyUrlToClipboard(XmppActivity activity, Message message) {
+	public static boolean copyTextToClipboard(Context context, String text, int labelResId) {
+        ClipboardManager mClipBoardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+        String label = context.getResources().getString(labelResId);
+        if (mClipBoardManager != null) {
+            ClipData mClipData = ClipData.newPlainText(label, text);
+            mClipBoardManager.setPrimaryClip(mClipData);
+            return true;
+        }
+        return false;
+    }
+
+    public static void copyUrlToClipboard(final XmppActivity activity, final Message message) {
         final String url;
         final int resId;
         if (message.isGeoUri()) {
@@ -125,11 +147,13 @@ public class ShareUtil {
                             : message.getRawBody().trim();
             resId = R.string.file_url;
         }
-        if (activity.copyTextToClipboard(url, resId)) {
+        if (activity.copyTextToClipboard(url, resId)
+                && Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
             Toast.makeText(activity, R.string.url_copied_to_clipboard, Toast.LENGTH_SHORT).show();
         }
     }
 
+
     public static void copyLinkToClipboard(final Context context, final String url) {
         final Uri uri = Uri.parse(url);
         if ("xmpp".equals(uri.getScheme())) {
@@ -147,35 +171,33 @@ public class ShareUtil {
     }
 
     public static void copyLinkToClipboard(final XmppActivity activity, final Message message) {
-        final SpannableStringBuilder body = message.getSpannableBody();
-        MyLinkify.addLinks(body, true);
-        for (final URLSpan urlspan : body.getSpans(0, body.length() - 1, URLSpan.class)) {
-            copyLinkToClipboard(activity, urlspan.getURL());
+        final var firstUri = Iterables.getFirst(Linkify.getLinks(message.getBody()), null);
+        if (firstUri == null) {
             return;
         }
-    }
-
-    public static boolean copyTextToClipboard(Context context, String text, int labelResId) {
-        ClipboardManager mClipBoardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
-        String label = context.getResources().getString(labelResId);
-        if (mClipBoardManager != null) {
-            ClipData mClipData = ClipData.newPlainText(label, text);
-            mClipBoardManager.setPrimaryClip(mClipData);
-            return true;
+        final String clip;
+        if (SCHEMES_COPY_PATH_ONLY.contains(firstUri.getScheme())) {
+            clip = firstUri.getPath();
+        } else {
+            clip = firstUri.getRaw();
         }
-        return false;
-    }
-
-    public static String getLinkScheme(final SpannableStringBuilder body) {
-        MyLinkify.addLinks(body, false);
-        for (final String url : MyLinkify.extractLinks(body)) {
-            final Uri uri = Uri.parse(url);
-            if ("xmpp".equals(uri.getScheme())) {
-                return uri.getScheme();
-            } else {
-                return "http";
-            }
+        final @StringRes int label =
+                switch (firstUri.getScheme()) {
+                    case "http", "https", "gemini" -> R.string.web_address;
+                    case "xmpp" -> R.string.account_settings_jabber_id;
+                    default -> R.string.uri;
+                };
+        final @StringRes int toast =
+                switch (firstUri.getScheme()) {
+                    case "http", "https", "gemini", "web+ap" -> R.string.url_copied_to_clipboard;
+                    case "xmpp" -> R.string.jabber_id_copied_to_clipboard;
+                    case "tel" -> R.string.copied_phone_number;
+                    case "mailto" -> R.string.copied_email_address;
+                    default -> R.string.uri_copied_to_clipboard;
+                };
+        if (activity.copyTextToClipboard(clip, label)
+                && Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+            Toast.makeText(activity, toast, Toast.LENGTH_SHORT).show();
         }
-        return null;
     }
 }

src/main/java/eu/siacs/conversations/utils/BackupFile.java 🔗

@@ -0,0 +1,185 @@
+package eu.siacs.conversations.utils;
+
+import android.content.Context;
+import android.content.UriPermission;
+import android.net.Uri;
+import android.os.Build;
+import android.provider.DocumentsContract;
+import android.util.Log;
+import androidx.documentfile.provider.DocumentFile;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Ordering;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.persistance.DatabaseBackend;
+import eu.siacs.conversations.persistance.FileBackend;
+import eu.siacs.conversations.services.QuickConversationsService;
+import eu.siacs.conversations.worker.ExportBackupWorker;
+import eu.siacs.conversations.xmpp.Jid;
+import java.io.DataInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class BackupFile implements Comparable<BackupFile> {
+
+    private static final ExecutorService BACKUP_FILE_READER_EXECUTOR =
+            Executors.newSingleThreadExecutor();
+
+    private final Uri uri;
+    private final BackupFileHeader header;
+
+    private BackupFile(Uri uri, BackupFileHeader header) {
+        this.uri = uri;
+        this.header = header;
+    }
+
+    public static ListenableFuture<BackupFile> readAsync(final Context context, final Uri uri) {
+        return Futures.submit(() -> read(context, uri), BACKUP_FILE_READER_EXECUTOR);
+    }
+
+    private static BackupFile read(final File file) throws IOException {
+        final FileInputStream fileInputStream = new FileInputStream(file);
+        final DataInputStream dataInputStream = new DataInputStream(fileInputStream);
+        BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
+        fileInputStream.close();
+        return new BackupFile(Uri.fromFile(file), backupFileHeader);
+    }
+
+    public static BackupFile read(final Context context, final Uri uri) throws IOException {
+        final InputStream inputStream = context.getContentResolver().openInputStream(uri);
+        if (inputStream == null) {
+            throw new FileNotFoundException();
+        }
+        final DataInputStream dataInputStream = new DataInputStream(inputStream);
+        final BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
+        inputStream.close();
+        return new BackupFile(uri, backupFileHeader);
+    }
+
+    public BackupFileHeader getHeader() {
+        return header;
+    }
+
+    public Uri getUri() {
+        return uri;
+    }
+
+    public static ListenableFuture<List<BackupFile>> listAsync(final Context context) {
+        return Futures.submit(() -> list(context), BACKUP_FILE_READER_EXECUTOR);
+    }
+
+    private static List<BackupFile> list(final Context context) {
+        final var database = DatabaseBackend.getInstance(context);
+        final List<Jid> accounts = database.getAccountJids(false);
+        final var backupFiles = new ImmutableList.Builder<BackupFile>();
+        final var apps =
+                ImmutableSet.of("Conversations", "Quicksy", context.getString(R.string.app_name));
+
+        final var uriPermissions = context.getContentResolver().getPersistedUriPermissions();
+
+        for (final UriPermission uriPermission : uriPermissions) {
+            final var uri = uriPermission.getUri();
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
+                    && DocumentsContract.isTreeUri(uri)) {
+                Log.d(Config.LOGTAG, "looking for backups in " + uri);
+                final var tree = DocumentFile.fromTreeUri(context, uriPermission.getUri());
+                final var files = tree == null ? new DocumentFile[0] : tree.listFiles();
+                for (final DocumentFile documentFile : files) {
+                    final var name = documentFile.getName();
+                    if (documentFile.isFile()
+                            && (ExportBackupWorker.MIME_TYPE.equals(documentFile.getType())
+                                    || (name != null && name.endsWith(".ceb")))) {
+                        try {
+                            final BackupFile backupFile =
+                                    BackupFile.read(context, documentFile.getUri());
+                            if (accounts.contains(backupFile.getHeader().getJid())) {
+                                Log.d(
+                                        Config.LOGTAG,
+                                        "skipping backup for " + backupFile.getHeader().getJid());
+                            } else {
+                                backupFiles.add(backupFile);
+                            }
+                        } catch (final IOException
+                                | IllegalArgumentException
+                                | BackupFileHeader.OutdatedBackupFileVersion e) {
+                            Log.d(Config.LOGTAG, "unable to read backup file ", e);
+                        }
+                    }
+                }
+            }
+        }
+
+        final List<File> directories = new ArrayList<>();
+        for (final String app : apps) {
+            directories.add(FileBackend.getLegacyBackupDirectory(app));
+        }
+        if (uriPermissions.isEmpty()) {
+            Log.d(
+                    Config.LOGTAG,
+                    "including default directory since no uri permissions have been granted");
+            directories.add(FileBackend.getBackupDirectory(context));
+        }
+        for (final File directory : directories) {
+            if (!directory.exists() || !directory.isDirectory()) {
+                Log.d(Config.LOGTAG, "directory not found: " + directory.getAbsolutePath());
+                continue;
+            }
+            final File[] files = directory.listFiles();
+            if (files == null) {
+                continue;
+            }
+            Log.d(Config.LOGTAG, "looking for backups in " + directory);
+            for (final File file : files) {
+                if (file.isFile() && file.getName().endsWith(".ceb")) {
+                    try {
+                        final BackupFile backupFile = BackupFile.read(file);
+                        if (accounts.contains(backupFile.getHeader().getJid())) {
+                            Log.d(
+                                    Config.LOGTAG,
+                                    "skipping backup for " + backupFile.getHeader().getJid());
+                        } else {
+                            backupFiles.add(backupFile);
+                        }
+                    } catch (final IOException
+                            | IllegalArgumentException
+                            | BackupFileHeader.OutdatedBackupFileVersion e) {
+                        Log.d(Config.LOGTAG, "unable to read backup file ", e);
+                    }
+                }
+            }
+        }
+        final var list = backupFiles.build();
+        if (QuickConversationsService.isQuicksy()) {
+            return Ordering.natural()
+                    .immutableSortedCopy(
+                            Collections2.filter(
+                                    list,
+                                    b ->
+                                            b.header
+                                                    .getJid()
+                                                    .getDomain()
+                                                    .equals(Config.QUICKSY_DOMAIN)));
+        }
+        return Ordering.natural().immutableSortedCopy(backupFiles.build());
+    }
+
+    @Override
+    public int compareTo(final BackupFile o) {
+        return ComparisonChain.start()
+                .compare(header.getJid(), o.header.getJid())
+                .compare(o.header.getTimestamp(), header.getTimestamp())
+                .result();
+    }
+}

src/main/java/eu/siacs/conversations/utils/GeoHelper.java 🔗

@@ -5,148 +5,168 @@ import android.content.Intent;
 import android.content.SharedPreferences;
 import android.net.Uri;
 import android.preference.PreferenceManager;
-
-import org.osmdroid.util.GeoPoint;
-
-import java.io.UnsupportedEncodingException;
-import java.net.URLEncoder;
-import java.util.ArrayList;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
+import de.gultsch.common.Patterns;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.Contact;
 import eu.siacs.conversations.entities.Conversational;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.ui.ShareLocationActivity;
 import eu.siacs.conversations.ui.ShowLocationActivity;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.util.ArrayList;
+import java.util.regex.Matcher;
+import org.osmdroid.util.GeoPoint;
 
 public class GeoHelper {
 
-	private static final String SHARE_LOCATION_PACKAGE_NAME = "eu.siacs.conversations.location.request";
-	private static final String SHOW_LOCATION_PACKAGE_NAME = "eu.siacs.conversations.location.show";
-
-	public static Pattern GEO_URI = Pattern.compile("geo:(-?\\d+(?:\\.\\d+)?),(-?\\d+(?:\\.\\d+)?)(?:,-?\\d+(?:\\.\\d+)?)?(?:;crs=[\\w-]+)?(?:;u=\\d+(?:\\.\\d+)?)?(?:;[\\w-]+=(?:[\\w-_.!~*'()]|%[\\da-f][\\da-f])+)*(\\?z=\\d+)?", Pattern.CASE_INSENSITIVE);
-
-	public static boolean isLocationPluginInstalled(Context context) {
-		return new Intent(SHARE_LOCATION_PACKAGE_NAME).resolveActivity(context.getPackageManager()) != null;
-	}
-
-	public static boolean isLocationPluginInstalledAndDesired(Context context) {
-		SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
-		final boolean configured = preferences.getBoolean("use_share_location_plugin", context.getResources().getBoolean(R.bool.use_share_location_plugin));
-		return configured && isLocationPluginInstalled(context);
-	}
-
-	public static Intent getFetchIntent(Context context) {
-		if (isLocationPluginInstalledAndDesired(context)) {
-			return new Intent(SHARE_LOCATION_PACKAGE_NAME);
-		} else {
-			return new Intent(context, ShareLocationActivity.class);
-		}
-	}
-
-	public static GeoPoint parseGeoPoint(final Uri uri) {
-		return parseGeoPoint(uri.toString());
-	}
-
-	private static GeoPoint parseGeoPoint(String body) throws IllegalArgumentException {
-		final Matcher matcher = GEO_URI.matcher(body);
-		if (!matcher.matches()) {
-			throw new IllegalArgumentException("Invalid geo uri");
-		}
-		final double latitude;
-		final double longitude;
-		try {
-			latitude = Double.parseDouble(matcher.group(1));
-			if (latitude > 90.0 || latitude < -90.0) {
-				throw new IllegalArgumentException("Invalid geo uri");
-			}
-			longitude = Double.parseDouble(matcher.group(2));
-			if (longitude > 180.0 || longitude < -180.0) {
-				throw new IllegalArgumentException("Invalid geo uri");
-			}
-		} catch (final NumberFormatException e) {
-			throw new IllegalArgumentException("Invalid geo uri",e);
-		}
-		return new GeoPoint(latitude, longitude);
-	}
-
-	public static ArrayList<Intent> createGeoIntentsFromMessage(Context context, Message message) {
-		final ArrayList<Intent> intents = new ArrayList<>();
-		final GeoPoint geoPoint;
-		try {
-			geoPoint = parseGeoPoint(message.getRawBody());
-		} catch (IllegalArgumentException e) {
-			return intents;
-		}
-		final Conversational conversation = message.getConversation();
-		final String label = getLabel(context, message);
-
-		if (isLocationPluginInstalledAndDesired(context)) {
-			Intent locationPluginIntent = new Intent(SHOW_LOCATION_PACKAGE_NAME);
-			locationPluginIntent.putExtra("latitude", geoPoint.getLatitude());
-			locationPluginIntent.putExtra("longitude", geoPoint.getLongitude());
-			if (message.getStatus() != Message.STATUS_RECEIVED) {
-				locationPluginIntent.putExtra("jid", conversation.getAccount().getJid().toString());
-				locationPluginIntent.putExtra("name", conversation.getAccount().getJid().getLocal());
-			} else {
-				Contact contact = message.getContact();
-				if (contact != null) {
-					locationPluginIntent.putExtra("name", contact.getDisplayName());
-					locationPluginIntent.putExtra("jid", contact.getJid().toString());
-				} else {
-					locationPluginIntent.putExtra("name", UIHelper.getDisplayedMucCounterpart(message.getCounterpart()));
-				}
-			}
-			intents.add(locationPluginIntent);
-		} else {
-			Intent intent = new Intent(context, ShowLocationActivity.class);
-			intent.setAction(SHOW_LOCATION_PACKAGE_NAME);
-			intent.putExtra("latitude", geoPoint.getLatitude());
-			intent.putExtra("longitude", geoPoint.getLongitude());
-			intents.add(intent);
-		}
-
-		intents.add(geoIntent(geoPoint, label));
-
-		Intent httpIntent = new Intent(Intent.ACTION_VIEW);
-		httpIntent.setData(Uri.parse("https://maps.google.com/maps?q=loc:"+ geoPoint.getLatitude() + "," + geoPoint.getLongitude() +label));
-		intents.add(httpIntent);
-		return intents;
-	}
-
-	public static void view(Context context, Message message) {
-		final GeoPoint geoPoint = parseGeoPoint(message.getRawBody());
-		final String label = getLabel(context, message);
-		context.startActivity(geoIntent(geoPoint,label));
-	}
-
-	private static Intent geoIntent(GeoPoint geoPoint, String label) {
-		Intent geoIntent = new Intent(Intent.ACTION_VIEW);
-		geoIntent.setData(Uri.parse("geo:" + geoPoint.getLatitude() + "," + geoPoint.getLongitude() + "?q=" + geoPoint.getLatitude() + "," + geoPoint.getLongitude() + "("+ label+")"));
-		return geoIntent;
-	}
-
-	public static boolean openInOsmAnd(Context context, Message message) {
-		try {
-			final GeoPoint geoPoint = parseGeoPoint(message.getRawBody());
-			final String label = getLabel(context, message);
-			return geoIntent(geoPoint, label).resolveActivity(context.getPackageManager()) != null;
-		} catch (IllegalArgumentException e) {
-			return false;
-		}
-	}
-
-	private static String getLabel(Context context, Message message) {
-		if(message.getStatus() == Message.STATUS_RECEIVED) {
-			try {
-				return URLEncoder.encode(UIHelper.getMessageDisplayName(message),"UTF-8");
-			} catch (UnsupportedEncodingException e) {
-				throw new AssertionError(e);
-			}
-		} else {
-			return context.getString(R.string.me);
-		}
-	}
+    private static final String SHARE_LOCATION_PACKAGE_NAME =
+            "eu.siacs.conversations.location.request";
+    private static final String SHOW_LOCATION_PACKAGE_NAME = "eu.siacs.conversations.location.show";
+
+    public static boolean isLocationPluginInstalled(Context context) {
+        return new Intent(SHARE_LOCATION_PACKAGE_NAME).resolveActivity(context.getPackageManager())
+                != null;
+    }
+
+    public static boolean isLocationPluginInstalledAndDesired(Context context) {
+        SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
+        final boolean configured =
+                preferences.getBoolean(
+                        "use_share_location_plugin",
+                        context.getResources().getBoolean(R.bool.use_share_location_plugin));
+        return configured && isLocationPluginInstalled(context);
+    }
+
+    public static Intent getFetchIntent(Context context) {
+        if (isLocationPluginInstalledAndDesired(context)) {
+            return new Intent(SHARE_LOCATION_PACKAGE_NAME);
+        } else {
+            return new Intent(context, ShareLocationActivity.class);
+        }
+    }
+
+    public static GeoPoint parseGeoPoint(final Uri uri) {
+        return parseGeoPoint(uri.toString());
+    }
+
+    private static GeoPoint parseGeoPoint(String body) throws IllegalArgumentException {
+        final Matcher matcher = Patterns.URI_GEO.matcher(body);
+        if (!matcher.matches()) {
+            throw new IllegalArgumentException("Invalid geo uri");
+        }
+        final double latitude;
+        final double longitude;
+        try {
+            latitude = Double.parseDouble(matcher.group(1));
+            if (latitude > 90.0 || latitude < -90.0) {
+                throw new IllegalArgumentException("Invalid geo uri");
+            }
+            longitude = Double.parseDouble(matcher.group(2));
+            if (longitude > 180.0 || longitude < -180.0) {
+                throw new IllegalArgumentException("Invalid geo uri");
+            }
+        } catch (final NumberFormatException e) {
+            throw new IllegalArgumentException("Invalid geo uri", e);
+        }
+        return new GeoPoint(latitude, longitude);
+    }
+
+    public static ArrayList<Intent> createGeoIntentsFromMessage(Context context, Message message) {
+        final ArrayList<Intent> intents = new ArrayList<>();
+        final GeoPoint geoPoint;
+        try {
+            geoPoint = parseGeoPoint(message.getRawBody());
+        } catch (IllegalArgumentException e) {
+            return intents;
+        }
+        final Conversational conversation = message.getConversation();
+        final String label = getLabel(context, message);
+
+        if (isLocationPluginInstalledAndDesired(context)) {
+            Intent locationPluginIntent = new Intent(SHOW_LOCATION_PACKAGE_NAME);
+            locationPluginIntent.putExtra("latitude", geoPoint.getLatitude());
+            locationPluginIntent.putExtra("longitude", geoPoint.getLongitude());
+            if (message.getStatus() != Message.STATUS_RECEIVED) {
+                locationPluginIntent.putExtra("jid", conversation.getAccount().getJid().toString());
+                locationPluginIntent.putExtra(
+                        "name", conversation.getAccount().getJid().getLocal());
+            } else {
+                Contact contact = message.getContact();
+                if (contact != null) {
+                    locationPluginIntent.putExtra("name", contact.getDisplayName());
+                    locationPluginIntent.putExtra("jid", contact.getJid().toString());
+                } else {
+                    locationPluginIntent.putExtra(
+                            "name", UIHelper.getDisplayedMucCounterpart(message.getCounterpart()));
+                }
+            }
+            intents.add(locationPluginIntent);
+        } else {
+            Intent intent = new Intent(context, ShowLocationActivity.class);
+            intent.setAction(SHOW_LOCATION_PACKAGE_NAME);
+            intent.putExtra("latitude", geoPoint.getLatitude());
+            intent.putExtra("longitude", geoPoint.getLongitude());
+            intents.add(intent);
+        }
+
+        intents.add(geoIntent(geoPoint, label));
+
+        Intent httpIntent = new Intent(Intent.ACTION_VIEW);
+        httpIntent.setData(
+                Uri.parse(
+                        "https://maps.google.com/maps?q=loc:"
+                                + geoPoint.getLatitude()
+                                + ","
+                                + geoPoint.getLongitude()
+                                + label));
+        intents.add(httpIntent);
+        return intents;
+    }
+
+    public static void view(Context context, Message message) {
+        final GeoPoint geoPoint = parseGeoPoint(message.getRawBody());
+        final String label = getLabel(context, message);
+        context.startActivity(geoIntent(geoPoint, label));
+    }
+
+    private static Intent geoIntent(GeoPoint geoPoint, String label) {
+        Intent geoIntent = new Intent(Intent.ACTION_VIEW);
+        geoIntent.setData(
+                Uri.parse(
+                        "geo:"
+                                + geoPoint.getLatitude()
+                                + ","
+                                + geoPoint.getLongitude()
+                                + "?q="
+                                + geoPoint.getLatitude()
+                                + ","
+                                + geoPoint.getLongitude()
+                                + "("
+                                + label
+                                + ")"));
+        return geoIntent;
+    }
+
+    public static boolean openInOsmAnd(Context context, Message message) {
+        try {
+            final GeoPoint geoPoint = parseGeoPoint(message.getRawBody());
+            final String label = getLabel(context, message);
+            return geoIntent(geoPoint, label).resolveActivity(context.getPackageManager()) != null;
+        } catch (IllegalArgumentException e) {
+            return false;
+        }
+    }
+
+    private static String getLabel(Context context, Message message) {
+        if (message.getStatus() == Message.STATUS_RECEIVED) {
+            try {
+                return URLEncoder.encode(UIHelper.getMessageDisplayName(message), "UTF-8");
+            } catch (UnsupportedEncodingException e) {
+                throw new AssertionError(e);
+            }
+        } else {
+            return context.getString(R.string.me);
+        }
+    }
 }

src/main/java/eu/siacs/conversations/utils/IP.java 🔗

@@ -1,34 +1,18 @@
 package eu.siacs.conversations.utils;
 
 import com.google.common.net.InetAddresses;
+import de.gultsch.common.Patterns;
 import java.net.InetAddress;
-import java.util.regex.Pattern;
 
 public class IP {
 
-    private static final Pattern PATTERN_IPV4 =
-            Pattern.compile(
-                    "\\A(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
-    private static final Pattern PATTERN_IPV6_HEX4DECCOMPRESSED =
-            Pattern.compile(
-                    "\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)"
-                        + " ::((?:[0-9A-Fa-f]{1,4}:)*)(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
-    private static final Pattern PATTERN_IPV6_6HEX4DEC =
-            Pattern.compile(
-                    "\\A((?:[0-9A-Fa-f]{1,4}:){6,6})(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
-    private static final Pattern PATTERN_IPV6_HEXCOMPRESSED =
-            Pattern.compile(
-                    "\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)\\z");
-    private static final Pattern PATTERN_IPV6 =
-            Pattern.compile("\\A(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\z");
-
     public static boolean matches(final String server) {
         return server != null
-                && (PATTERN_IPV4.matcher(server).matches()
-                        || PATTERN_IPV6.matcher(server).matches()
-                        || PATTERN_IPV6_6HEX4DEC.matcher(server).matches()
-                        || PATTERN_IPV6_HEX4DECCOMPRESSED.matcher(server).matches()
-                        || PATTERN_IPV6_HEXCOMPRESSED.matcher(server).matches());
+                && (Patterns.IPV4.matcher(server).matches()
+                        || Patterns.IPV6.matcher(server).matches()
+                        || Patterns.IPV6_6HEX4DEC.matcher(server).matches()
+                        || Patterns.IPV6_HEX4_DECOMPRESSED.matcher(server).matches()
+                        || Patterns.IPV6_HEX_COMPRESSED.matcher(server).matches());
     }
 
     public static String wrapIPv6(final String host) {

src/main/java/eu/siacs/conversations/utils/IrregularUnicodeDetector.java 🔗

@@ -77,14 +77,12 @@ public class IrregularUnicodeDetector {
         return style(
                 jid,
                 MaterialColors.getColor(
-                        context,
-                        com.google.android.material.R.attr.colorError,
-                        "colorError not found"));
+                        context, androidx.appcompat.R.attr.colorError, "colorError not found"));
     }
 
-    private static Spannable style(Jid jid, @ColorInt int color) {
-        PatternTuple patternTuple = find(jid);
-        SpannableStringBuilder builder = new SpannableStringBuilder();
+    private static Spannable style(final Jid jid, final @ColorInt int color) {
+        final var patternTuple = find(jid);
+        final var builder = new SpannableStringBuilder();
         if (jid.getLocal() != null && patternTuple.local != null) {
             SpannableString local = new SpannableString(jid.getLocal());
             colorize(local, patternTuple.local, color);
@@ -92,7 +90,7 @@ public class IrregularUnicodeDetector {
             builder.append('@');
         }
         if (jid.getDomain() != null) {
-            String[] labels = jid.getDomain().toString().split("\\.");
+            final var labels = jid.getDomain().toString().split("\\.");
             for (int i = 0; i < labels.length; ++i) {
                 SpannableString spannableString = new SpannableString(labels[i]);
                 colorize(spannableString, patternTuple.domain.get(i), color);

src/main/java/eu/siacs/conversations/utils/MimeUtils.java 🔗

@@ -48,6 +48,12 @@ public final class MimeUtils {
                     "video/3gpp", // .3gp files can contain audio, video or both
                     "video/3gpp2");
 
+    public static final List<String> WORD_DOCUMENT_MIMES =
+            Arrays.asList(
+                    "application/vnd.oasis.opendocument.text",
+                    "application/msword",
+                    "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
+
     private static final Map<String, String> mimeTypeToExtensionMap = new HashMap<>();
     private static final Map<String, String> extensionToMimeTypeMap = new HashMap<>();
 

src/main/java/eu/siacs/conversations/utils/Patterns.java 🔗

@@ -1,554 +0,0 @@
-/*
- * Copyright (C) 2007 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- *
- * Download latest version here:
- * https://android.googlesource.com/platform/frameworks/base.git/+/master/core/java/android/util/Patterns.java
- *
- *
- */
-package eu.siacs.conversations.utils;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-/**
- * Commonly used regular expression patterns.
- */
-public class Patterns {
-
-    public static final Pattern XMPP_PATTERN = Pattern
-            .compile("xmpp\\:(?:(?:["
-                    + Patterns.GOOD_IRI_CHAR
-                    + "\\;\\/\\?\\@\\&\\=\\#\\~\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])"
-                    + "|(?:\\%[a-fA-F0-9]{2}))+");
-
-    public static final Pattern BITCOIN_URI = Pattern
-            .compile("bitcoin\\:(?:[13][a-km-zA-HJ-NP-Z1-9]{25,34}|[bB][cC]1[pPqQ][a-zA-Z0-9]{38,58})(?:\\?(?:(?:["
-                    + Patterns.GOOD_IRI_CHAR
-                    + "\\;\\/\\?\\@\\&\\=\\#\\~\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])"
-                    + "|(?:\\%[a-fA-F0-9]{2}))+)?");
-
-    public static final Pattern BITCOINCASH_URI = Pattern
-            .compile("bitcoincash\\:(?:[13][a-km-zA-HJ-NP-Z1-9]{33}|[qp][a-z0-9]{41})(?:\\?(?:(?:["
-                    + Patterns.GOOD_IRI_CHAR
-                    + "\\;\\/\\?\\@\\&\\=\\#\\~\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])"
-                    + "|(?:\\%[a-fA-F0-9]{2}))+)?");
-
-    public static final Pattern ETHEREUM_URI = Pattern
-            .compile("ethereum\\:(?:pay\\-)?(0x[0-9a-f]{40})(?:@[0-9]+)?(?:/(?:(?:["
-                    + Patterns.GOOD_IRI_CHAR
-                    + "\\;\\/\\?\\@\\&\\=\\#\\~\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])"
-                    + "|(?:\\%[a-fA-F0-9]{2}))+)?(?:\\?(?:(?:["
-                    + Patterns.GOOD_IRI_CHAR
-                    + "\\;\\/\\?\\@\\&\\=\\#\\~\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])"
-                    + "|(?:\\%[a-fA-F0-9]{2}))+)?");
-
-    public static final Pattern MONERO_URI = Pattern
-            .compile("monero\\:(?:[48][0-9AB][1-9A-HJ-NP-Za-km-z]{93})(?:\\?(?:(?:["
-                    + Patterns.GOOD_IRI_CHAR
-                    + "\\;\\/\\?\\@\\&\\=\\#\\~\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])"
-                    + "|(?:\\%[a-fA-F0-9]{2}))+)?");
-
-    public static final Pattern WOWNERO_URI = Pattern
-            .compile("wownero\\:(?:W(?:[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{96}|[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{106}|[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{187}))(?:\\?(?:(?:["
-                    + Patterns.GOOD_IRI_CHAR
-                    + "\\;\\/\\?\\@\\&\\=\\#\\~\\-\\.\\+\\!\\*\\'\\(\\)\\,\\_])"
-                    + "|(?:\\%[a-fA-F0-9]{2}))+)?");
-
-    /**
-     *  Regular expression to match all IANA top-level domains.
-     *  List accurate as of 2011/07/18.  List taken from:
-     *  http://data.iana.org/TLD/tlds-alpha-by-domain.txt
-     *  This pattern is auto-generated by frameworks/ex/common/tools/make-iana-tld-pattern.py
-     *
-     *  @deprecated Due to the recent profileration of gTLDs, this API is
-     *  expected to become out-of-date very quickly. Therefore it is now
-     *  deprecated.
-     */
-    @Deprecated
-    public static final String TOP_LEVEL_DOMAIN_STR =
-            "((aero|arpa|asia|a[cdefgilmnoqrstuwxz])"
-                    + "|(biz|b[abdefghijmnorstvwyz])"
-                    + "|(cat|com|coop|c[acdfghiklmnoruvxyz])"
-                    + "|d[ejkmoz]"
-                    + "|(edu|e[cegrstu])"
-                    + "|f[ijkmor]"
-                    + "|(gov|g[abdefghilmnpqrstuwy])"
-                    + "|h[kmnrtu]"
-                    + "|(info|int|i[delmnoqrst])"
-                    + "|(jobs|j[emop])"
-                    + "|k[eghimnprwyz]"
-                    + "|l[abcikrstuvy]"
-                    + "|(mil|mobi|museum|m[acdeghklmnopqrstuvwxyz])"
-                    + "|(name|net|n[acefgilopruz])"
-                    + "|(org|om)"
-                    + "|(pro|p[aefghklmnrstwy])"
-                    + "|qa"
-                    + "|r[eosuw]"
-                    + "|s[abcdeghijklmnortuvyz]"
-                    + "|(tel|travel|t[cdfghjklmnoprtvwz])"
-                    + "|u[agksyz]"
-                    + "|v[aceginu]"
-                    + "|w[fs]"

src/main/java/eu/siacs/conversations/utils/UIHelper.java 🔗

@@ -8,7 +8,6 @@ import android.text.format.DateFormat;
 import android.text.format.DateUtils;
 import android.util.Pair;
 import android.widget.TextView;
-
 import androidx.annotation.ColorInt;
 import androidx.core.content.res.ResourcesCompat;
 import androidx.annotation.ColorRes;
@@ -29,6 +28,9 @@ import java.util.Date;
 import java.util.List;
 import java.util.Locale;
 
+import de.gultsch.common.Linkify;
+import com.google.android.material.color.MaterialColors;
+import com.google.common.base.Strings;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
@@ -43,39 +45,45 @@ import eu.siacs.conversations.entities.Reaction;
 import eu.siacs.conversations.entities.RtpSessionStatus;
 import eu.siacs.conversations.entities.Transferable;
 import eu.siacs.conversations.services.XmppConnectionService;
-import eu.siacs.conversations.ui.util.MyLinkify;
 import eu.siacs.conversations.ui.util.QuoteHelper;
 import eu.siacs.conversations.worker.ExportBackupWorker;
 import eu.siacs.conversations.xmpp.Jid;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
 
 public class UIHelper {
 
-    private static final List<String> LOCATION_QUESTIONS = Arrays.asList(
-            "where are you", //en
-            "where are you now", //en
-            "where are you right now", //en
-            "whats your 20", //en
-            "what is your 20", //en
-            "what's your 20", //en
-            "whats your twenty", //en
-            "what is your twenty", //en
-            "what's your twenty", //en
-            "wo bist du", //de
-            "wo bist du jetzt", //de
-            "wo bist du gerade", //de
-            "wo seid ihr", //de
-            "wo seid ihr jetzt", //de
-            "wo seid ihr gerade", //de
-            "dónde estás", //es
-            "donde estas" //es
-    );
-
-    private static final List<Character> PUNCTIONATION = Arrays.asList('.', ',', '?', '!', ';', ':');
-
-    private static final int SHORT_DATE_FLAGS = DateUtils.FORMAT_SHOW_DATE
-            | DateUtils.FORMAT_NO_YEAR | DateUtils.FORMAT_ABBREV_ALL;
-    private static final int FULL_DATE_FLAGS = DateUtils.FORMAT_SHOW_TIME
-            | DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_SHOW_DATE;
+    private static final List<String> LOCATION_QUESTIONS =
+            Arrays.asList(
+                    "where are you", // en
+                    "where are you now", // en
+                    "where are you right now", // en
+                    "whats your 20", // en
+                    "what is your 20", // en
+                    "what's your 20", // en
+                    "whats your twenty", // en
+                    "what is your twenty", // en
+                    "what's your twenty", // en
+                    "wo bist du", // de
+                    "wo bist du jetzt", // de
+                    "wo bist du gerade", // de
+                    "wo seid ihr", // de
+                    "wo seid ihr jetzt", // de
+                    "wo seid ihr gerade", // de
+                    "dónde estás", // es
+                    "donde estas" // es
+                    );
+
+    private static final List<Character> PUNCTIONATION =
+            Arrays.asList('.', ',', '?', '!', ';', ':');
+
+    private static final int SHORT_DATE_FLAGS =
+            DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NO_YEAR | DateUtils.FORMAT_ABBREV_ALL;
+    private static final int FULL_DATE_FLAGS =
+            DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_SHOW_DATE;
 
     public static String readableTimeDifference(Context context, long time) {
         return readableTimeDifference(context, time, false);
@@ -85,8 +93,7 @@ public class UIHelper {
         return readableTimeDifference(context, time, true);
     }
 
-    private static String readableTimeDifference(Context context, long time,
-                                                 boolean fullDate) {
+    private static String readableTimeDifference(Context context, long time, boolean fullDate) {
         if (time == 0) {
             return context.getString(R.string.just_now);
         }
@@ -103,11 +110,9 @@ public class UIHelper {
             return df.format(date);
         } else {
             if (fullDate) {
-                return DateUtils.formatDateTime(context, date.getTime(),
-                        FULL_DATE_FLAGS);
+                return DateUtils.formatDateTime(context, date.getTime(), FULL_DATE_FLAGS);
             } else {
-                return DateUtils.formatDateTime(context, date.getTime(),
-                        SHORT_DATE_FLAGS);
+                return DateUtils.formatDateTime(context, date.getTime(), SHORT_DATE_FLAGS);
             }
         }
     }
@@ -126,8 +131,7 @@ public class UIHelper {
         cal1.add(Calendar.DAY_OF_YEAR, -1);
         cal2.setTime(new Date(date));
         return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR)
-                && cal1.get(Calendar.DAY_OF_YEAR) == cal2
-                .get(Calendar.DAY_OF_YEAR);
+                && cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR);
     }
 
     public static boolean sameDay(long a, long b) {
@@ -140,8 +144,7 @@ public class UIHelper {
         cal1.setTime(a);
         cal2.setTime(b);
         return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR)
-                && cal1.get(Calendar.DAY_OF_YEAR) == cal2
-                .get(Calendar.DAY_OF_YEAR);
+                && cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR);
     }
 
     public static String lastseen(Context context, boolean active, long time) {
@@ -157,13 +160,13 @@ public class UIHelper {
         } else if (difference < 60 * 60 * 2) {
             return context.getString(R.string.last_seen_hour);
         } else if (difference < 60 * 60 * 24) {
-            return context.getString(R.string.last_seen_hours,
-                    Math.round(difference / (60.0 * 60.0)));
+            return context.getString(
+                    R.string.last_seen_hours, Math.round(difference / (60.0 * 60.0)));
         } else if (difference < 60 * 60 * 48) {
             return context.getString(R.string.last_seen_day);
         } else {
-            return context.getString(R.string.last_seen_days,
-                    Math.round(difference / (60.0 * 60.0 * 24.0)));
+            return context.getString(
+                    R.string.last_seen_days, Math.round(difference / (60.0 * 60.0 * 24.0)));
         }
     }
 
@@ -177,7 +180,7 @@ public class UIHelper {
         }
     }
 
-    public static int getColorForName(String name) {
+    public static int getColorForName(final String name) {
         return XEP0392Helper.rgbFromNick(name);
     }
 
@@ -185,7 +188,8 @@ public class UIHelper {
         return getMessagePreview(context, message, 0);
     }
 
-    public static Pair<CharSequence, Boolean> getMessagePreview(final XmppConnectionService context, final Message message, @ColorInt int textColor) {
+    public static Pair<CharSequence, Boolean> getMessagePreview(
+            final XmppConnectionService context, final Message message, @ColorInt int textColor) {
         final Transferable d = message.getTransferable();
         final boolean moderated = message.getModerated() != null;
         final boolean muted = message.getStatus() == Message.STATUS_RECEIVED && message.getConversation().getMode() == Conversation.MODE_MULTI && context.isMucUserMuted(new MucOptions.User(null, message.getConversation().getJid(), message.getOccupantId(), null, null));
@@ -194,27 +198,43 @@ public class UIHelper {
         } else if (d != null && !moderated) {
             switch (d.getStatus()) {
                 case Transferable.STATUS_CHECKING:
-                    return new Pair<>(context.getString(R.string.checking_x,
-                            getFileDescriptionString(context, message)), true);
+                    return new Pair<>(
+                            context.getString(
+                                    R.string.checking_x,
+                                    getFileDescriptionString(context, message)),
+                            true);
                 case Transferable.STATUS_DOWNLOADING:
-                    return new Pair<>(context.getString(R.string.receiving_x_file,
-                            getFileDescriptionString(context, message),
-                            d.getProgress()), true);
+                    return new Pair<>(
+                            context.getString(
+                                    R.string.receiving_x_file,
+                                    getFileDescriptionString(context, message),
+                                    d.getProgress()),
+                            true);
                 case Transferable.STATUS_OFFER:
                 case Transferable.STATUS_OFFER_CHECK_FILESIZE:
-                    return new Pair<>(context.getString(R.string.x_file_offered_for_download,
-                            getFileDescriptionString(context, message)), true);
+                    return new Pair<>(
+                            context.getString(
+                                    R.string.x_file_offered_for_download,
+                                    getFileDescriptionString(context, message)),
+                            true);
                 case Transferable.STATUS_FAILED:
                     return new Pair<>(context.getString(R.string.file_transmission_failed), true);
                 case Transferable.STATUS_CANCELLED:
-                    return new Pair<>(context.getString(R.string.file_transmission_cancelled), true);
+                    return new Pair<>(
+                            context.getString(R.string.file_transmission_cancelled), true);
                 case Transferable.STATUS_UPLOADING:
                     if (message.getStatus() == Message.STATUS_OFFERED) {
-                        return new Pair<>(context.getString(R.string.offering_x_file,
-                                getFileDescriptionString(context, message)), true);
+                        return new Pair<>(
+                                context.getString(
+                                        R.string.offering_x_file,
+                                        getFileDescriptionString(context, message)),
+                                true);
                     } else {
-                        return new Pair<>(context.getString(R.string.sending_x_file,
-                                getFileDescriptionString(context, message)), true);
+                        return new Pair<>(
+                                context.getString(
+                                        R.string.sending_x_file,
+                                        getFileDescriptionString(context, message)),
+                                true);
                     }
                 default:
                     return new Pair<>("", false);
@@ -235,18 +255,28 @@ public class UIHelper {
             if (!rtpSessionStatus.successful && received) {
                 return new Pair<>(context.getString(R.string.missed_call), true);
             } else {
-                return new Pair<>(context.getString(received ? R.string.incoming_call : R.string.outgoing_call), true);
+                return new Pair<>(
+                        context.getString(
+                                received ? R.string.incoming_call : R.string.outgoing_call),
+                        true);
             }
         } else {
             final String body = MessageUtils.filterLtrRtl(message.getBody());
             if (body.startsWith(Message.ME_COMMAND)) {
-                return new Pair<>(body.replaceAll("^" + Message.ME_COMMAND,
-                        UIHelper.getMessageDisplayName(message) + " "), false);
+                return new Pair<>(
+                        body.replaceAll(
+                                "^" + Message.ME_COMMAND,
+                                UIHelper.getMessageDisplayName(message) + " "),
+                        false);
             } else if (message.isGeoUri()) {
                 return new Pair<>(context.getString(R.string.location), true);
-            } else if (!moderated && (message.treatAsDownloadable() || MessageUtils.unInitiatedButKnownSize(message))) {
-                return new Pair<>(context.getString(R.string.x_file_offered_for_download,
-                        getFileDescriptionString(context, message)), true);
+            } else if (!moderated && (message.treatAsDownloadable()
+                    || MessageUtils.unInitiatedButKnownSize(message))) {
+                return new Pair<>(
+                        context.getString(
+                                R.string.x_file_offered_for_download,
+                                getFileDescriptionString(context, message)),
+                        true);
             } else {
                 Drawable fallbackImg = ResourcesCompat.getDrawable(context.getResources(), R.drawable.ic_photo_24dp, null);
                 fallbackImg.setBounds(0, 0, fallbackImg.getIntrinsicWidth(), fallbackImg.getIntrinsicHeight());
@@ -255,7 +285,7 @@ public class UIHelper {
                 if (textColor != 0 && processMarkup) {
                     StylingHelper.format(styledBody, 0, styledBody.length() - 1, textColor, false);
                 }
-                MyLinkify.addLinks(styledBody, message.getConversation().getAccount(), message.getConversation().getJid());
+                Linkify.addLinks(styledBody, message.getConversation().getAccount(), message.getConversation().getJid());
 
                 for (final android.text.style.QuoteSpan quote : Lists.reverse(Lists.newArrayList(styledBody.getSpans(0, styledBody.length(), android.text.style.QuoteSpan.class)))) {
                     int start = styledBody.getSpanStart(quote);
@@ -317,18 +347,18 @@ public class UIHelper {
         return input.length() > 256 ? StylingHelper.subSequence(input, 0, 256) : input;
     }
 
-    public static boolean isPositionPrecededByBodyStart(CharSequence body, int pos){
+    public static boolean isPositionPrecededByBodyStart(CharSequence body, int pos) {
         // true if not a single linebreak before current position
-        for (int i = pos - 1; i >= 0; i--){
-            if (body.charAt(i) != ' '){
+        for (int i = pos - 1; i >= 0; i--) {
+            if (body.charAt(i) != ' ') {
                 return false;
             }
         }
         return true;
     }
 
-    public static boolean isPositionPrecededByLineStart(CharSequence body, int pos){
-        if (isPositionPrecededByBodyStart(body, pos)){
+    public static boolean isPositionPrecededByLineStart(CharSequence body, int pos) {
+        if (isPositionPrecededByBodyStart(body, pos)) {
             return true;
         }
         return body.charAt(pos - 1) == '\n';
@@ -376,7 +406,8 @@ public class UIHelper {
             final char c = body.charAt(i);
             if (Character.isWhitespace(c)) {
                 return false;
-            } else if (QuoteHelper.isPositionQuoteCharacter(body, pos) || QuoteHelper.isPositionQuoteEndCharacter(body, pos)) {
+            } else if (QuoteHelper.isPositionQuoteCharacter(body, pos)
+                    || QuoteHelper.isPositionQuoteEndCharacter(body, pos)) {
                 return body.length() == i + 1 || Character.isWhitespace(body.charAt(i + 1));
             }
         }
@@ -439,6 +470,8 @@ public class UIHelper {
             return context.getString(R.string.image);
         } else if (mime.contains("pdf")) {
             return context.getString(R.string.pdf_document);
+        } else if (MimeUtils.WORD_DOCUMENT_MIMES.contains(mime)) {
+            return context.getString(R.string.word_document);
         } else if (mime.equals("application/vnd.android.package-archive")) {
             return context.getString(R.string.apk);
         } else if (mime.equals(ExportBackupWorker.MIME_TYPE)) {
@@ -447,7 +480,8 @@ public class UIHelper {
             return context.getString(R.string.vcard);
         } else if (mime.equals("text/x-vcalendar") || mime.equals("text/calendar")) {
             return context.getString(R.string.event);
-        } else if (mime.equals("application/epub+zip") || mime.equals("application/vnd.amazon.mobi8-ebook")) {
+        } else if (mime.equals("application/epub+zip")
+                || mime.equals("application/vnd.amazon.mobi8-ebook")) {
             return context.getString(R.string.ebook);
         } else if (mime.equals("application/gpx+xml")) {
             return context.getString(R.string.gpx_track);
@@ -488,7 +522,8 @@ public class UIHelper {
                 return contact != null ? contact.getDisplayName() : "";
             }
         } else {
-            if (conversation instanceof Conversation && conversation.getMode() == Conversation.MODE_MULTI) {
+            if (conversation instanceof Conversation
+                    && conversation.getMode() == Conversation.MODE_MULTI) {
                 return ((Conversation) conversation).getMucOptions().getSelf().getNick();
             } else {
                 final Account account = conversation.getAccount();
@@ -499,7 +534,6 @@ public class UIHelper {
                 } else {
                     return displayName;
                 }
-
             }
         }
     }
@@ -520,7 +554,7 @@ public class UIHelper {
         return conversation == null ? reaction.from.asBareJid().toString() : conversation.getAccount().getRoster().getContact(reaction.from).getDisplayName();
     }
 
-    public static String getMessageHint(final Context context,final  Conversation conversation) {
+    public static String getMessageHint(final Context context, final Conversation conversation) {
         return switch (conversation.getNextEncryption()) {
             case Message.ENCRYPTION_NONE -> {
                 if (Config.multipleEncryptionChoices()) {
@@ -557,10 +591,12 @@ public class UIHelper {
                 || message.getType() != Message.TYPE_TEXT) {
             return false;
         }
-        final String body = Strings.nullToEmpty(message.getBody())
-                .trim()
-                .toLowerCase(Locale.getDefault())
-                .replace("?", "").replace("¿", "");
+        final String body =
+                Strings.nullToEmpty(message.getBody())
+                        .trim()
+                        .toLowerCase(Locale.getDefault())
+                        .replace("?", "")
+                        .replace("¿", "");
         return LOCATION_QUESTIONS.contains(body);
     }
 

src/main/java/eu/siacs/conversations/utils/XmppUri.java 🔗

@@ -45,19 +45,19 @@ public class XmppUri {
         }
     }
 
-    public XmppUri(Uri uri) {
+    public XmppUri(final Uri uri) {
         parse(uri);
     }
 
-    public XmppUri(Uri uri, boolean safeSource) {
+    public XmppUri(final Uri uri, final boolean safeSource) {
         this.safeSource = safeSource;
         parse(uri);
     }
 
-    private static Map<String, String> parseParameters(final String query, final char seperator) {
+    private static Map<String, String> parseParameters(final String query, final char separator) {
         final ImmutableMap.Builder<String, String> builder = new ImmutableMap.Builder<>();
         final String[] pairs =
-                query == null ? new String[0] : query.split(String.valueOf(seperator));
+                query == null ? new String[0] : query.split(String.valueOf(separator));
         for (String pair : pairs) {
             final String[] parts = pair.split("=", 2);
             if (parts.length == 0) {
@@ -278,7 +278,8 @@ public class XmppUri {
         public final String fingerprint;
         final int deviceId;
 
-        public Fingerprint(FingerprintType type, String fingerprint, int deviceId) {
+        public Fingerprint(
+                final FingerprintType type, final String fingerprint, final int deviceId) {
             this.type = type;
             this.fingerprint = fingerprint;
             this.deviceId = deviceId;

src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java 🔗

@@ -16,6 +16,7 @@ import android.os.SystemClock;
 import android.util.Log;
 import androidx.annotation.NonNull;
 import androidx.core.app.NotificationCompat;
+import androidx.documentfile.provider.DocumentFile;
 import androidx.work.ForegroundInfo;
 import androidx.work.WorkManager;
 import androidx.work.Worker;
@@ -26,6 +27,7 @@ import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gson.stream.JsonWriter;
+import eu.siacs.conversations.AppSettings;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
@@ -34,12 +36,14 @@ import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.persistance.DatabaseBackend;
 import eu.siacs.conversations.persistance.FileBackend;
+import eu.siacs.conversations.services.QuickConversationsService;
 import eu.siacs.conversations.utils.BackupFileHeader;
 import eu.siacs.conversations.utils.Compatibility;
 import java.io.DataOutputStream;
 import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
+import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.nio.charset.StandardCharsets;
@@ -69,9 +73,9 @@ public class ExportBackupWorker extends Worker {
     private static final SimpleDateFormat DATE_FORMAT =
             new SimpleDateFormat("yyyy-MM-dd-HH-mm", Locale.US);
 
-    public static final String KEYTYPE = "AES";
-    public static final String CIPHERMODE = "AES/GCM/NoPadding";
-    public static final String PROVIDER = "BC";
+    private static final String KEY_TYPE = "AES";
+    private static final String CIPHER_MODE = "AES/GCM/NoPadding";
+    private static final String PROVIDER = "BC";
 
     public static final String MIME_TYPE = "application/vnd.conversations.backup";
 
@@ -96,7 +100,7 @@ public class ExportBackupWorker extends Worker {
     @Override
     public Result doWork() {
         setForegroundAsync(getForegroundInfo());
-        final List<File> files;
+        final List<Uri> files;
         try {
             files = export();
         } catch (final IOException
@@ -136,7 +140,7 @@ public class ExportBackupWorker extends Worker {
         }
     }
 
-    private List<File> export()
+    private List<Uri> export()
             throws IOException,
                     InvalidKeySpecException,
                     InvalidAlgorithmParameterException,
@@ -145,17 +149,19 @@ public class ExportBackupWorker extends Worker {
                     NoSuchAlgorithmException,
                     NoSuchProviderException {
         final Context context = getApplicationContext();
+        final var appSettings = new AppSettings(context);
+        final var backupLocation = appSettings.getBackupLocation();
         final var database = DatabaseBackend.getInstance(context);
         final var accounts = database.getAccounts();
 
         int count = 0;
         final int max = accounts.size();
-        final ImmutableList.Builder<File> files = new ImmutableList.Builder<>();
+        final ImmutableList.Builder<Uri> locations = new ImmutableList.Builder<>();
         Log.d(Config.LOGTAG, "starting backup for " + max + " accounts");
         for (final Account account : accounts) {
             if (isStopped()) {
                 Log.d(Config.LOGTAG, "ExportBackupWorker has stopped. Returning what we have");
-                return files.build();
+                return locations.build();
             }
             final String password = account.getPassword();
             if (Strings.nullToEmpty(password).trim().isEmpty()) {
@@ -168,34 +174,24 @@ public class ExportBackupWorker extends Worker {
                 count++;
                 continue;
             }
-            final String filename =
-                    String.format(
-                            "%s.%s.ceb",
-                            account.getJid().asBareJid().toString(),
-                            DATE_FORMAT.format(new Date()));
-            final File file = new File(FileBackend.getBackupDirectory(context), filename);
+            final Uri uri;
             try {
-                export(database, account, password, file, max, count);
+                uri = export(database, account, password, backupLocation, max, count);
             } catch (final WorkStoppedException e) {
-                if (file.delete()) {
-                    Log.d(
-                            Config.LOGTAG,
-                            "deleted in progress backup file " + file.getAbsolutePath());
-                }
                 Log.d(Config.LOGTAG, "ExportBackupWorker has stopped. Returning what we have");
-                return files.build();
+                return locations.build();
             }
-            files.add(file);
+            locations.add(uri);
             count++;
         }
-        return files.build();
+        return locations.build();
     }
 
-    private void export(
+    private Uri export(
             final DatabaseBackend database,
             final Account account,
             final String password,
-            final File file,
+            final Uri backupLocation,
             final int max,
             final int count)
             throws IOException,
@@ -234,24 +230,48 @@ public class ExportBackupWorker extends Worker {
                                 cancelPendingIntent)
                         .build());
         final Progress progress = new Progress(notification, max, count);
-        final File directory = file.getParentFile();
-        if (directory != null && directory.mkdirs()) {
-            Log.d(Config.LOGTAG, "created backup directory " + directory.getAbsolutePath());
+        final String filename =
+                String.format(
+                        "%s.%s.ceb",
+                        account.getJid().asBareJid().toString(), DATE_FORMAT.format(new Date()));
+        final OutputStream outputStream;
+        final Uri location;
+        if ("file".equalsIgnoreCase(backupLocation.getScheme())) {
+            final File file = new File(backupLocation.getPath(), filename);
+            final File directory = file.getParentFile();
+            if (directory != null && directory.mkdirs()) {
+                Log.d(Config.LOGTAG, "created backup directory " + directory.getAbsolutePath());
+            }
+            outputStream = new FileOutputStream(file);
+            location = Uri.fromFile(file);
+        } else {
+            final var tree = DocumentFile.fromTreeUri(context, backupLocation);
+            if (tree == null) {
+                throw new IOException(
+                        String.format(
+                                "DocumentFile.fromTreeUri returned null for %s", backupLocation));
+            }
+            final var file = tree.createFile(MIME_TYPE, filename);
+            if (file == null) {
+                throw new IOException(
+                        String.format("Could not create %s in %s", filename, backupLocation));
+            }
+            location = file.getUri();
+            outputStream = context.getContentResolver().openOutputStream(location);
         }
-        final FileOutputStream fileOutputStream = new FileOutputStream(file);
-        final DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
+        final DataOutputStream dataOutputStream = new DataOutputStream(outputStream);
         backupFileHeader.write(dataOutputStream);
         dataOutputStream.flush();
 
         final Cipher cipher =
                 Compatibility.twentyEight()
-                        ? Cipher.getInstance(CIPHERMODE)
-                        : Cipher.getInstance(CIPHERMODE, PROVIDER);
+                        ? Cipher.getInstance(CIPHER_MODE)
+                        : Cipher.getInstance(CIPHER_MODE, PROVIDER);
         final byte[] key = getKey(password, salt);
-        SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
+        SecretKeySpec keySpec = new SecretKeySpec(key, KEY_TYPE);
         IvParameterSpec ivSpec = new IvParameterSpec(IV);
         cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
-        CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher);
+        CipherOutputStream cipherOutputStream = new CipherOutputStream(outputStream, cipher);
 
         final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
         final SQLiteDatabase db = database.getReadableDatabase();
@@ -267,13 +287,12 @@ public class ExportBackupWorker extends Worker {
                         SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
                         SQLiteAxolotlStore.SESSION_TABLENAME,
                         SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
-            throwIfWorkStopped();
+            throwIfWorkStopped(location);
             simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, writer);
         }
         writer.flush();
         writer.close();
-        mediaScannerScanFile(file);
-        Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
+        return location;
     }
 
     private NotificationCompat.Builder getNotification() {
@@ -289,8 +308,20 @@ public class ExportBackupWorker extends Worker {
         return notification;
     }
 
-    private void throwIfWorkStopped() throws WorkStoppedException {
+    private void throwIfWorkStopped(final Uri location) throws WorkStoppedException {
         if (isStopped()) {
+            if ("file".equalsIgnoreCase(location.getScheme())) {
+                final var file = new File(location.getPath());
+                if (file.delete()) {
+                    Log.d(Config.LOGTAG, "deleted " + file.getAbsolutePath());
+                }
+            } else {
+                final var documentFile =
+                        DocumentFile.fromSingleUri(getApplicationContext(), location);
+                if (documentFile != null && documentFile.delete()) {
+                    Log.d(Config.LOGTAG, "deleted " + location);
+                }
+            }
             throw new WorkStoppedException();
         }
     }
@@ -495,16 +526,19 @@ public class ExportBackupWorker extends Worker {
                 .getEncoded();
     }
 
-    private void notifySuccess(final List<File> files) {
+    private void notifySuccess(final List<Uri> locations) {
         final var context = getApplicationContext();
-        final String path = FileBackend.getBackupDirectory(context).getAbsolutePath();
-
-        final var openFolderIntent = getOpenFolderIntent(path);
-
+        final var appSettings = new AppSettings(context);
+        final String path = appSettings.getBackupLocationAsPath();
         final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
         final ArrayList<Uri> uris = new ArrayList<>();
-        for (final File file : files) {
-            uris.add(FileBackend.getUriForFile(context, file, file.getName()));
+        for (final Uri uri : locations) {
+            if ("file".equalsIgnoreCase(uri.getScheme())) {
+                final var file = new File(uri.getPath());
+                uris.add(FileBackend.getUriForFile(context, file, file.getName()));
+            } else {
+                uris.add(uri);
+            }
         }
         intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
         intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
@@ -514,8 +548,8 @@ public class ExportBackupWorker extends Worker {
         final var shareFilesIntent =
                 PendingIntent.getActivity(context, 190, chooser, PENDING_INTENT_FLAGS);
 
-        NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(context, "backup");
-        mBuilder.setContentTitle(context.getString(R.string.notification_backup_created_title))
+        NotificationCompat.Builder builder = new NotificationCompat.Builder(context, "backup");
+        builder.setContentTitle(context.getString(R.string.notification_backup_created_title))
                 .setContentText(
                         context.getString(R.string.notification_backup_created_subtitle, path))
                 .setStyle(
@@ -523,60 +557,17 @@ public class ExportBackupWorker extends Worker {
                                 .bigText(
                                         context.getString(
                                                 R.string.notification_backup_created_subtitle,
-                                                FileBackend.getBackupDirectory(context)
-                                                        .getAbsolutePath())))
+                                                path)))
                 .setAutoCancel(true)
                 .setSmallIcon(R.drawable.ic_archive_24dp);
 
-        if (openFolderIntent.isPresent()) {
-            mBuilder.setContentIntent(openFolderIntent.get());
-        } else {
-            Log.w(Config.LOGTAG, "no app can display folders");
-        }
-
-        mBuilder.addAction(
+        builder.addAction(
                 R.drawable.ic_share_24dp,
                 context.getString(R.string.share_backup_files),
                 shareFilesIntent);
+        builder.setLocalOnly(true);
         final var notificationManager = context.getSystemService(NotificationManager.class);
-        notificationManager.notify(BACKUP_CREATED_NOTIFICATION_ID, mBuilder.build());
-    }
-
-    private Optional<PendingIntent> getOpenFolderIntent(final String path) {
-        final var context = getApplicationContext();
-        for (final Intent intent : getPossibleFileOpenIntents(context, path)) {
-            if (intent.resolveActivityInfo(context.getPackageManager(), 0) != null) {
-                return Optional.of(
-                        PendingIntent.getActivity(context, 189, intent, PENDING_INTENT_FLAGS));
-            }
-        }
-        return Optional.absent();
-    }
-
-    private static List<Intent> getPossibleFileOpenIntents(
-            final Context context, final String path) {
-
-        // http://www.openintents.org/action/android-intent-action-view/file-directory
-        // do not use 'vnd.android.document/directory' since this will trigger system file manager
-        final Intent openIntent = new Intent(Intent.ACTION_VIEW);
-        openIntent.addCategory(Intent.CATEGORY_DEFAULT);
-        if (Compatibility.runsAndTargetsTwentyFour(context)) {
-            openIntent.setType("resource/folder");
-        } else {
-            openIntent.setDataAndType(Uri.parse("file://" + path), "resource/folder");
-        }
-        openIntent.putExtra("org.openintents.extra.ABSOLUTE_PATH", path);
-
-        final Intent amazeIntent = new Intent(Intent.ACTION_VIEW);
-        amazeIntent.setDataAndType(Uri.parse("com.amaze.filemanager:" + path), "resource/folder");
-
-        // will open a file manager at root and user can navigate themselves
-        final Intent systemFallBack = new Intent(Intent.ACTION_VIEW);
-        systemFallBack.addCategory(Intent.CATEGORY_DEFAULT);
-        systemFallBack.setData(
-                Uri.parse("content://com.android.externalstorage.documents/root/primary"));
-
-        return Arrays.asList(openIntent, amazeIntent, systemFallBack);
+        notificationManager.notify(BACKUP_CREATED_NOTIFICATION_ID, builder.build());
     }
 
     private static class Progress {

src/main/java/eu/siacs/conversations/worker/ImportBackupWorker.java 🔗

@@ -0,0 +1,389 @@
+package eu.siacs.conversations.worker;
+
+import static eu.siacs.conversations.utils.Compatibility.s;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.provider.OpenableColumns;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.core.app.NotificationCompat;
+import androidx.work.Data;
+import androidx.work.ForegroundInfo;
+import androidx.work.Worker;
+import androidx.work.WorkerParameters;
+import com.google.common.base.Stopwatch;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.CountingInputStream;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.entities.Conversation;
+import eu.siacs.conversations.entities.Message;
+import eu.siacs.conversations.persistance.DatabaseBackend;
+import eu.siacs.conversations.services.QuickConversationsService;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.utils.AccountUtils;
+import eu.siacs.conversations.utils.BackupFileHeader;
+import eu.siacs.conversations.xmpp.Jid;
+import java.io.BufferedReader;
+import java.io.DataInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.security.spec.InvalidKeySpecException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.regex.Pattern;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.ZipException;
+import javax.crypto.BadPaddingException;
+import org.bouncycastle.crypto.engines.AESEngine;
+import org.bouncycastle.crypto.io.CipherInputStream;
+import org.bouncycastle.crypto.modes.AEADBlockCipher;
+import org.bouncycastle.crypto.modes.GCMBlockCipher;
+import org.bouncycastle.crypto.params.AEADParameters;
+import org.bouncycastle.crypto.params.KeyParameter;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public class ImportBackupWorker extends Worker {
+
+    public static final String TAG_IMPORT_BACKUP = "tag-import-backup";
+
+    private static final String DATA_KEY_PASSWORD = "password";
+    private static final String DATA_KEY_URI = "uri";
+    private static final String DATA_KEY_INCLUDE_OMEMO = "omemo";
+
+    private static final Collection<String> OMEMO_TABLE_LIST =
+            Arrays.asList(
+                    SQLiteAxolotlStore.PREKEY_TABLENAME,
+                    SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
+                    SQLiteAxolotlStore.SESSION_TABLENAME,
+                    SQLiteAxolotlStore.IDENTITIES_TABLENAME);
+
+    private static final List<String> TABLE_ALLOW_LIST =
+            new ImmutableList.Builder<String>()
+                    .add(Account.TABLENAME, Conversation.TABLENAME, Message.TABLENAME)
+                    .addAll(OMEMO_TABLE_LIST)
+                    .build();
+
+    private static final Pattern COLUMN_PATTERN = Pattern.compile("^[a-zA-Z_]+$");
+
+    private static final int NOTIFICATION_ID = 21;
+
+    private final String password;
+    private final Uri uri;
+    private final boolean includeOmemo;
+
+    public ImportBackupWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
+        super(context, workerParams);
+        final var inputData = workerParams.getInputData();
+        this.password = inputData.getString(DATA_KEY_PASSWORD);
+        this.uri = Uri.parse(inputData.getString(DATA_KEY_URI));
+        this.includeOmemo = inputData.getBoolean(DATA_KEY_INCLUDE_OMEMO, true);
+    }
+
+    @NonNull
+    @Override
+    public Result doWork() {
+        setForegroundAsync(
+                new ForegroundInfo(NOTIFICATION_ID, createImportBackupNotification(1, 0)));
+        final Result result;
+        try {
+            result = importBackup(this.uri, this.password);
+        } catch (final FileNotFoundException e) {
+            return failure(Reason.FILE_NOT_FOUND);
+        } catch (final Exception e) {
+            Log.d(Config.LOGTAG, "error restoring backup " + uri, e);
+            final Throwable throwable = e.getCause();
+            if (throwable instanceof BadPaddingException || e instanceof ZipException) {
+                return failure(Reason.DECRYPTION_FAILED);
+            } else {
+                return failure(Reason.GENERIC);
+            }
+        } finally {
+            getApplicationContext()
+                    .getSystemService(NotificationManager.class)
+                    .cancel(NOTIFICATION_ID);
+        }
+
+        return result;
+    }
+
+    private Result importBackup(final Uri uri, final String password)
+            throws IOException, InvalidKeySpecException {
+        final var context = getApplicationContext();
+        final var database = DatabaseBackend.getInstance(context);
+        Log.d(Config.LOGTAG, "importing backup from " + uri);
+        final Stopwatch stopwatch = Stopwatch.createStarted();
+        final SQLiteDatabase db = database.getWritableDatabase();
+        final InputStream inputStream;
+        final String path = uri.getPath();
+        final long fileSize;
+        if ("file".equals(uri.getScheme()) && path != null) {
+            final File file = new File(path);
+            inputStream = new FileInputStream(file);
+            fileSize = file.length();
+        } else {
+            final Cursor returnCursor =
+                    context.getContentResolver().query(uri, null, null, null, null);
+            if (returnCursor == null) {
+                fileSize = 0;
+            } else {
+                returnCursor.moveToFirst();
+                fileSize =
+                        returnCursor.getLong(
+                                returnCursor.getColumnIndexOrThrow(OpenableColumns.SIZE));
+                returnCursor.close();
+            }
+            inputStream = context.getContentResolver().openInputStream(uri);
+        }
+        if (inputStream == null) {
+            return failure(Reason.FILE_NOT_FOUND);
+        }
+        final CountingInputStream countingInputStream = new CountingInputStream(inputStream);
+        final DataInputStream dataInputStream = new DataInputStream(countingInputStream);
+        final BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
+        Log.d(Config.LOGTAG, backupFileHeader.toString());
+
+        final var accounts = database.getAccountJids(false);
+
+        if (QuickConversationsService.isQuicksy() && !accounts.isEmpty()) {
+            return failure(Reason.ACCOUNT_ALREADY_EXISTS);
+        }
+
+        if (accounts.contains(backupFileHeader.getJid())) {
+            return failure(Reason.ACCOUNT_ALREADY_EXISTS);
+        }
+
+        final byte[] key = ExportBackupWorker.getKey(password, backupFileHeader.getSalt());
+
+        final AEADBlockCipher cipher = GCMBlockCipher.newInstance(AESEngine.newInstance());
+        cipher.init(
+                false, new AEADParameters(new KeyParameter(key), 128, backupFileHeader.getIv()));
+        final CipherInputStream cipherInputStream =
+                new CipherInputStream(countingInputStream, cipher);
+
+        final GZIPInputStream gzipInputStream = new GZIPInputStream(cipherInputStream);
+        final BufferedReader reader =
+                new BufferedReader(new InputStreamReader(gzipInputStream, StandardCharsets.UTF_8));
+        final JsonReader jsonReader = new JsonReader(reader);
+        if (jsonReader.peek() == JsonToken.BEGIN_ARRAY) {
+            jsonReader.beginArray();
+        } else {
+            throw new IllegalStateException("Backup file did not begin with array");
+        }
+        db.beginTransaction();
+        while (jsonReader.hasNext()) {
+            if (jsonReader.peek() == JsonToken.BEGIN_OBJECT) {
+                importRow(db, jsonReader, backupFileHeader.getJid(), password);
+            } else if (jsonReader.peek() == JsonToken.END_ARRAY) {
+                jsonReader.endArray();
+                continue;
+            }
+            updateImportBackupNotification(fileSize, countingInputStream.getCount());
+        }
+        db.setTransactionSuccessful();
+        db.endTransaction();
+        final Jid jid = backupFileHeader.getJid();
+        final Cursor countCursor =
+                db.rawQuery(
+                        "select count(messages.uuid) from messages join conversations on"
+                                + " conversations.uuid=messages.conversationUuid join accounts on"
+                                + " conversations.accountUuid=accounts.uuid where"
+                                + " accounts.username=? and accounts.server=?",
+                        new String[] {jid.getLocal(), jid.getDomain().toString()});
+        countCursor.moveToFirst();
+        final int count = countCursor.getInt(0);
+        Log.d(Config.LOGTAG, String.format("restored %d messages in %s", count, stopwatch.stop()));
+        countCursor.close();
+        stopBackgroundService();
+        notifySuccess();
+        return Result.success();
+    }
+
+    private void importRow(
+            final SQLiteDatabase db,
+            final JsonReader jsonReader,
+            final Jid account,
+            final String passphrase)
+            throws IOException {
+        jsonReader.beginObject();
+        final String firstParameter = jsonReader.nextName();
+        if (!firstParameter.equals("table")) {
+            throw new IllegalStateException("Expected key 'table'");
+        }
+        final String table = jsonReader.nextString();
+        if (!TABLE_ALLOW_LIST.contains(table)) {
+            throw new IOException(String.format("%s is not recognized for import", table));
+        }
+        final ContentValues contentValues = new ContentValues();
+        final String secondParameter = jsonReader.nextName();
+        if (!secondParameter.equals("values")) {
+            throw new IllegalStateException("Expected key 'values'");
+        }
+        jsonReader.beginObject();
+        while (jsonReader.peek() != JsonToken.END_OBJECT) {
+            final String name = jsonReader.nextName();
+            if (COLUMN_PATTERN.matcher(name).matches()) {
+                if (jsonReader.peek() == JsonToken.NULL) {
+                    jsonReader.nextNull();
+                    contentValues.putNull(name);
+                } else if (jsonReader.peek() == JsonToken.NUMBER) {
+                    contentValues.put(name, jsonReader.nextLong());
+                } else {
+                    contentValues.put(name, jsonReader.nextString());
+                }
+            } else {
+                throw new IOException(String.format("Unexpected column name %s", name));
+            }
+        }
+        jsonReader.endObject();
+        jsonReader.endObject();
+        if (Account.TABLENAME.equals(table)) {
+            final Jid jid =
+                    Jid.of(
+                            contentValues.getAsString(Account.USERNAME),
+                            contentValues.getAsString(Account.SERVER),
+                            null);
+            final String password = contentValues.getAsString(Account.PASSWORD);
+            if (QuickConversationsService.isQuicksy()) {
+                if (!jid.getDomain().equals(Config.QUICKSY_DOMAIN)) {
+                    throw new IOException("Trying to restore non Quicksy account on Quicksy");
+                }
+            }
+            if (jid.equals(account) && passphrase.equals(password)) {
+                Log.d(Config.LOGTAG, "jid and password from backup header had matching row");
+            } else {
+                throw new IOException("jid or password in table did not match backup");
+            }
+            final var keys = Account.parseKeys(contentValues.getAsString(Account.KEYS));
+            final var deviceId = keys.optString(SQLiteAxolotlStore.JSONKEY_REGISTRATION_ID);
+            final var importReadyKeys = new JSONObject();
+            if (!Strings.isNullOrEmpty(deviceId) && this.includeOmemo) {
+                try {
+                    importReadyKeys.put(SQLiteAxolotlStore.JSONKEY_REGISTRATION_ID, deviceId);
+                } catch (final JSONException e) {
+                    Log.e(Config.LOGTAG, "error writing omemo registration id", e);
+                }
+            }
+            contentValues.put(Account.KEYS, importReadyKeys.toString());
+        }
+        if (this.includeOmemo) {
+            db.insert(table, null, contentValues);
+        } else {
+            if (OMEMO_TABLE_LIST.contains(table)) {
+                if (SQLiteAxolotlStore.IDENTITIES_TABLENAME.equals(table)
+                        && contentValues.getAsInteger(SQLiteAxolotlStore.OWN) == 0) {
+                    db.insert(table, null, contentValues);
+                } else {
+                    Log.d(Config.LOGTAG, "skipping over omemo key material in table " + table);
+                }
+            } else {
+                db.insert(table, null, contentValues);
+            }
+        }
+    }
+
+    private void stopBackgroundService() {
+        final var intent = new Intent(getApplicationContext(), XmppConnectionService.class);
+        getApplicationContext().stopService(intent);
+    }
+
+    private void updateImportBackupNotification(final long total, final long current) {
+        final int max;
+        final int progress;
+        if (total == 0) {
+            max = 1;
+            progress = 0;
+        } else {
+            max = 100;
+            progress = (int) (current * 100 / total);
+        }
+        getApplicationContext()
+                .getSystemService(NotificationManager.class)
+                .notify(NOTIFICATION_ID, createImportBackupNotification(max, progress));
+    }
+
+    private Notification createImportBackupNotification(final int max, final int progress) {
+        final var context = getApplicationContext();
+        final var builder = new NotificationCompat.Builder(getApplicationContext(), "backup");
+        builder.setContentTitle(context.getString(R.string.restoring_backup))
+                .setSmallIcon(R.drawable.ic_unarchive_24dp)
+                .setProgress(max, progress, max == 1 && progress == 0);
+        return builder.build();
+    }
+
+    private void notifySuccess() {
+        final var context = getApplicationContext();
+        final var builder = new NotificationCompat.Builder(context, "backup");
+        builder.setContentTitle(context.getString(R.string.notification_restored_backup_title))
+                .setContentText(context.getString(R.string.notification_restored_backup_subtitle))
+                .setAutoCancel(true)
+                .setSmallIcon(R.drawable.ic_unarchive_24dp);
+        if (QuickConversationsService.isConversations()
+                && AccountUtils.MANAGE_ACCOUNT_ACTIVITY != null) {
+            builder.setContentText(
+                    context.getString(R.string.notification_restored_backup_subtitle));
+            builder.setContentIntent(
+                    PendingIntent.getActivity(
+                            context,
+                            145,
+                            new Intent(context, AccountUtils.MANAGE_ACCOUNT_ACTIVITY),
+                            s()
+                                    ? PendingIntent.FLAG_IMMUTABLE
+                                            | PendingIntent.FLAG_UPDATE_CURRENT
+                                    : PendingIntent.FLAG_UPDATE_CURRENT));
+        }
+        getApplicationContext()
+                .getSystemService(NotificationManager.class)
+                .notify(NOTIFICATION_ID + 2, builder.build());
+    }
+
+    public static Data data(final String password, final Uri uri, final boolean includeOmemo) {
+        return new Data.Builder()
+                .putString(DATA_KEY_PASSWORD, password)
+                .putString(DATA_KEY_URI, uri.toString())
+                .putBoolean(DATA_KEY_INCLUDE_OMEMO, includeOmemo)
+                .build();
+    }
+
+    private static Result failure(final Reason reason) {
+        return Result.failure(new Data.Builder().putString("reason", reason.toString()).build());
+    }
+
+    public enum Reason {
+        ACCOUNT_ALREADY_EXISTS,
+        DECRYPTION_FAILED,
+        FILE_NOT_FOUND,
+        GENERIC;
+
+        public static Reason valueOfOrGeneric(final String value) {
+            if (Strings.isNullOrEmpty(value)) {
+                return GENERIC;
+            }
+            try {
+                return valueOf(value);
+            } catch (final IllegalArgumentException e) {
+                return GENERIC;
+            }
+        }
+    }
+}

src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java 🔗

@@ -70,6 +70,7 @@ import javax.net.ssl.SSLSocketFactory;
 import javax.net.ssl.X509KeyManager;
 import javax.net.ssl.X509TrustManager;
 
+import de.gultsch.common.Patterns;
 import eu.siacs.conversations.AppSettings;
 import eu.siacs.conversations.BuildConfig;
 import eu.siacs.conversations.Config;
@@ -98,7 +99,6 @@ import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.ui.util.PendingItem;
 import eu.siacs.conversations.utils.AccountUtils;
 import eu.siacs.conversations.utils.CryptoHelper;
-import eu.siacs.conversations.utils.Patterns;
 import eu.siacs.conversations.utils.PhoneHelper;
 import eu.siacs.conversations.utils.Resolver;
 import eu.siacs.conversations.utils.SSLSockets;
@@ -146,7 +146,6 @@ import im.conversations.android.xmpp.model.sm.StreamManagement;
 import im.conversations.android.xmpp.model.stanza.Iq;
 import im.conversations.android.xmpp.model.stanza.Presence;
 import im.conversations.android.xmpp.model.stanza.Stanza;
-import im.conversations.android.xmpp.model.streams.Features;
 import im.conversations.android.xmpp.model.streams.StreamError;
 import im.conversations.android.xmpp.model.tls.Proceed;
 import im.conversations.android.xmpp.model.tls.StartTls;
@@ -1056,7 +1055,7 @@ public class XmppConnection implements Runnable {
             if (Strings.isNullOrEmpty(text)) {
                 throw new StateChangingException(Account.State.UNAUTHORIZED);
             }
-            final Matcher matcher = Patterns.AUTOLINK_WEB_URL.matcher(text);
+            final Matcher matcher = Patterns.URI_HTTP.matcher(text);
             if (matcher.find()) {
                 final HttpUrl url;
                 try {
@@ -1961,7 +1960,7 @@ public class XmppConnection implements Runnable {
                         if (url != null) {
                             setAccountCreationFailed(url);
                         } else if (instructions != null) {
-                            final Matcher matcher = Patterns.AUTOLINK_WEB_URL.matcher(instructions);
+                            final Matcher matcher = Patterns.URI_HTTP.matcher(instructions);
                             if (matcher.find()) {
                                 setAccountCreationFailed(
                                         instructions.substring(matcher.start(), matcher.end()));

src/main/java/eu/siacs/conversations/xmpp/jingle/IceServers.java 🔗

@@ -1,98 +1,21 @@
 package eu.siacs.conversations.xmpp.jingle;
 
-import android.util.Log;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.primitives.Ints;
-
-import eu.siacs.conversations.Config;
-import eu.siacs.conversations.utils.IP;
-import eu.siacs.conversations.xml.Element;
-import eu.siacs.conversations.xml.Namespace;
+import im.conversations.android.xmpp.model.disco.external.Services;
 import im.conversations.android.xmpp.model.stanza.Iq;
-
-import org.webrtc.PeerConnection;
-
-import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
-import java.util.List;
+import org.webrtc.PeerConnection;
 
 public final class IceServers {
 
-    public static List<PeerConnection.IceServer> parse(final Iq response) {
-        ImmutableList.Builder<PeerConnection.IceServer> listBuilder = new ImmutableList.Builder<>();
-        if (response.getType() == Iq.Type.RESULT) {
-            final Element services =
-                    response.findChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
-            final List<Element> children =
-                    services == null ? Collections.emptyList() : services.getChildren();
-            for (final Element child : children) {
-                if ("service".equals(child.getName())) {
-                    final String type = child.getAttribute("type");
-                    final String host = child.getAttribute("host");
-                    final String sport = child.getAttribute("port");
-                    final Integer port = sport == null ? null : Ints.tryParse(sport);
-                    final String transport = child.getAttribute("transport");
-                    final String username = child.getAttribute("username");
-                    final String password = child.getAttribute("password");
-                    if (Strings.isNullOrEmpty(host) || port == null) {
-                        continue;
-                    }
-                    if (port < 0 || port > 65535) {
-                        continue;
-                    }
-
-                    if (Arrays.asList("stun", "stuns", "turn", "turns").contains(type)
-                            && Arrays.asList("udp", "tcp").contains(transport)) {
-                        if (Arrays.asList("stuns", "turns").contains(type)
-                                && "udp".equals(transport)) {
-                            Log.w(
-                                    Config.LOGTAG,
-                                    "skipping invalid combination of udp/tls in external services");
-                            continue;
-                        }
-
-                        // STUN URLs do not support a query section since M110
-                        final String uri;
-                        if (Arrays.asList("stun", "stuns").contains(type)) {
-                            uri = String.format("%s:%s:%s", type, IP.wrapIPv6(host), port);
-                        } else {
-                            uri =
-                                    String.format(
-                                            "%s:%s:%s?transport=%s",
-                                            type, IP.wrapIPv6(host), port, transport);
-                        }
-
-                        final PeerConnection.IceServer.Builder iceServerBuilder =
-                                PeerConnection.IceServer.builder(uri);
-                        iceServerBuilder.setTlsCertPolicy(
-                                PeerConnection.TlsCertPolicy.TLS_CERT_POLICY_INSECURE_NO_CHECK);
-                        if (username != null && password != null) {
-                            iceServerBuilder.setUsername(username);
-                            iceServerBuilder.setPassword(password);
-                        } else if (Arrays.asList("turn", "turns").contains(type)) {
-                            // The WebRTC spec requires throwing an
-                            // InvalidAccessError when username (from libwebrtc
-                            // source coder)
-                            // https://chromium.googlesource.com/external/webrtc/+/master/pc/ice_server_parsing.cc
-                            Log.w(
-                                    Config.LOGTAG,
-                                    "skipping "
-                                            + type
-                                            + "/"
-                                            + transport
-                                            + " without username and password");
-                            continue;
-                        }
-                        final PeerConnection.IceServer iceServer =
-                                iceServerBuilder.createIceServer();
-                        Log.w(Config.LOGTAG, "discovered ICE Server: " + iceServer);
-                        listBuilder.add(iceServer);
-                    }
-                }
-            }
+    public static Collection<PeerConnection.IceServer> parse(final Iq response) {
+        if (response.getType() != Iq.Type.RESULT) {
+            return Collections.emptySet();
+        }
+        final var services = response.getExtension(Services.class);
+        if (services == null) {
+            return Collections.emptySet();
         }
-        return listBuilder.build();
+        return services.getIceServers();
     }
 }

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java 🔗

@@ -355,6 +355,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection
             Log.d(
                     Config.LOGTAG,
                     "got file offer " + file + " jet=" + Objects.nonNull(keyTransportMessage));
+            // TODO store hashes if there are any
             setFileOffer(file);
             if (keyTransportMessage != null) {
                 this.transportSecurity =
@@ -548,10 +549,13 @@ public class JingleFileTransferConnection extends AbstractJingleConnection
 
     private void receiveSessionInfoChecksum(final FileTransferDescription.Checksum checksum) {
         Log.d(Config.LOGTAG, "received checksum " + checksum);
+        // TODO check that we are receiver
+        // TODO store hashes
     }
 
     private void receiveSessionInfoReceived(final FileTransferDescription.Received received) {
         Log.d(Config.LOGTAG, "peer confirmed received " + received);
+        // TODO check that we are sender
     }
 
     private synchronized void receiveSessionTerminate(final Iq jinglePacket, final Jingle jingle) {
@@ -902,6 +906,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection
             sendSessionInfoChecksum(hashes);
         } else {
             Log.d(Config.LOGTAG, "file transfer complete " + hashes);
+            // TODO compare with stored file hashes
             sendFileSessionInfoReceived();
             terminateTransport();
             messageReceivedSuccess();

src/main/java/eu/siacs/conversations/xmpp/jingle/JingleRtpConnection.java 🔗

@@ -47,6 +47,7 @@ import eu.siacs.conversations.xmpp.jingle.stanzas.Proceed;
 import eu.siacs.conversations.xmpp.jingle.stanzas.Propose;
 import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
 import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
+import im.conversations.android.xmpp.model.disco.external.Services;
 import im.conversations.android.xmpp.model.jingle.Jingle;
 import im.conversations.android.xmpp.model.stanza.Iq;
 
@@ -1356,7 +1357,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
     private synchronized void sendSessionAccept(
             final Set<Media> media,
             final SessionDescription offer,
-            final List<PeerConnection.IceServer> iceServers) {
+            final Collection<PeerConnection.IceServer> iceServers) {
         if (isTerminated()) {
             Log.w(
                     Config.LOGTAG,
@@ -1840,7 +1841,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
     private synchronized void sendSessionInitiate(
             final Set<Media> media,
             final State targetState,
-            final List<PeerConnection.IceServer> iceServers) {
+            final Collection<PeerConnection.IceServer> iceServers) {
         if (isTerminated()) {
             Log.w(
                     Config.LOGTAG,
@@ -2337,7 +2338,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
 
     private void setupWebRTC(
             final Set<Media> media,
-            final List<PeerConnection.IceServer> iceServers,
+            final Collection<PeerConnection.IceServer> iceServers,
             final boolean trickle)
             throws WebRTCWrapper.InitializationException {
         this.jingleConnectionManager.ensureConnectionIsRegistered(this);
@@ -2841,7 +2842,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
         if (id.account.getXmppConnection().getFeatures().externalServiceDiscovery()) {
             final Iq request = new Iq(Iq.Type.GET);
             request.setTo(id.account.getDomain());
-            request.addChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
+            request.addExtension(new Services());
             xmppConnectionService.sendIqPacket(
                     id.account,
                     request,
@@ -2860,7 +2861,7 @@ public class JingleRtpConnection extends AbstractJingleConnection
             Log.w(
                     Config.LOGTAG,
                     id.account.getJid().asBareJid() + ": has no external service discovery");
-            onIceServersDiscovered.onIceServersDiscovered(Collections.emptyList());
+            onIceServersDiscovered.onIceServersDiscovered(Collections.emptySet());
         }
     }
 
@@ -2978,6 +2979,6 @@ public class JingleRtpConnection extends AbstractJingleConnection
     }
 
     private interface OnIceServersDiscovered {
-        void onIceServersDiscovered(List<PeerConnection.IceServer> iceServers);
+        void onIceServersDiscovered(Collection<PeerConnection.IceServer> iceServers);
     }
 }

src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java 🔗

@@ -12,7 +12,6 @@ import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
-
 import eu.siacs.conversations.xmpp.jingle.stanzas.Content;
 import eu.siacs.conversations.xmpp.jingle.stanzas.GenericDescription;
 import eu.siacs.conversations.xmpp.jingle.stanzas.GenericTransportInfo;
@@ -21,14 +20,12 @@ import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
 import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportInfo;
 import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
 import im.conversations.android.xmpp.model.jingle.Jingle;
-
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-
 import javax.annotation.Nonnull;
 
 public class RtpContentMap extends AbstractContentMap<RtpDescription, IceUdpTransportInfo> {
@@ -100,7 +97,7 @@ public class RtpContentMap extends AbstractContentMap<RtpDescription, IceUdpTran
     }
 
     void requireDTLSFingerprint(final boolean requireActPass) {
-        if (this.contents.size() == 0) {
+        if (this.contents.isEmpty()) {
             throw new IllegalStateException("No contents available");
         }
         for (Map.Entry<String, DescriptionTransport<RtpDescription, IceUdpTransportInfo>> entry :
@@ -119,7 +116,8 @@ public class RtpContentMap extends AbstractContentMap<RtpDescription, IceUdpTran
             if (setup == null) {
                 throw new SecurityException(
                         String.format(
-                                "Use of DTLS-SRTP (XEP-0320) is required for content %s but missing setup attribute",
+                                "Use of DTLS-SRTP (XEP-0320) is required for content %s but missing"
+                                        + " setup attribute",
                                 entry.getKey()));
             }
             if (requireActPass && setup != IceUdpTransportInfo.Setup.ACTPASS) {
@@ -128,6 +126,7 @@ public class RtpContentMap extends AbstractContentMap<RtpDescription, IceUdpTran
             }
         }
     }
+
     RtpContentMap transportInfo(
             final String contentName, final IceUdpTransportInfo.Candidate candidate) {
         final DescriptionTransport<RtpDescription, IceUdpTransportInfo> descriptionTransport =

src/main/java/eu/siacs/conversations/xmpp/jingle/SessionDescription.java 🔗

@@ -2,9 +2,7 @@ package eu.siacs.conversations.xmpp.jingle;
 
 import android.util.Log;
 import android.util.Pair;
-
 import androidx.annotation.NonNull;
-
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
@@ -12,7 +10,6 @@ import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.Multimap;
-
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.jingle.stanzas.FileTransferDescription;
@@ -21,7 +18,6 @@ import eu.siacs.conversations.xmpp.jingle.stanzas.Group;
 import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
 import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
 import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo;
-
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
@@ -206,6 +202,10 @@ public class SessionDescription {
                 entry : contentMap.contents.entrySet()) {
             final String name = entry.getKey();
             checkNoWhitespace(name, "content name must not contain any whitespace");
+            // https://groups.google.com/g/discuss-webrtc/c/VG406JMTBI4/m/MrSex_q7AgAJ
+            if (name.length() > 16) {
+                throw new IllegalArgumentException("mid should not be longer than 16 chars");
+            }
             final DescriptionTransport<RtpDescription, IceUdpTransportInfo> descriptionTransport =
                     entry.getValue();
             final RtpDescription description = descriptionTransport.description;
@@ -226,7 +226,7 @@ public class SessionDescription {
                 if (parameters.size() == 1) {
                     mediaAttributes.put(
                             "fmtp", RtpDescription.Parameter.toSdpString(id, parameters.get(0)));
-                } else if (parameters.size() > 0) {
+                } else if (!parameters.isEmpty()) {
                     mediaAttributes.put(
                             "fmtp", RtpDescription.Parameter.toSdpString(id, parameters));
                 }
@@ -306,7 +306,7 @@ public class SessionDescription {
                             "A SSRC group is missing semantics attribute");
                 }
                 checkNoWhitespace(semantics, "source group semantics must not contain whitespace");
-                if (groups.size() == 0) {
+                if (groups.isEmpty()) {
                     throw new IllegalArgumentException("A SSRC group is missing SSRC ids");
                 }
                 for (final String source : groups) {

src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java 🔗

@@ -5,19 +5,28 @@ import android.media.AudioManager;
 import android.media.ToneGenerator;
 import android.os.Build;
 import android.util.Log;
-
 import com.google.common.base.Optional;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.common.util.concurrent.SettableFuture;
-
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.services.XmppConnectionService;
-
+import java.util.Collection;
+import java.util.LinkedList;
+import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
 import org.webrtc.AudioSource;
 import org.webrtc.AudioTrack;
 import org.webrtc.CandidatePairChangeEvent;
@@ -261,13 +270,13 @@ public class WebRTCWrapper {
 
     synchronized void initializePeerConnection(
             final Set<Media> media,
-            final List<PeerConnection.IceServer> iceServers,
+            final Collection<PeerConnection.IceServer> iceServers,
             final boolean trickle)
             throws InitializationException {
         Preconditions.checkState(this.eglBase != null);
         Preconditions.checkNotNull(media);
         Preconditions.checkArgument(
-                media.size() > 0, "media can not be empty when initializing peer connection");
+                !media.isEmpty(), "media can not be empty when initializing peer connection");
         final boolean setUseHardwareAcousticEchoCanceler =
                 !HARDWARE_AEC_BLACKLIST.contains(Build.MODEL);
         Log.d(
@@ -397,16 +406,16 @@ public class WebRTCWrapper {
         if (videoSourceWrapper != null) {
             try {
                 videoSourceWrapper.stopCapture();
-            } catch (InterruptedException e) {
-                e.printStackTrace();
+            } catch (final InterruptedException e) {
+                Log.e(Config.LOGTAG, "could not stop capturing video source", e);
             }
         }
     }
 
     public static PeerConnection.RTCConfiguration buildConfiguration(
-            final List<PeerConnection.IceServer> iceServers, final boolean trickle) {
+            final Collection<PeerConnection.IceServer> iceServers, final boolean trickle) {
         final PeerConnection.RTCConfiguration rtcConfig =
-                new PeerConnection.RTCConfiguration(iceServers);
+                new PeerConnection.RTCConfiguration(ImmutableList.copyOf(iceServers));
         rtcConfig.tcpCandidatePolicy =
                 PeerConnection.TcpCandidatePolicy.DISABLED; // XEP-0176 doesn't support tcp
         if (trickle) {
@@ -423,7 +432,7 @@ public class WebRTCWrapper {
     }
 
     void reconfigurePeerConnection(
-            final List<PeerConnection.IceServer> iceServers, final boolean trickle) {
+            final Set<PeerConnection.IceServer> iceServers, final boolean trickle) {
         requirePeerConnection().setConfiguration(buildConfiguration(iceServers, trickle));
     }
 
@@ -809,7 +818,7 @@ public class WebRTCWrapper {
         }
     }
 
-    static class InitializationException extends Exception {
+    public static class InitializationException extends Exception {
 
         private InitializationException(final String message, final Throwable throwable) {
             super(message, throwable);

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/IceUdpTransportInfo.java 🔗

@@ -1,28 +1,23 @@
 package eu.siacs.conversations.xmpp.jingle.stanzas;
 
 import android.util.Log;
-
 import androidx.annotation.NonNull;
-
 import com.google.common.base.Joiner;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Objects;
 import com.google.common.base.Preconditions;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
-import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Multimap;
-
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.jingle.SessionDescription;
 import eu.siacs.conversations.xmpp.jingle.transports.Transport;
-
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
@@ -104,7 +99,9 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
         for (final Element child : getChildren()) {
             if (Namespace.JINGLE_TRANSPORT_ICE_OPTION.equals(child.getNamespace())
                     && IceOption.WELL_KNOWN.contains(child.getName())) {
-                optionBuilder.add(child.getName());
+                optionBuilder.add(
+                        SessionDescription.checkNoWhitespace(
+                                child.getName(), "Ice options should not contain whitespace"));
             }
         }
         return optionBuilder.build();
@@ -162,7 +159,7 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
         final IceUdpTransportInfo transportInfo = new IceUdpTransportInfo();
         transportInfo.setAttributes(new Hashtable<>(getAttributes()));
         transportInfo.setChildren(this.getChildren());
-        for(final Candidate candidate : candidates) {
+        for (final Candidate candidate : candidates) {
             transportInfo.addChild(candidate);
         }
         return transportInfo;
@@ -223,7 +220,8 @@ public class IceUdpTransportInfo extends GenericTransportInfo {
             return null;
         }
 
-        public static Candidate fromSdpAttributeValue(final String value, final String currentUfrag) {
+        public static Candidate fromSdpAttributeValue(
+                final String value, final String currentUfrag) {
             final String[] segments = value.split(" ");
             if (segments.length < 6) {
                 return null;

src/main/java/eu/siacs/conversations/xmpp/jingle/stanzas/RtpDescription.java 🔗

@@ -1,25 +1,22 @@
 package eu.siacs.conversations.xmpp.jingle.stanzas;
 
 import android.util.Pair;
-
 import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
-
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.jingle.Media;
+import eu.siacs.conversations.xmpp.jingle.SessionDescription;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
-import eu.siacs.conversations.xml.Element;
-import eu.siacs.conversations.xml.Namespace;
-import eu.siacs.conversations.xmpp.jingle.Media;
-import eu.siacs.conversations.xmpp.jingle.SessionDescription;
-
 public class RtpDescription extends GenericDescription {
 
     private RtpDescription(final String media) {
@@ -287,7 +284,7 @@ public class RtpDescription extends GenericDescription {
             final String channels = this.getAttribute("channels");
             if (channels == null) {
                 return 1; // The number of channels; if omitted, it MUST be assumed to contain one
-                          // channel
+                // channel
             }
             try {
                 return Integer.parseInt(channels);
@@ -532,13 +529,17 @@ public class RtpDescription extends GenericDescription {
         }
 
         public List<String> getSsrcs() {
-            ImmutableList.Builder<String> builder = new ImmutableList.Builder<>();
+            final ImmutableList.Builder<String> builder = new ImmutableList.Builder<>();
             for (Element child : getChildren()) {
                 if ("source".equals(child.getName())) {
                     final String ssrc = child.getAttribute("ssrc");
-                    if (ssrc != null) {
-                        builder.add(ssrc);
+                    if (Strings.isNullOrEmpty(ssrc)) {
+                        continue;
                     }
+                    builder.add(
+                            SessionDescription.checkNoNewline(
+                                    ssrc,
+                                    "Source Specific media attributes can not contain newline"));
                 }
             }
             return builder.build();

src/main/java/eu/siacs/conversations/xmpp/jingle/transports/WebRTCDataChannelTransport.java 🔗

@@ -5,7 +5,6 @@ import static eu.siacs.conversations.xmpp.jingle.WebRTCWrapper.logDescription;
 
 import android.content.Context;
 import android.util.Log;
-
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
 import com.google.common.io.Closeables;
@@ -13,26 +12,15 @@ import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.common.util.concurrent.SettableFuture;
-
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.XmppConnection;
 import eu.siacs.conversations.xmpp.jingle.IceServers;
 import eu.siacs.conversations.xmpp.jingle.WebRTCWrapper;
 import eu.siacs.conversations.xmpp.jingle.stanzas.IceUdpTransportInfo;
 import eu.siacs.conversations.xmpp.jingle.stanzas.WebRTCDataChannelTransportInfo;
-
+import im.conversations.android.xmpp.model.disco.external.Services;
 import im.conversations.android.xmpp.model.stanza.Iq;
-
-import org.webrtc.CandidatePairChangeEvent;
-import org.webrtc.DataChannel;
-import org.webrtc.IceCandidate;
-import org.webrtc.MediaStream;
-import org.webrtc.PeerConnection;
-import org.webrtc.PeerConnectionFactory;
-import org.webrtc.SessionDescription;
-
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InterruptedIOException;
@@ -42,6 +30,7 @@ import java.io.PipedOutputStream;
 import java.nio.ByteBuffer;
 import java.nio.channels.Channels;
 import java.nio.channels.WritableByteChannel;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.LinkedList;
 import java.util.List;
@@ -52,8 +41,14 @@ import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.atomic.AtomicBoolean;
-
 import javax.annotation.Nonnull;
+import org.webrtc.CandidatePairChangeEvent;
+import org.webrtc.DataChannel;
+import org.webrtc.IceCandidate;
+import org.webrtc.MediaStream;
+import org.webrtc.PeerConnection;
+import org.webrtc.PeerConnectionFactory;
+import org.webrtc.SessionDescription;
 
 public class WebRTCDataChannelTransport implements Transport {
 
@@ -229,16 +224,16 @@ public class WebRTCDataChannelTransport implements Transport {
         }
     }
 
-    private ListenableFuture<List<PeerConnection.IceServer>> getIceServers() {
+    private ListenableFuture<Collection<PeerConnection.IceServer>> getIceServers() {
         if (Config.DISABLE_PROXY_LOOKUP) {
-            return Futures.immediateFuture(Collections.emptyList());
+            return Futures.immediateFuture(Collections.emptySet());
         }
         if (xmppConnection.getFeatures().externalServiceDiscovery()) {
-            final SettableFuture<List<PeerConnection.IceServer>> iceServerFuture =
+            final SettableFuture<Collection<PeerConnection.IceServer>> iceServerFuture =
                     SettableFuture.create();
             final Iq request = new Iq(Iq.Type.GET);
             request.setTo(this.account.getDomain());
-            request.addChild("services", Namespace.EXTERNAL_SERVICE_DISCOVERY);
+            request.addExtension(new Services());
             xmppConnection.sendIqPacket(
                     request,
                     (response) -> {
@@ -254,12 +249,12 @@ public class WebRTCDataChannelTransport implements Transport {
                     });
             return iceServerFuture;
         } else {
-            return Futures.immediateFuture(Collections.emptyList());
+            return Futures.immediateFuture(Collections.emptySet());
         }
     }
 
     private PeerConnection createPeerConnection(
-            final List<PeerConnection.IceServer> iceServers, final boolean trickle) {
+            final Collection<PeerConnection.IceServer> iceServers, final boolean trickle) {
         final PeerConnection.RTCConfiguration rtcConfig = buildConfiguration(iceServers, trickle);
         final PeerConnection peerConnection =
                 requirePeerConnectionFactory()

src/main/java/im/conversations/android/xmpp/model/disco/external/Services.java 🔗

@@ -1,7 +1,19 @@
 package im.conversations.android.xmpp.model.disco.external;
 
+import android.util.Log;
+import androidx.annotation.NonNull;
+import com.google.common.base.Objects;
+import com.google.common.base.Strings;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.primitives.Ints;
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.utils.IP;
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
+import java.util.Arrays;
+import java.util.Collection;
+import org.webrtc.PeerConnection;
 
 @XmlElement
 public class Services extends Extension {
@@ -9,4 +21,104 @@ public class Services extends Extension {
     public Services() {
         super(Services.class);
     }
+
+    public Collection<Service> getServices() {
+        return this.getExtensions(Service.class);
+    }
+
+    public Collection<PeerConnection.IceServer> getIceServers() {
+        final var builder = new ImmutableSet.Builder<IceServerWrapper>();
+        for (final var service : this.getServices()) {
+            final String type = service.getAttribute("type");
+            final String host = service.getAttribute("host");
+            final String sport = service.getAttribute("port");
+            final Integer port = sport == null ? null : Ints.tryParse(sport);
+            final String transport = service.getAttribute("transport");
+            final String username = service.getAttribute("username");
+            final String password = service.getAttribute("password");
+            if (Strings.isNullOrEmpty(host) || port == null) {
+                continue;
+            }
+            if (port < 0 || port > 65535) {
+                continue;
+            }
+
+            if (Arrays.asList("stun", "stuns", "turn", "turns").contains(type)
+                    && Arrays.asList("udp", "tcp").contains(transport)) {
+                if (Arrays.asList("stuns", "turns").contains(type) && "udp".equals(transport)) {
+                    Log.w(
+                            Config.LOGTAG,
+                            "skipping invalid combination of udp/tls in external services");
+                    continue;
+                }
+
+                // STUN URLs do not support a query section since M110
+                final String uri;
+                if (Arrays.asList("stun", "stuns").contains(type)) {
+                    uri = String.format("%s:%s:%s", type, IP.wrapIPv6(host), port);
+                } else {
+                    uri =
+                            String.format(
+                                    "%s:%s:%s?transport=%s",
+                                    type, IP.wrapIPv6(host), port, transport);
+                }
+
+                final PeerConnection.IceServer.Builder iceServerBuilder =
+                        PeerConnection.IceServer.builder(uri);
+                iceServerBuilder.setTlsCertPolicy(
+                        PeerConnection.TlsCertPolicy.TLS_CERT_POLICY_INSECURE_NO_CHECK);
+                if (username != null && password != null) {
+                    iceServerBuilder.setUsername(username);
+                    iceServerBuilder.setPassword(password);
+                } else if (Arrays.asList("turn", "turns").contains(type)) {
+                    // The WebRTC spec requires throwing an
+                    // InvalidAccessError on empty username or password
+                    // https://chromium.googlesource.com/external/webrtc/+/master/pc/ice_server_parsing.cc
+                    Log.w(
+                            Config.LOGTAG,
+                            "skipping "
+                                    + type
+                                    + "/"
+                                    + transport
+                                    + " without username and password");
+                    continue;
+                }
+                final var iceServer = new IceServerWrapper(iceServerBuilder.createIceServer());
+                Log.w(Config.LOGTAG, "discovered ICE Server: " + iceServer);
+                builder.add(iceServer);
+            }
+        }
+        final var set = builder.build();
+        Log.d(Config.LOGTAG, "discovered " + set.size() + " ice servers");
+        return Collections2.transform(set, i -> i.iceServer);
+    }
+
+    private static class IceServerWrapper {
+
+        private final PeerConnection.IceServer iceServer;
+
+        private IceServerWrapper(final PeerConnection.IceServer iceServer) {
+            this.iceServer = iceServer;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (!(o instanceof IceServerWrapper that)) return false;
+            return Objects.equal(iceServer.urls, that.iceServer.urls)
+                    && Objects.equal(iceServer.username, that.iceServer.username)
+                    && Objects.equal(iceServer.password, that.iceServer.password);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hashCode(iceServer.urls, iceServer.urls, iceServer.password);
+        }
+
+        @Override
+        @NonNull
+        public String toString() {
+            return this.iceServer.toString();
+        }
+    }
 }

src/main/res/drawable/ic_folder_open_24dp.xml 🔗

@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?attr/colorControlNormal"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L400,160L480,240L800,240Q833,240 856.5,263.5Q880,287 880,320L160,320L160,720Q160,720 160,720Q160,720 160,720L256,400L940,400L837,743Q829,769 807.5,784.5Q786,800 760,800L160,800Z" />
+</vector>

src/cheogram/res/layout/dialog_enter_password.xml → src/main/res/layout/dialog_enter_password.xml 🔗

@@ -19,25 +19,40 @@
                 android:text="@string/enter_password_to_restore"
                 android:textAppearance="?textAppearanceBodyMedium" />
 
-            <TextView
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="18sp"
-                android:text="@string/restore_warning"
-                android:textAppearance="?textAppearanceBodyMedium" />
+            <com.google.android.material.card.MaterialCardView
+                android:layout_marginTop="16sp"
+                style="?attr/materialCardViewFilledStyle"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content">
+
+                <LinearLayout
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:orientation="vertical"
+                    android:padding="16dp">
+                    <com.google.android.material.materialswitch.MaterialSwitch
+                        android:id="@+id/include_keys"
+                        android:checked="true"
+
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:text="@string/restore_omemo_key"/>
+
+                    <TextView
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:text="@string/restore_warning"
+                        android:textAppearance="?textAppearanceBodyMedium" />
+                </LinearLayout>
+
+            </com.google.android.material.card.MaterialCardView>
 
-            <TextView
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="18sp"
-                android:text="@string/restore_warning_continued"
-                android:textAppearance="?textAppearanceBodyMedium" />
 
             <com.google.android.material.textfield.TextInputLayout
                 android:id="@+id/account_password_layout"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
-                android:layout_marginTop="8dp"
+                android:layout_marginTop="16sp"
                 app:endIconMode="password_toggle">
 
                 <eu.siacs.conversations.ui.widget.TextInputEditText
@@ -48,6 +63,12 @@
                     android:inputType="textPassword" />
 
             </com.google.android.material.textfield.TextInputLayout>
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="16sp"
+                android:text="@string/restore_warning_continued"
+                android:textAppearance="?textAppearanceBodySmall" />
         </LinearLayout>
     </ScrollView>
 </layout>

src/main/res/values-ar/strings.xml 🔗

@@ -650,4 +650,4 @@
     <string name="account_status_regis_invalid_token">رمز التسجيل غير صالح</string>
     <string name="bad_key_for_encryption">مفتاح تعمية خاطئ.</string>
     <string name="invalid_jid">هذا ليس عنوان XMPP صالح</string>
-</resources>
+</resources>

src/main/res/values-bg/strings.xml 🔗

@@ -936,4 +936,4 @@
     <string name="backup_started_message">Създаването на резервно копие е стартирано. Ще получите известие, когато приключи.</string>
     <string name="unable_to_enable_video">Видеото не може да бъде включено.</string>
     <string name="plain_text_document">Обикновен текстов документ</string>
-</resources>
+</resources>

src/main/res/values-ca/strings.xml 🔗

@@ -914,4 +914,4 @@
     <string name="unable_to_parse_invite">No es pot processar la invitació</string>
     <string name="server_does_not_support_easy_onboarding_invites">El servidor no admet la generació d\'invitacions</string>
     <string name="no_active_accounts_support_this">Cap compte actiu admet aquesta funció</string>
-</resources>
+</resources>

src/main/res/values-cs/strings.xml 🔗

@@ -1132,4 +1132,4 @@
     <string name="pref_call_integration">Integrace hovorů</string>
     <string name="pref_call_integration_summary">Hovory z této aplikace interagují s běžnými telefonními hovory, například ukončení jednoho hovoru, když začne další.</string>
     <string name="delete_avatar_message">Chcete smazat svůj avatar? Některé aplikace mohou i nadále zobrazovat uloženou kopii vašeho avataru.</string>
-</resources>
+</resources>

src/main/res/values-da-rDK/strings.xml 🔗

@@ -984,4 +984,4 @@
     <string name="outgoing_call_duration_timestamp">Udgående opkald (%s) · %s</string>
     <string name="incoming_call_duration_timestamp">Indkommende opkald (%s) · %s</string>
     <string name="delete_from_server">Fjern konto fra server</string>
-</resources>
+</resources>

src/main/res/values-de/strings.xml 🔗

@@ -309,8 +309,8 @@
     <string name="send_again">Erneut senden</string>
     <string name="file_url">Datei-URL</string>
     <string name="url_copied_to_clipboard">URL in die Zwischenablage kopiert</string>
-    <string name="jabber_id_copied_to_clipboard">XMPP-Adresse in Zwischenablage kopiert</string>
-    <string name="error_message_copied_to_clipboard">Fehlermeldung in Zwischenablage kopiert</string>
+    <string name="jabber_id_copied_to_clipboard">XMPP-Adresse in die Zwischenablage kopiert</string>
+    <string name="error_message_copied_to_clipboard">Fehlermeldung in die Zwischenablage kopiert</string>
     <string name="web_address">Internetadresse</string>
     <string name="scan_qr_code">QR-Code scannen</string>
     <string name="show_qr_code">QR-Code anzeigen</string>
@@ -321,7 +321,7 @@
     <string name="pref_keep_foreground_service">Vordergrunddienst</string>
     <string name="pref_keep_foreground_service_summary">Verhindert, dass das Betriebssystem deine Verbindung unterbricht</string>
     <string name="pref_create_backup">Sicherung erstellen</string>
-    <string name="pref_create_backup_summary">Sicherungsdateien werden gespeichert in %s</string>
+    <string name="pref_create_backup_summary">Sicherungen werden gespeichert in %s</string>
     <string name="notification_create_backup_title">Erstelle Sicherungsdateien</string>
     <string name="notification_backup_created_title">Deine Sicherung wurde erstellt</string>
     <string name="notification_backup_created_subtitle">Die Sicherungsdateien wurden gespeichert in %s</string>
@@ -819,12 +819,12 @@
     <string name="ebook">E-Book</string>
     <string name="video_original">Original (unkomprimiert)</string>
     <string name="open_with">Öffnen mit…</string>
-    <string name="set_profile_picture">Conversations Profilbild</string>
+    <string name="set_profile_picture">Profilbild</string>
     <string name="choose_account">Konto auswählen</string>
     <string name="restore_backup">Sicherung wiederherstellen</string>
     <string name="restore">Wiederherstellung</string>
     <string name="enter_password_to_restore">Gib dein Passwort für das Konto %s ein, um die Sicherung wiederherzustellen.</string>
-    <string name="restore_warning">Benutze die Sicherungsfunktion nicht, um eine Installation zu klonen (gleichzeitig auszuführen). Die Wiederherstellung einer Sicherung ist nur für Migrationen oder für den Fall gedacht, dass du das ursprüngliche Gerät verloren hast.</string>
+    <string name="restore_warning">Stelle die OMEMO-Schlüssel nicht wieder her, um eine Installation zu klonen (gleichzeitig auszuführen). Die Wiederherstellung von OMEMO-Schlüsseln ist nur für Migrationen oder für den Fall gedacht, dass du das ursprüngliche Gerät verloren hast.</string>
     <string name="unable_to_restore_backup">Sicherung konnte nicht wiederhergestellt werden.</string>
     <string name="unable_to_decrypt_backup">Sicherung konnte nicht entschlüsselt werden. Ist das Passwort korrekt?</string>
     <string name="backup_channel_name">Sicherung &amp; Wiederherstellung</string>
@@ -987,7 +987,7 @@
     <string name="could_not_delete_account_from_server">Konto konnte nicht vom Server gelöscht werden</string>
     <string name="group_chats">Gruppenchats</string>
     <string name="search_group_chats">Gruppenchats durchsuchen</string>
-    <string name="restore_warning_continued">Versuche nicht, Backups wiederherzustellen, die du nicht selbst erstellt hast!</string>
+    <string name="restore_warning_continued">Nur Sicherungen wiederherstellen, die du selbst erstellt hast.</string>
     <string name="outdated_backup_file_format">Du versuchst, ein veraltetes Sicherungsdateiformat zu importieren</string>
     <string name="audiobook">Hörbuch</string>
     <string name="reconnect_on_other_host">Verbindung auf anderem Host wiederherstellen</string>
@@ -1110,4 +1110,17 @@
     <string name="show_to_contacts_only">Nur für Kontakte anzeigen</string>
     <string name="account_status_connection_timeout">Zeitüberschreitung beim Verbinden</string>
     <string name="retry_with_p2p">Erneut mit P2P versuchen</string>
-</resources>
+    <string name="account_status_channel_binding">Kanalbindung nicht verfügbar</string>
+    <string name="word_document">Word-Dokument</string>
+    <string name="restore_omemo_key">OMEMO-Schlüssel wiederherstellen</string>
+    <string name="non_quicksy_backup">Quicksy kann nur Sicherungen für quicksy.im-Konten wiederherstellen</string>
+    <string name="pref_backup_location">Sicherungsort</string>
+    <string name="uri">URI</string>
+    <string name="copy_telephone_number">Telefonnummer kopieren</string>
+    <string name="copy_geo_uri">Standort kopieren</string>
+    <string name="copy_email_address">E-Mail-Adresse kopieren</string>
+    <string name="copied_phone_number">Telefonnummer in die Zwischenablage kopiert</string>
+    <string name="uri_copied_to_clipboard">URI in die Zwischenablage kopiert</string>
+    <string name="copy_URI">URI kopieren</string>
+    <string name="copied_email_address">E-Mail-Adresse in die Zwischenablage kopiert</string>
+</resources>

src/main/res/values-el/strings.xml 🔗

@@ -944,4 +944,4 @@
     <string name="plain_text_document">Έγγραφο απλού κειμένου</string>
     <string name="account_registrations_are_not_supported">Δεν υποστηρίζονται εγγραφές λογαριασμών</string>
     <string name="no_xmpp_adddress_found">Δεν βρέθηκε διεύθυνση XMPP</string>
-</resources>
+</resources>

src/main/res/values-es/strings.xml 🔗

@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <string name="action_settings">Ajustes</string>
+    <string name="action_settings">Configuración</string>
     <string name="action_accounts">Gestionar cuentas</string>
     <string name="action_account">Gestionar cuenta</string>
     <string name="action_contact_details">Detalles del contacto</string>
@@ -16,10 +16,10 @@
     <string name="action_unblock_domain">Desbloquear dominio</string>
     <string name="action_block_participant">Bloquear participante</string>
     <string name="action_unblock_participant">Desbloquear participante</string>
-    <string name="title_activity_manage_accounts">Gestionar Cuentas</string>
-    <string name="title_activity_settings">Ajustes</string>
-    <string name="title_activity_choose_contact">Seleccionar Contacto</string>
-    <string name="title_activity_choose_contacts">Seleccionar Contactos</string>
+    <string name="title_activity_manage_accounts">Gestionar cuentas</string>
+    <string name="title_activity_settings">Configuración</string>
+    <string name="title_activity_choose_contact">Elegir contacto</string>
+    <string name="title_activity_choose_contacts">Elegir contactos</string>
     <string name="title_activity_share_via_account">Compartir via cuenta</string>
     <string name="title_activity_block_list">Lista contactos bloqueados</string>
     <string name="just_now">ahora</string>
@@ -31,18 +31,18 @@
         <item quantity="other">%d chats sin leer</item>
     </plurals>
     <string name="sending">enviando…</string>
-    <string name="message_decrypting">Descifrando el mensaje. Espere por favor…</string>
+    <string name="message_decrypting">Descifrando el mensaje. Espere…</string>
     <string name="pgp_message">Mensaje cifrado con OpenPGP</string>
     <string name="nick_in_use">El apodo ya está en uso</string>
-    <string name="invalid_muc_nick">Apodo inválido</string>
+    <string name="invalid_muc_nick">Apodo no válido</string>
     <string name="admin">Administrador</string>
     <string name="owner">Propietario</string>
     <string name="moderator">Moderador</string>
     <string name="participant">Participante</string>
     <string name="visitor">Visitante</string>
-    <string name="remove_contact_text">¿Quieres eliminar a %s de tu lista de contactos? Los chats con este contacto no se eliminarán.</string>
-    <string name="block_contact_text">¿Quieres bloquear a %s para que no pueda enviarte mensajes?</string>
-    <string name="unblock_contact_text">¿Quieres desbloquear a %s y permitirle que te envíe mensajes?</string>
+    <string name="remove_contact_text">¿Quiere eliminar a %s de su lista de contactos? La conversación con este contacto no se eliminará.</string>
+    <string name="block_contact_text">¿Quiere bloquear a %s para que no pueda enviarle mensajes?</string>
+    <string name="unblock_contact_text">¿Quiere desbloquear a %s y permitirle que le envíe mensajes?</string>
     <string name="block_domain_text">¿Bloquear todos los contactos de %s?</string>
     <string name="unblock_domain_text">¿Desbloquear todos los contatos de %s?</string>
     <string name="contact_blocked">Contacto bloqueado</string>
@@ -62,16 +62,16 @@
     <string name="block">Bloquear</string>
     <string name="unblock">Desbloquear</string>
     <string name="save">Guardar</string>
-    <string name="ok">OK</string>
+    <string name="ok">Aceptar</string>
     <string name="crash_report_title">%1$s se ha detenido</string>
     <string name="crash_report_message">Usar tu cuenta XMPP para enviar trazas de error ayuda al desarrollo de %1$s.</string>
     <string name="send_now">Enviar ahora</string>
     <string name="send_never">No preguntar de nuevo</string>
     <string name="problem_connecting_to_account">No se ha podido conectar a la cuenta</string>
     <string name="problem_connecting_to_accounts">No se ha podido conectar a varias cuentas</string>
-    <string name="touch_to_fix">Pulsa aquí para gestionar tus cuentas</string>
+    <string name="touch_to_fix">Toque para gestionar sus cuentas</string>
     <string name="attach_file">Adjuntar</string>
-    <string name="not_in_roster">El contacto no está en tu lista. ¿Te gustaría añadirlo?</string>
+    <string name="not_in_roster">El contacto no está en su lista. ¿Le gustaría añadirlo?</string>
     <string name="add_contact">Añadir contacto</string>
     <string name="send_failed">Error al enviar</string>
     <string name="preparing_image">Preparando para enviar imagen</string>
@@ -83,26 +83,24 @@
 \n
 \n<b>Aviso:</b> Esto no afectará a los mensajes guardados en otros dispositivos o servidores.</string>
     <string name="delete_file_dialog">Eliminar fichero</string>
-    <string name="delete_file_dialog_msg">¿Está seguro de que desea eliminar este archivo\?
-\n
-\n<b>Advertencia:</b> Esto no eliminará las copias de este archivo almacenadas en otros dispositivos o servidores. </string>
-    <string name="choose_presence">Seleccionar dispositivo</string>
+    <string name="delete_file_dialog_msg">¿Confirma que quiere eliminar este archivo? \n \n<b>Aviso:</b> esto no eliminará las copias de este archivo almacenadas en otros dispositivos o servidores.</string>
+    <string name="choose_presence">Elegir dispositivo</string>
     <string name="send_unencrypted_message">Enviar mensaje sin cifrar</string>
     <string name="send_message">Enviar mensaje</string>
     <string name="send_message_to_x">Enviar mensaje a %s</string>
     <string name="send_omemo_x509_message">Enviar mensaje cifrado v\\OMEMO</string>
     <string name="your_nick_has_been_changed">El apodo ha sido modificado</string>
     <string name="send_unencrypted">Enviar sin cifrar</string>
-    <string name="decryption_failed">Falló el descifrado. Tal vez no tengas la clave privada apropiada.</string>
+    <string name="decryption_failed">Falló el descifrado. Tal vez no tenga la clave privada apropiada.</string>
     <string name="openkeychain_required">OpenKeychain</string>
     <string name="openkeychain_required_long"><![CDATA[%1$s utiliza <b>OpenKeychain</b> para cifrar y descifrar mensajes y gestionar tus claves públicas.<br><br>Está publicado bajo licencia GPLv3+ y disponible en F-Droid y Google Play.<br><br><small>(Por favor, reinicie %1$s después.)</small>]]></string>
     <string name="restart">Reiniciar</string>
     <string name="install">Instalar</string>
-    <string name="openkeychain_not_installed">Por favor, instala OpenKeyChain</string>
+    <string name="openkeychain_not_installed">Instale OpenKeyChain</string>
     <string name="offering">ofreciendo…</string>
     <string name="waiting">esperando…</string>
     <string name="no_pgp_key">Clave OpenPGP no encontrada</string>
-    <string name="contact_has_no_pgp_key">No se ha podido cifrar tu mensaje porque tu contacto no está anunciando su clave pública.\n\n<small>Por favor, pide a tu contacto que configure OpenPGP.</small></string>
+    <string name="contact_has_no_pgp_key">No se ha podido cifrar tu mensaje porque su contacto no está anunciando su clave pública.\n\n<small>Pida a su contacto que configure OpenPGP.</small></string>
     <string name="no_pgp_keys">Claves OpenPGP no encontradas</string>
     <string name="contacts_have_no_pgp_keys">No se ha podido cifrar tu mensaje porque tus contactos no están anunciando sus claves públicas.\n\n<small>Por favor, pide a tus contactos que configuren OpenPGP.</small></string>
     <string name="pref_general">General</string>
@@ -123,7 +121,7 @@
     <string name="pref_advanced_options">Avanzado</string>
     <string name="pref_never_send_crash_summary">Al enviar los informes de los fallos, ayudará a un mayor desarrollo</string>
     <string name="pref_confirm_messages">Confirmar mensajes</string>
-    <string name="pref_confirm_messages_summary">Permitir a tus contactos saber cuando has recibido y leído sus mensajes</string>
+    <string name="pref_confirm_messages_summary">Permitir a sus contactos saber cuando ha recibido y leído sus mensajes</string>
     <string name="pref_prevent_screenshots">Impedir capturas de pantalla</string>
     <string name="pref_prevent_screenshots_summary">Ocultar el contenido de la aplicación en el selector de aplicaciones y bloquear las capturas de pantalla</string>
     <string name="pref_ui_options">Pantalla</string>
@@ -132,11 +130,11 @@
     <string name="accept">Aceptar</string>
     <string name="error">Ha ocurrido un error</string>
     <string name="recording_error">Error</string>
-    <string name="your_account">Tu cuenta</string>
+    <string name="your_account">Su cuenta</string>
     <string name="send_presence_updates">Enviar actualizaciones de presencia</string>
     <string name="receive_presence_updates">Recibir actualizaciones de presencia</string>
     <string name="ask_for_presence_updates">Solicitar actualizaciones de presencia</string>
-    <string name="attach_choose_picture">Seleccionar imagen</string>
+    <string name="attach_choose_picture">Elegir imagen</string>
     <string name="attach_take_picture">Hacer foto</string>
     <string name="preemptively_grant">De forma automática conceder suscripción de presencia</string>
     <string name="error_not_an_image_file">El archivo seleccionado no es una imagen</string>
@@ -148,7 +146,7 @@
 \n<small>Use otro administrador de archivos para seleccionar una imagen</small>.</string>
     <string name="error_security_exception">La aplicación que utilizó para compartir este archivo no tiene suficientes permisos.</string>
     <string name="account_status_unknown">Desconocido</string>
-    <string name="account_status_disabled">Deshabilitado temporalmente</string>
+    <string name="account_status_disabled">Desactivado temporalmente</string>
     <string name="account_status_online">Conectado</string>
     <string name="account_status_connecting">Conectando\u2026</string>
     <string name="account_status_offline">Desconectado</string>
@@ -158,7 +156,7 @@
     <string name="account_status_regis_fail">Error en el registro</string>
     <string name="account_status_regis_conflict">El identificador ya está en uso</string>
     <string name="account_status_regis_success">Registro completado</string>
-    <string name="account_status_regis_not_sup">El servidor no soporta registros</string>
+    <string name="account_status_regis_not_sup">El servidor no admite registros</string>
     <string name="account_status_regis_invalid_token">Token de registro inválido</string>
     <string name="account_status_tls_error">Error de negociación TLS</string>
     <string name="account_status_tls_error_domain">Dominio no verificable</string>
@@ -172,14 +170,14 @@
     <string name="encryption_choice_pgp">OpenPGP</string>
     <string name="encryption_choice_omemo">OMEMO</string>
     <string name="mgmt_account_delete">Eliminar cuenta</string>
-    <string name="mgmt_account_disable">Deshabilitar temporalmente</string>
-    <string name="mgmt_account_publish_avatar">Imagen de perfil</string>
+    <string name="mgmt_account_disable">Desactivar temporalmente</string>
+    <string name="mgmt_account_publish_avatar">Publicar avatar</string>
     <string name="mgmt_account_publish_pgp">Publicar clave pública OpenPGP</string>
     <string name="unpublish_pgp">Eliminar la clave pública OpenPGP</string>
     <string name="unpublish_pgp_message">¿Estás seguro de que quieres eliminar tu clave pública OpenPGP de tu anuncio de presencia?\nTus contactos no podrán enviarte mensajes cifrados con OpenPGP.</string>
     <string name="openpgp_has_been_published">La clave pública OpenPGP ha sido publicada.</string>
-    <string name="mgmt_account_enable">Habilitar</string>
-    <string name="mgmt_account_delete_confirm_text">¿Estás seguro de que desea eliminar tu cuenta? Eliminar tú cuenta borrando todo tu historial del chat</string>
+    <string name="mgmt_account_enable">Activar cuenta</string>
+    <string name="mgmt_account_delete_confirm_text">¿Confirma que quiere eliminar su cuenta? Eliminar la cuenta borrará por completo el histórico de conversaciones</string>
     <string name="attach_record_voice">Grabar audio</string>
     <string name="account_settings_jabber_id">Dirección XMPP</string>
     <string name="block_jabber_id">Bloquear dirección XMPP</string>
@@ -187,7 +185,7 @@
     <string name="password">Contraseña</string>
     <string name="invalid_jid">Esta no es una dirección XMPP válida</string>
     <string name="error_out_of_memory">Sin memoria. La imagen es demasiado grande</string>
-    <string name="add_phone_book_text">¿Quieres añadir a %s a tus contactos?</string>
+    <string name="add_phone_book_text">¿Quiere añadir a %s a sus contactos?</string>
     <string name="server_info_show_more">Información de servidor</string>
     <string name="server_info_mam">XEP-0313: MAM</string>
     <string name="server_info_carbon_messages">XEP-0280: Copias de los mensajes</string>
@@ -201,7 +199,7 @@
     <string name="server_info_push">XEP-0357: Notificaciones automáticas</string>
     <string name="server_info_available">Sí</string>
     <string name="server_info_unavailable">No</string>
-    <string name="missing_public_keys">Se han perdido las claves de anuncio públicas</string>
+    <string name="missing_public_keys">Anuncios de clave pública no notificados</string>
     <string name="last_seen_now">Visto última vez ahora</string>
     <string name="last_seen_min">visto última vez hace un minuto</string>
     <string name="last_seen_mins">Visto última vez hace %d minutos</string>
@@ -235,30 +233,30 @@
     <string name="channel_bare_jid_example">canal@salas.ejemplo.com</string>
     <string name="save_as_bookmark">Guardar en marcadores</string>
     <string name="delete_bookmark">Eliminar marcador</string>
-    <string name="destroy_room">Destruir conversación en grupo</string>
+    <string name="destroy_room">Destruir conversación grupal</string>
     <string name="destroy_channel">Destruir canal</string>
-    <string name="destroy_room_dialog">¿Estás seguro de que quieres destruir esta conversación en grupo?\n\n<b>Aviso:</b>La conversación en grupo será eliminada completamente en el servidor.</string>
-    <string name="destroy_channel_dialog">¿Estás seguro de que quieres destruir este canal público?\n\n<b>Aviso:</b>El canal será eliminado completamente en el servidor.</string>
-    <string name="could_not_destroy_room">No se ha podido destruir la conversación en grupo</string>
+    <string name="destroy_room_dialog">¿Confirma que quiere destruir esta conversación grupal?\n\n<b>Aviso:</b> la conversación grupal se eliminará completamente del servidor.</string>
+    <string name="destroy_channel_dialog">¿Confirma que quiere destruir este canal público?\n\n<b>Aviso:</b> el canal se eliminará completamente del servidor.</string>
+    <string name="could_not_destroy_room">No se ha podido destruir la conversación grupal</string>
     <string name="could_not_destroy_channel">No se ha podido destruir el canal</string>
     <string name="action_edit_subject">Editar asunto de la conversación</string>
     <string name="topic">Asunto</string>
     <string name="joining_conference">Uniéndose a un chat de grupo…</string>
-    <string name="leave">Salir</string>
-    <string name="contact_added_you">El contacto te ha añadido a su lista de contactos</string>
-    <string name="add_back">Añadir contacto</string>
+    <string name="leave">Abandonar</string>
+    <string name="contact_added_you">El contacto le ha añadido a su lista de contactos</string>
+    <string name="add_back">Añadir de vuelta</string>
     <string name="contact_has_read_up_to_this_point">%s ha leído hasta aquí</string>
     <string name="contacts_have_read_up_to_this_point">%s han leído hasta aquí</string>
     <string name="contacts_and_n_more_have_read_up_to_this_point">%1$s + %2$d han leído hasta aquí</string>
     <string name="everyone_has_read_up_to_this_point">Todos han leído hasta aquí</string>
     <string name="publish">Publicar</string>
-    <string name="touch_to_choose_picture">Pulsa la imagen de perfil para seleccionar una imagen de la galería</string>
+    <string name="touch_to_choose_picture">Toque el avatar para seleccionar una imagen de la galería</string>
     <string name="publishing">Publicando…</string>
     <string name="error_publish_avatar_server_reject">El servidor rechazó la publicación</string>
     <string name="error_publish_avatar_converting">No se ha podido convertir su imagen</string>
     <string name="error_saving_avatar">No se ha podido guardar la imagen de perfil en disco</string>
     <string name="or_long_press_for_default">(O pulsación prolongada para volver a tu imagen de la agenda)</string>
-    <string name="error_publish_avatar_no_server_support">Tu servidor no soporta la publicación de imágenes de perfil</string>
+    <string name="error_publish_avatar_no_server_support">Su servidor no admite la publicación de avatares</string>
     <string name="private_message">en privado</string>
     <string name="private_message_to">en privado para %s</string>
     <string name="send_private_message_to">Enviar mensaje privado a %s</string>
@@ -267,14 +265,14 @@
     <string name="next">Siguiente</string>
     <string name="server_info_session_established">Sesión establecida</string>
     <string name="skip">Omitir</string>
-    <string name="disable_notifications">Deshabilitar notificaciones</string>
-    <string name="enable">Habilitar</string>
+    <string name="disable_notifications">Desactivar notificaciones</string>
+    <string name="enable">Activar</string>
     <string name="conference_requires_password">Esta conversación en grupo requiere contraseña</string>
-    <string name="enter_password">Introduce la contraseña</string>
+    <string name="enter_password">Introduzca la contraseña</string>
     <string name="request_presence_updates">Por favor, solicita la actualización de presencia a tu contacto primero.\n\n<small>Esto se usará para determinar qué aplicación de mensajería está usando tu contacto</small>.</string>
     <string name="request_now">Solicitar ahora</string>
     <string name="ignore">Ignorar</string>
-    <string name="without_mutual_presence_updates"><b>Aviso:</b> Si envías esto sin actualización de presencia mutua con tu contacto se podrían producir problemas inesperados.\n\n<small>Ve a “Detalles del contacto” para verificar las actualizaciones de presencia.</small></string>
+    <string name="without_mutual_presence_updates"><b>Aviso:</b> si envía esto sin actualización de presencia mutua con su contacto se podrían producir problemas inesperados.\n\n<small>Vaya a «Detalles del contacto» para verificar las actualizaciones de presencia.</small></string>
     <string name="pref_security_settings">Seguridad</string>
     <string name="pref_allow_message_correction">Corrección de los mensajes</string>
     <string name="pref_allow_message_correction_summary">Permitir a tus contactos editar mensajes previamente enviados</string>
@@ -283,7 +281,7 @@
     <string name="title_activity_about_x">Acerca de %s</string>
     <string name="title_pref_quiet_hours">Horario de silencio</string>
     <string name="title_pref_quiet_hours_start_time">Hora de comienzo</string>
-    <string name="title_pref_quiet_hours_end_time">Hora de fin</string>
+    <string name="title_pref_quiet_hours_end_time">Hora de finalización</string>
     <string name="title_pref_enable_quiet_hours">Habilitar horario de silencio</string>
     <string name="pref_quiet_hours_summary">Las notificaciones serán silenciadas durante el horario de silencio</string>
     <string name="pref_expert_options_other">Otros</string>
@@ -322,14 +320,14 @@
     <string name="pref_keep_foreground_service">Servicio en primer plano</string>
     <string name="pref_keep_foreground_service_summary">Mantener el servicio en primer plano previene que el sistema cierre la conexión</string>
     <string name="pref_create_backup">Crear una copia de respaldo</string>
-    <string name="pref_create_backup_summary">Los ficheros de respaldo serán almacenados en %s</string>
+    <string name="pref_create_backup_summary">Las copias de seguridad se almacenarán en %s</string>
     <string name="notification_create_backup_title">Creando los ficheros de respaldo</string>
     <string name="notification_backup_created_title">Tu copia de respaldo ha sido creada</string>
     <string name="notification_backup_created_subtitle">Los ficheros de respaldo han sido almacenados en %s</string>
     <string name="restoring_backup">Restaurando copia de respaldo</string>
     <string name="notification_restored_backup_title">Tu copia de respaldo ha sido restaurada</string>
     <string name="notification_restored_backup_subtitle">No olvides activar la cuenta.</string>
-    <string name="choose_file">Seleccionar archivo</string>
+    <string name="choose_file">Elegir archivo</string>
     <string name="receiving_x_file">Recibiendo %1$s (%2$d%% completado)</string>
     <string name="download_x_file">Descargar %s</string>
     <string name="delete_x_file">Eliminar %s</string>
@@ -347,7 +345,7 @@
     <string name="no_application_found_to_view_contact">No se ha encontrado aplicación para ver el contacto</string>
     <string name="pref_show_dynamic_tags">Etiquetas dinámicas</string>
     <string name="pref_show_dynamic_tags_summary">Mostrar información en forma de etiquetas debajo de los contactos</string>
-    <string name="enable_notifications">Habilitar notificaciones</string>
+    <string name="enable_notifications">Activar notificaciones</string>
     <string name="no_conference_server_found">No se ha encontrado el servidor de la conversación en grupo</string>
     <string name="conference_creation_failed">No se ha podido crear la conversación en grupo</string>
     <string name="account_image_description">Imagen de perfil</string>
@@ -367,8 +365,8 @@
     <string name="current_password">Contraseña actual</string>
     <string name="new_password">Nueva contraseña</string>
     <string name="password_should_not_be_empty">La contraseña no puede ser vacía</string>
-    <string name="enable_all_accounts">Habilitar todas las cuentas</string>
-    <string name="disable_all_accounts">Deshabilitar todas las cuentas</string>
+    <string name="enable_all_accounts">Activar todas las cuentas</string>
+    <string name="disable_all_accounts">Desactivar todas las cuentas</string>
     <string name="perform_action_with">Realizar acción con</string>
     <string name="no_affiliation">Sin afiliación</string>
     <string name="no_role">Desconectado</string>
@@ -404,7 +402,7 @@
     <string name="mark_as_read">Marcar como leído</string>
     <string name="pref_input_options">Entrada</string>
     <string name="pref_enter_is_send">Intro para enviar</string>
-    <string name="pref_enter_is_send_summary">Utilizar la tecla Enter para enviar un mensaje. Siempre puedes usar Ctrl+Enter para enviar un mensaje, incluso si esta opción está deshabilitada.</string>
+    <string name="pref_enter_is_send_summary">Usar la tecla Intro para enviar un mensaje. Siempre puede usar Ctrl+Intro para enviar un mensaje, incluso si esta opción está desactivada.</string>
     <string name="pref_display_enter_key">Mostrar tecla Intro</string>
     <string name="pref_display_enter_key_summary">Cambiar la tecla de emoticonos por la tecla Intro</string>
     <string name="audio">audio</string>
@@ -413,9 +411,9 @@
     <string name="vector_graphic">gráfico de vectores</string>
     <string name="multimedia_file">archivo multimedia</string>
     <string name="pdf_document">documento PDF</string>
-    <string name="apk">Android App</string>
+    <string name="apk">aplicación para Android</string>
     <string name="vcard">Contacto</string>
-    <string name="avatar_has_been_published">¡La imagen de perfil ha sido publicada!</string>
+    <string name="avatar_has_been_published">Se ha publicado el avatar.</string>
     <string name="sending_x_file">Enviando %s</string>
     <string name="offering_x_file">Ofreciendo %s</string>
     <string name="hide_offline">Ocultar desconectados</string>
@@ -443,7 +441,7 @@
         <item quantity="many">%d certificados eliminados</item>
         <item quantity="other">%d certificados eliminados</item>
     </plurals>
-    <string name="pref_quick_action_summary">Cambiar el botón de “Enviar” por el botón de acción rápida</string>
+    <string name="pref_quick_action_summary">Cambiar el botón «Enviar» por el botón de acción rápida</string>
     <string name="pref_quick_action">Acción rápida</string>
     <string name="none">Ninguna</string>
     <string name="recently_used">Usada más recientemente</string>
@@ -486,7 +484,7 @@
     <string name="action_renew_certificate">Renovar certificado</string>
     <string name="error_fetching_omemo_key">¡Error buscando clave OMEMO!</string>
     <string name="verified_omemo_key_with_certificate">¡Clave OMEMO con certificado verificada!</string>
-    <string name="device_does_not_support_certificates">¡Tu dispositivo no soporta la elección de certificados de cliente!</string>
+    <string name="device_does_not_support_certificates">Su dispositivo no admite la selección de certificados de cliente.</string>
     <string name="pref_connection_options">Conexión</string>
     <string name="pref_use_tor">Conectar via Tor</string>
     <string name="pref_use_tor_summary">Todas las conexiones se realizan a través de la red TOR. Requiere Orbot</string>
@@ -514,23 +512,23 @@
 \n¡Ningún dato de la lista de contactos sale de tu dispositivo!</string>
     <string name="notify_on_all_messages">Notificar para todos los mensajes</string>
     <string name="notify_only_when_highlighted">Notificar solo cuando eres mencionado</string>
-    <string name="notify_never">Notificaciones deshabilitadas</string>
+    <string name="notify_never">Notificaciones desactivadas</string>
     <string name="notify_paused">Notificaciones pausadas</string>
     <string name="pref_picture_compression">Compresión de imagen</string>
-    <string name="pref_picture_compression_summary">Pista: Usa \'Seleccionar archivo\' en lugar de \'Seleccionar imagen\' para enviar imágenes individuales sin comprimir con independencia de los ajustes.</string>
+    <string name="pref_picture_compression_summary">Consejo: use «Elegir archivo» en lugar de «Elegir imagen» para enviar imágenes separadas no comprimidas sin tener en cuenta esta opción.</string>
     <string name="always">Siempre</string>
     <string name="large_images_only">Solo imágenes de gran tamaño</string>
-    <string name="battery_optimizations_enabled">Optimizaciones de uso de batería habilitadas</string>
+    <string name="battery_optimizations_enabled">Optimizaciones de batería activadas</string>
     <string name="battery_optimizations_enabled_explained">Tu dispositivo está empleando severas optimizaciones del uso de batería por parte de %1$s, lo cual puede hacer que las notificaciones se retrasen o incluso que los mensajes se pierdan.\nEs recomendable deshabilitarlas.</string>
     <string name="battery_optimizations_enabled_dialog">Tu dispositivo está empleando severas optimizaciones del uso de batería por parte de %1$s, lo cual puede hacer que las notificaciones se retrasen o incluso que los mensajes se pierdan.\n\nA continuación se te preguntará si quiere deshabilitarlas.</string>
-    <string name="disable">Deshabilitar</string>
+    <string name="disable">Desactivar</string>
     <string name="selection_too_large">El área seleccionada es demasiado grande</string>
     <string name="no_accounts">(No hay cuentas activas)</string>
     <string name="this_field_is_required">Este campo es requerido</string>
     <string name="correct_message">Corregir mensaje</string>
     <string name="send_corrected_message">Enviar mensaje corregido</string>
-    <string name="no_keys_just_confirm">Ya has confiado en la huella digital de esta persona. Al seleccionar “Hecho” solo estás confirmando que %s es parte de este chat grupal.</string>
-    <string name="this_account_is_disabled">Has deshabilitado esta cuenta</string>
+    <string name="no_keys_just_confirm">Ya ha confiado en la huella digital de esta persona. Al seleccionar «Hecho» solo está confirmando que %s es parte de este chat grupal.</string>
+    <string name="this_account_is_disabled">Ha desactivado esta cuenta</string>
     <string name="security_error_invalid_file_access">Error de seguridad: ¡Acceso a archivo inválido!</string>
     <string name="no_application_to_share_uri">No se ha encontrado ninguna aplicación para compartir la URI</string>
     <string name="share_uri_with">Compartir URI con…</string>
@@ -550,13 +548,13 @@
     <string name="presence_xa">No disponible</string>
     <string name="presence_dnd">Ocupado</string>
     <string name="secure_password_generated">Se ha generado una contraseña segura</string>
-    <string name="device_does_not_support_battery_op">Tu dispositivo no soporta la opción de optimización de batería</string>
+    <string name="device_does_not_support_battery_op">Su dispositivo no admite desactivar la optimización de batería</string>
     <string name="registration_please_wait">El registro falló. Prueba de nuevo más tarde</string>
     <string name="registration_password_too_weak">Error en el registro: La contraseña es demasiado débil</string>
     <string name="choose_participants">Elige a los participantes</string>
-    <string name="creating_conference">Creando un chat de grupo…</string>
+    <string name="creating_conference">Creando una conversación grupal…</string>
     <string name="invite_again">Invitar de nuevo</string>
-    <string name="gp_disable">Deshabilitar</string>
+    <string name="gp_disable">Desactivar</string>
     <string name="gp_short">Corto</string>
     <string name="gp_medium">Medio</string>
     <string name="gp_long">Largo</string>
@@ -593,7 +591,7 @@
     <string name="error_message">Mensaje de error</string>
     <string name="data_saver_enabled">Optimización de datos habilitado</string>
     <string name="data_saver_enabled_explained">Tu sistema operativo está restringiendo a %1$s el acceso a Internet cuando está en segundo plano. Para recibir notificaciones de nuevos mensajes deberías permitir a %1$s un acceso sin restricciones cuando la optimización de datos está habilitada.\n%1$s se esforzará igualmente en ahorrar datos cuando sea posible.</string>
-    <string name="device_does_not_support_data_saver">Tu dispositivo no soporta la opción de deshabilitar la optimización de datos para %1$s.</string>
+    <string name="device_does_not_support_data_saver">Su dispositivo no admite la desactivación de la optimización de datos para %1$s.</string>
     <string name="error_unable_to_create_temporary_file">No se ha podido crear el archivo temporal </string>
     <string name="this_device_has_been_verified">Este dispositivo ha sido verificado</string>
     <string name="copy_fingerprint">Copiar huella digital</string>
@@ -682,33 +680,33 @@
     <string name="copy_to_clipboard">Copiar al portapapeles</string>
     <string name="message_copied_to_clipboard">Mensaje copiado en el portapapeles</string>
     <string name="message">Mensaje</string>
-    <string name="private_messages_are_disabled">Los mensajes privados están deshabilitados</string>
+    <string name="private_messages_are_disabled">Los mensajes privados están desactivados</string>
     <string name="mtm_accept_cert">¿Aceptar certificado desconocido?</string>
     <string name="mtm_trust_anchor">El certificado del servidor no está firmado por una Autoridad Certificadora conocida.</string>
     <string name="mtm_accept_servername">¿Aceptar nombre del servidor no coincidente?</string>
-    <string name="mtm_hostname_mismatch">El servidor no pudo autenticarse como \"%s\". El certificado es solo válido para:</string>
+    <string name="mtm_hostname_mismatch">El servidor no pudo autenticarse como «%s». El certificado solo es válido para:</string>
     <string name="mtm_connect_anyway">¿Quieres conectar de todas formas?</string>
-    <string name="mtm_cert_details">Detalles del Certificado:</string>
+    <string name="mtm_cert_details">Detalles del certificado:</string>
     <string name="once">Una vez</string>
     <string name="qr_code_scanner_needs_access_to_camera">El escáner de código QR necesita acceso a la cámara</string>
     <string name="pref_scroll_to_bottom">Desplazarse hasta abajo</string>
     <string name="pref_scroll_to_bottom_summary">Desplazarse hasta abajo después de mandar un mensaje</string>
-    <string name="edit_status_message_title">Editar Mensaje de Estado</string>
+    <string name="edit_status_message_title">Editar mensaje de estado</string>
     <string name="edit_status_message">Editar mensaje de estado</string>
-    <string name="disable_encryption">Deshabilitar cifrado</string>
+    <string name="disable_encryption">Desactivar cifrado</string>
     <string name="error_trustkey_general">%1$s no puede enviar mensajes cifrados a %2$s. Esto puede deberse a que tu contacto está usando un servidor o un cliente desactualizado que no puede manejar las claves OMEMO.</string>
     <string name="error_trustkey_device_list">No se ha podido conseguir la lista de dispositivos </string>
     <string name="error_trustkey_bundle">No se han podido conseguir las claves de cifrado</string>
     <string name="error_trustkey_hint_mutual">Consejo: En algunas ocasiones esto puede corregirse agregando a tu contacto a tu lista de contactos. Tu contacto deberá asegurarse también que estás en su lista de contactos.</string>
     <string name="disable_encryption_message">¿Estás seguro de que deseas desactivar el cifrado OMEMO para este chat?
 \nEsto permitirá que el administrador de su servidor lea sus mensajes, pero podría ser la única forma de comunicarse con personas que utilizan clientes obsoletos.</string>
-    <string name="disable_now">Deshabilitar ahora</string>
+    <string name="disable_now">Desactivar ahora</string>
     <string name="draft">Borrador:</string>
     <string name="pref_omemo_setting">Cifrado OMEMO</string>
-    <string name="pref_omemo_setting_summary_always">OMEMO siempre será usado para conversaciones uno a uno y en conversaciones en grupo privadas.</string>
+    <string name="pref_omemo_setting_summary_always">OMEMO siempre se usará para conversaciones uno a uno y en conversaciones grupales privadas.</string>
     <string name="pref_omemo_setting_summary_default_on">OMEMO será usado por defecto para chats nuevos.</string>
     <string name="pref_omemo_setting_summary_default_off">OMEMO tendrá que ser activado explícitamente para los nuevos chats.</string>
-    <string name="create_shortcut">Crear acceso directo</string>
+    <string name="create_shortcut">Crear atajo</string>
     <string name="default_on">Activo por defecto</string>
     <string name="default_off">Desactivado por defecto</string>
     <string name="not_encrypted_for_this_device">El mensaje no fue cifrado para este dispositivo.</string>
@@ -718,7 +716,7 @@
     <string name="action_fix_to_location">Fijar posición</string>
     <string name="action_unfix_from_location">Desfijar posición</string>
     <string name="action_copy_location">Copiar ubicación</string>
-    <string name="action_share_location">Compatir Ubicación</string>
+    <string name="action_share_location">Compatir ubicación</string>
     <string name="action_directions">Direcciones</string>
     <string name="title_activity_share_location">Compartir ubicación</string>
     <string name="title_activity_show_location">Mostrar ubicación</string>
@@ -733,18 +731,18 @@
     <string name="pref_use_share_location_plugin_summary">Usar el Plugin Compartir Ubicación en lugar del propio de la aplicación</string>
     <string name="copy_link">Copiar dirección web</string>
     <string name="copy_jabber_id">Copiar dirección XMPP</string>
-    <string name="p1_s3_filetransfer">Compartición de Archivos mediante S3</string>
+    <string name="p1_s3_filetransfer">Compartición de archivos HTTP con S3</string>
     <string name="pref_start_search">Búsqueda directa</string>
-    <string name="pref_start_search_summary">En la pantalla \"Nuevo chat\", abra el teclado y coloque el cursor en el campo de búsqueda</string>
-    <string name="group_chat_avatar">Avatar de la conversación en grupo</string>
-    <string name="host_does_not_support_group_chat_avatars">El servidor no soporta avatares en conversaciones en grupo</string>
+    <string name="pref_start_search_summary">En la pantalla «Chat nuevo», abra el teclado y coloque el cursor en el campo de búsqueda</string>
+    <string name="group_chat_avatar">Avatar de la conversación grupal</string>
+    <string name="host_does_not_support_group_chat_avatars">El anfitrión no admite avatares en conversaciones grupales</string>
     <string name="only_the_owner_can_change_group_chat_avatar">Solo el propietario de la conversación puede cambiar el avatar</string>
     <string name="contact_name">Nombre del contacto</string>
     <string name="nickname">Apodo</string>
     <string name="group_chat_name">Nombre</string>
     <string name="providing_a_name_is_optional">Añadir un nombre es opcional</string>
-    <string name="create_dialog_group_chat_name">Nombre de la Conversación en grupo</string>
-    <string name="conference_destroyed">Esta conversación en grupo ha sido destruida</string>
+    <string name="create_dialog_group_chat_name">Nombre de la conversación grupal</string>
+    <string name="conference_destroyed">Esta conversación grupal se ha destruido</string>
     <string name="unable_to_save_recording">No se ha podido guardar la grabación </string>
     <string name="foreground_service_channel_name">Servicio en primer plano</string>
     <string name="foreground_service_channel_description">Esta categoría de notificación se usa para mostrar una notificación permantente indicando que %1$s está ejecutándose.</string>
@@ -760,8 +758,8 @@
     <string name="silent_messages_channel_name">Mensajes sin sonido</string>
     <string name="silent_messages_channel_description">Este grupo de notificaciones se usa para mostrar notificaciones que no deberían emitir ningún sonido. Por ejemplo, cuando estás activo en otro dispositivo (periodo de gracia).</string>
     <string name="delivery_failed_channel_name">Envíos fallidos</string>
-    <string name="pref_message_notification_settings">Ajustes de notificación de mensajes</string>
-    <string name="pref_incoming_call_notification_settings">Ajustes de notificación de llamadas</string>
+    <string name="pref_message_notification_settings">Configuración de notificaciones de mensajes</string>
+    <string name="pref_incoming_call_notification_settings">Configuración de notificaciones de llamadas</string>
     <string name="pref_more_notification_settings_summary">Importancia, Sonido, Vibración</string>
     <string name="video_compression_channel_name">Compresión de video</string>
     <string name="view_media">Ver galería</string>
@@ -794,7 +792,7 @@
     <string name="back">Atrás</string>
     <string name="possible_pin">Pegado automático del posible PIN desde el portapapeles.</string>
     <string name="please_enter_pin">Por favor, introduzca su PIN de 6 dígitos.</string>
-    <string name="abort_registration_procedure">¿Estás seguro de que quieres abortar el proceso de registro?</string>
+    <string name="abort_registration_procedure">¿Confirma que quiere interrumpir el proceso de registro?</string>
     <string name="yes">Sí</string>
     <string name="no">No</string>
     <string name="verifying">Verificando…</string>
@@ -828,19 +826,19 @@
     <string name="ebook">e-book</string>
     <string name="video_original">Original (sin comprimir)</string>
     <string name="open_with">Abrir con…</string>
-    <string name="set_profile_picture">Foto de perfil de Conversations</string>
+    <string name="set_profile_picture">Avatar</string>
     <string name="choose_account">Elige una cuenta</string>
     <string name="restore_backup">Restaurar copia de respaldo</string>
     <string name="restore">Restaurar</string>
     <string name="enter_password_to_restore">Introduce tu contraseña para la cuenta %s para restaurar la copia de respaldo.</string>
-    <string name="restore_warning">No utilices la opción de restaurar una copia de respaldo para clonar (ejecutar simultáneamente) una instalación. Restaurar una copia de respaldo se debe utilizar solo para migraciones o en caso de que hayas perdido el dispositivo original.</string>
+    <string name="restore_warning">No restaures claves OMEMO en un intento de clonar (ejecutar simultáneamente) una instalación. La restauración de claves OMEMO solo está pensada para migraciones o en caso de que hayas perdido el dispositivo original.</string>
     <string name="unable_to_restore_backup">No se ha podido restaurar la copia de respaldo.</string>
     <string name="unable_to_decrypt_backup">No se ha podido descifrar la copia de respaldo. ¿Es la contraseña correcta?</string>
     <string name="backup_channel_name">Respaldar &amp; Restaurar</string>
     <string name="enter_jabber_id">Introduce dirección XMPP</string>
-    <string name="create_group_chat">Crear una conversación en grupo</string>
+    <string name="create_group_chat">Crear una conversación grupal</string>
     <string name="join_public_channel">Unirse a canal público</string>
-    <string name="create_private_group_chat">Crear una conversación en grupo privada</string>
+    <string name="create_private_group_chat">Crear una conversación grupal privada</string>
     <string name="create_public_channel">Crear un canal público</string>
     <string name="create_dialog_channel_name">Nombre del canal</string>
     <string name="xmpp_address">Dirección XMPP</string>
@@ -892,7 +890,7 @@
     <string name="pref_channel_discovery">Método para la búsqueda de Canales</string>
     <string name="backup">Copia de respaldo</string>
     <string name="category_about">Acerca de</string>
-    <string name="please_enable_an_account">Por favor, habilita una cuenta</string>
+    <string name="please_enable_an_account">Active una cuenta</string>
     <string name="make_call">Hacer una llamada</string>
     <string name="rtp_state_incoming_call">Llamada entrante</string>
     <string name="rtp_state_incoming_video_call">Videollamada entrante</string>
@@ -918,7 +916,7 @@
     <string name="ongoing_video_call">Video llamada saliente</string>
     <string name="reconnecting_call">Reconectando llamada</string>
     <string name="reconnecting_video_call">Reconectando video llamada</string>
-    <string name="disable_tor_to_make_call">Deshabilitar Tor para hacer llamadas</string>
+    <string name="disable_tor_to_make_call">Desactive Tor para hacer llamadas</string>
     <string name="incoming_call">Llamada entrante</string>
     <string name="missed_call_timestamp">Llamada perdida · %s</string>
     <string name="outgoing_call">Llamada saliente</string>
@@ -976,16 +974,16 @@
     <string name="no_application_found">No se ha encontrado aplicación</string>
     <string name="invite_to_app">Invitar a Conversations</string>
     <string name="unable_to_parse_invite">No se ha podido leer la invitación</string>
-    <string name="server_does_not_support_easy_onboarding_invites">El servidor no soporta la creación de invitaciones</string>
-    <string name="no_active_accounts_support_this">Ninguna cuenta activa soporta esta característica</string>
+    <string name="server_does_not_support_easy_onboarding_invites">El servidor no admite la generación de invitaciones</string>
+    <string name="no_active_accounts_support_this">Ninguna cuenta activa admite esta funcionalidad</string>
     <string name="backup_started_message">La copia de seguridad ha empezado. Recibirás una notificación cuando se haya completado.</string>
-    <string name="unable_to_enable_video">No se ha podido habilitar el vídeo.</string>
+    <string name="unable_to_enable_video">No se ha podido activar el vídeo.</string>
     <string name="plain_text_document">Documento de texto plano</string>
-    <string name="account_registrations_are_not_supported">Los registros de cuenta no están soportados</string>
+    <string name="account_registrations_are_not_supported">No se admiten las altas de cuentas</string>
     <string name="no_xmpp_adddress_found">Dirección XMPP no encontrada</string>
     <string name="account_status_temporary_auth_failure">Fallo temporal de autenticación</string>
     <string name="delete_avatar">Eliminar imagen de perfil</string>
-    <string name="audio_video_disabled_tor">Las llamadas están deshabilitadas cuando se usa Tor</string>
+    <string name="audio_video_disabled_tor">Las llamadas se desactivan cuando se usa Tor</string>
     <string name="switch_to_video">Cambiar a vídeo</string>
     <string name="reject_switch_to_video">Rechazar petición de cambiar a vídeo</string>
     <string name="unified_push_distributor">Distribuidor de UnifiedPush</string>
@@ -999,9 +997,9 @@
     <string name="decline">Rechazar</string>
     <string name="delete_from_server">Eliminar la cuenta del servidor</string>
     <string name="could_not_delete_account_from_server">No se pudo eliminar la cuenta del servidor</string>
-    <string name="group_chats">Chats en grupo</string>
+    <string name="group_chats">Conversaciones grupales</string>
     <string name="search_group_chats">Buscar un grupo de chats</string>
-    <string name="restore_warning_continued">¡No intentes restaurar las copias de seguridad que no creaste tu mismo!</string>
+    <string name="restore_warning_continued">Restaura solo las copias de seguridad que hayas creado personalmente.</string>
     <string name="outdated_backup_file_format">Estás intentando importar un formato de copia de seguridad obsoleto</string>
     <string name="audiobook">Audiolibro</string>
     <string name="reconnect_on_other_host">Reconectarse a otros hosts</string>
@@ -1011,12 +1009,12 @@
     <string name="contact_uses_unverified_keys">Tu contacto utiliza dispositivos no verificados. Escanea su código QR para realizar la verificación e impedir ataques MITM activos.</string>
     <string name="log_out">Desconectarse</string>
     <string name="account_state_logged_out">Desconectado</string>
-    <string name="unverified_devices">Estás utilizando dispositivos no verificados. Escanea el código QR de tus otros dispositivos para realizar la verificación e impedir ataques MITM activos.</string>
+    <string name="unverified_devices">Está utilizando dispositivos no verificados. Escanee el código QR en sus otros dispositivos para verificarlos e impedir ataques MITM activos.</string>
     <string name="report_spam_and_block">Informar de spam y bloquear al spammer</string>
     <string name="report_spam">Informar sobre spam</string>
     <string name="welcome_header_quicksy">¡Bienvenido a Quicksy!</string>
     <string name="quicksy_wants_your_consent">Quicksy pide tu consentimiento para utilizar tus datos</string>
-    <string name="privacy_policy">Política de privacidad</string>
+    <string name="privacy_policy">Normativa de privacidad</string>
     <string name="contact_list_integration_not_available">La lista de contactos no está disponible</string>
     <string name="no_permission_to_place_call">Sin permiso para llamar por teléfono</string>
     <string name="rtp_state_contact_offline">Contacto no disponible</string>
@@ -1044,11 +1042,11 @@
     <string name="pref_up_long_summary">Al actuar como un Distribuidor de UnifiedPush la conexión XMPP persistente, fiable y de bajo consumo de batería se utilizará para despertar a otras aplicaciones compatibles con UnifiedPush como Tusky, Ltt.rs, FluffyChat y más.</string>
     <string name="send_encrypted_message">Enviar mensaje cifrado</string>
     <string name="pref_title_interface">Interfaz</string>
-    <string name="pref_summary_appearance">Tema, Colores, Capturas de pantalla, Entrada</string>
+    <string name="pref_summary_appearance">Tema, colores, capturas de pantalla, entrada</string>
     <string name="pref_title_security">Seguridad</string>
     <string name="unified_push_summary">Relé de notificaciones para aplicaciones de terceros compatibles con UnifiedPush</string>
     <string name="notifications">Notificaciones</string>
-    <string name="pref_notifications_summary">Período de gracia, Tono de llamada, Vibración, Extraños</string>
+    <string name="pref_notifications_summary">Período de gracia, tono de llamada, vibración, extraños</string>
     <string name="pref_category_sending">Enviando</string>
     <string name="pref_category_receiving">Recibiendo</string>
     <string name="pref_automatic_download">Descarga automática</string>
@@ -1073,19 +1071,19 @@
     <string name="start_chat">Iniciar un chat</string>
     <string name="channel_discover_opt_in_message">El descubrimiento de canales utiliza un servicio de terceros llamado &lt;a href=https://search.jabber.network&gt;search.jabber.network&lt;/a&gt;.&lt;br&gt;&lt;br&gt;Usar esta función transmitirá tu dirección IP y términos de búsqueda a ese servicio. Consulte su &lt;a href=https://search.jabber.network/privacy&gt;Política de privacidad&lt;/a&gt; para obtener más información.</string>
     <string name="no_certificate_selected">¡No se ha seleccionado ningún certificado de cliente!</string>
-    <string name="pref_attachments_summary">Tamaño de archivo, Compresión de imagen, Calidad de vídeo</string>
+    <string name="pref_attachments_summary">Tamaño de archivo, compresión de imagen, calidad de vídeo</string>
     <string name="pref_allow_screenshots_summary">Mostrar el contenido de la aplicación en el conmutador de aplicaciones y permitir la realización de capturas de pantalla</string>
     <string name="pref_accept_invites_from_strangers">Invitaciones de extraños</string>
     <string name="pref_accept_invites_from_strangers_summary">Aceptar invitaciones a chats grupales de extraños</string>
     <string name="pref_backup_summary">Crear una sola vez, Programar recurrentes</string>
     <string name="pref_create_backup_one_off_summary">Crear una copia de seguridad única</string>
     <string name="pref_backup_recurring">Copia de seguridad periódica</string>
-    <string name="unsupported_operation">Operación no soportada</string>
+    <string name="unsupported_operation">Operación no admitida</string>
     <string name="pref_fullscreen_notification">Notificaciones a pantalla completa</string>
     <string name="pref_fullscreen_notification_summary">Permite que esta aplicación muestre notificaciones de llamadas entrantes que ocupan toda la pantalla cuando el dispositivo está bloqueado.</string>
     <string name="allow_private_messages">Permitir mensajes privados</string>
     <string name="more_reactions">Más reacciones</string>
-    <string name="your_avatar_tap_to_select_new_avatar">Tu avatar. Toca para seleccionar un nuevo avatar de la galería.</string>
+    <string name="your_avatar_tap_to_select_new_avatar">Su avatar. Toque para seleccionar un avatar nuevo de la galería.</string>
     <string name="server_info_bind2">XEP-0386: Vinculación 2</string>
     <string name="server_info_sasl2">XEP-0388: Perfil SASL Extensible</string>
     <string name="could_not_disable_video">No se pudo desactivar el video.</string>
@@ -1096,32 +1094,37 @@
     <string name="edit_name_and_topic">Editar nombre y tema</string>
     <string name="edit_configuration">Cambiar configuración</string>
     <string name="change_notification_settings">Cambiar la configuración de notificaciones</string>
-    <string name="call_is_using_earpiece_tap_to_switch_to_speaker">La llamada está utilizando el auricular. Toca para cambiar al altavoz.</string>
+    <string name="call_is_using_earpiece_tap_to_switch_to_speaker">La llamada está utilizando el auricular. Toque para cambiar al altavoz.</string>
     <string name="call_is_using_earpiece">La llamada está utilizando el auricular.</string>
     <string name="call_is_using_wired_headset">La llamada está utilizando auriculares con cable</string>
-    <string name="call_is_using_speaker_tap_to_switch_to_earpiece">La llamada está utilizando altavoz. Toca para cambiar a auricular.</string>
+    <string name="call_is_using_speaker_tap_to_switch_to_earpiece">La llamada está utilizando el altavoz. Toque para cambiar al auricular.</string>
     <string name="call_is_using_speaker">La llamada está usando el altavoz.</string>
-    <string name="call_is_using_bluetooth">La llamada está usando bluetooth.</string>
+    <string name="call_is_using_bluetooth">La llamada está usando Bluetooth.</string>
     <string name="video_is_disabled_tap_to_enable">Video desactivado. Toca para activar.</string>
     <string name="server_info_login_mechanism">Método de acceso</string>
-    <string name="could_not_add_reaction">No se pudo agregar la reacción</string>
-    <string name="add_reaction">Agregar reacción…</string>
-    <string name="add_reaction_title">Agregar reacción</string>
-    <string name="clients_may_not_support_av">El cliente XMPP de tu contacto puede que no soporte llamadas de audio/video.</string>
+    <string name="could_not_add_reaction">No se pudo añadir la reacción</string>
+    <string name="add_reaction">Añadir reacción…</string>
+    <string name="add_reaction_title">Añadir reacción</string>
+    <string name="clients_may_not_support_av">El cliente XMPP de su contacto puede que no admita llamadas de audio/vídeo.</string>
     <string name="could_not_modify_call">No se pudo modificar la llamada</string>
     <string name="pref_chat_bubbles">Burbujas de chat</string>
     <string name="pref_chat_bubbles_summary">Color, Tamaño de fuente, Imágenes de perfil</string>
     <string name="pref_title_bubbles">Burbujas de Chat</string>
     <string name="pref_call_integration">Integración de llamadas</string>
-    <string name="pref_show_avatars">Ver imágenes de perfil</string>
+    <string name="pref_show_avatars">Mostrar avatares</string>
     <string name="pref_show_avatars_summary">Mostrar imágenes de perfil para tus mensajes y conversaciones 1:1, aparte de las conversaciones en grupo.</string>
     <string name="pref_call_integration_summary">Las llamadas desde esta app interactúan con las llamadas telefónicas regulares, como ser finalizar una llamada cuando recibimos otra.</string>
     <string name="pref_align_start">Mensajes alineados a la izquierda</string>
     <string name="pref_align_start_summary">Mostrar todos los mensajes, incluso los propios, sobre el margen izquierdo para una distribución uniforme del chat.</string>
     <string name="custom_notifications">Notificaciones personalizadas</string>
-    <string name="custom_notifications_enable">¿Habilitar los ajustes de notificaciones personalizadas (importancia, sonido, vibración) para esta conversación?</string>
+    <string name="custom_notifications_enable">¿Quiere activar la configuración de notificaciones personalizadas (importancia, sonido, vibración) para esta conversación?</string>
     <string name="delete_avatar_message">¿Quieres eliminar tu imagen de perfil? Algunos clientes podrían seguir mostrando una copia en caché de tu avatar.</string>
     <string name="show_to_contacts_only">Mostrar sólo a contactos</string>
     <string name="account_status_connection_timeout">Se agotó el tiempo de espera de la conexión</string>
     <string name="retry_with_p2p">Reintentar con P2P</string>
-</resources>
+    <string name="word_document">documento de Word</string>
+    <string name="non_quicksy_backup">Quicksy solo puede restaurar las copias de seguridad de las cuentas quicksy.im</string>
+    <string name="pref_backup_location">Ubicación de la copia de seguridad</string>
+    <string name="restore_omemo_key">Restaurar claves OMEMO</string>
+    <string name="account_status_channel_binding">Canal no disponible</string>
+</resources>

src/main/res/values-et/strings.xml 🔗

@@ -341,7 +341,7 @@
     <string name="group_chat_members">Osalejad</string>
     <string name="only_the_owner_can_change_group_chat_avatar">Vaid omanik võib muuta vestlusrühma profiilipilte</string>
     <string name="contact_name">Kontakti nimi</string>
-    <string name="set_profile_picture">Conversationsi profiilipilt</string>
+    <string name="set_profile_picture">Tunnuspilt</string>
     <string name="enter_password_to_restore">Varukoopiast taastamiseks sisesta %s kasutajakonto salasõna.</string>
     <string name="enter_your_name_instructions">Selleks, et need, kellel pole sind oma aadressiraamatus, saaksid teada, kes sa oled, siis palun lisa oma nimi.</string>
     <string name="group_chat_will_make_your_jabber_id_public">See kanal teeb sinu XMPP-aadressi avalikuks</string>
@@ -441,7 +441,7 @@
     <string name="pref_keep_foreground_service">Teenus esiplaanil</string>
     <string name="pref_keep_foreground_service_summary">See eelistus takistab operatsioonisüsteemil sinu võrguühenduse sulgemist</string>
     <string name="pref_create_backup">Tee varukoopia</string>
-    <string name="pref_create_backup_summary">Varukoopia failide salvestamise asukoht: %s</string>
+    <string name="pref_create_backup_summary">Varukoopiate salvestamise asukoht: %s</string>
     <string name="notification_backup_created_title">Sinu varukoopia on tehtud</string>
     <string name="notification_create_backup_title">Teeme varukoopiat</string>
     <string name="notification_backup_created_subtitle">Varukoopia failid on salvestatud siin kaustas: %s</string>
@@ -889,8 +889,8 @@
     <string name="try_again_in_x">Palun oota %s ja proovi uuesti</string>
     <string name="rate_limited">Sinu tehtavatele päringutele kehtib hetkel ajaühikuline piirang</string>
     <string name="too_many_attempts">Liiga palju päringuid</string>
-    <string name="restore_warning">Ära kasuta varukoopiat olemasoleva paigalduse kloonimiseks (samaaegseks käivitamiseks). Varukoopiast taastamine on mõeldud vaid teise seadmesse kolimise jaoks ning juhuks, kui kaotad oma algse nutiseadme.</string>
-    <string name="restore_warning_continued">Palun ära kasuta varukoopiaid, mida sa pole ise teinud!</string>
+    <string name="restore_warning">Ära kasuta OMEMO võtmete taastamist olemasoleva paigalduse kloonimiseks (samaaegseks käivitamiseks). OMEMO võtmete taastamine on mõeldud vaid teise seadmesse kolimise jaoks ning juhuks, kui kaotad oma algse nutiseadme.</string>
+    <string name="restore_warning_continued">Palun taasta varukoopiatest, mille sa oled ise teinud.</string>
     <string name="unable_to_restore_backup">Varukoopiast taastamine ei õnnestunud.</string>
     <string name="local_server">Kohalik server</string>
     <string name="pref_channel_discovery_summary">Enamus kasutajaid peaksid eelistama valikut „jabber.network“. See tagab asjakohasemad soovitused kogu avalikust XMPP võrgustikust.</string>
@@ -1131,4 +1131,16 @@
     <string name="account_status_connection_timeout">Ühenduse on aegunud</string>
     <string name="retry_with_p2p">Proovi uuesti võrdõigusvõrguga</string>
     <string name="account_status_channel_binding">Edastuskanaliga sidumine pole võimalik</string>
-</resources>
+    <string name="word_document">Wordi-dokument</string>
+    <string name="restore_omemo_key">Taasta OMEMO võtmed</string>
+    <string name="non_quicksy_backup">Quicksy saab taastada vaid quicksy.im teenuses asuvate kasutajakontode varukoopiaid</string>
+    <string name="pref_backup_location">Varukoopia asukoht</string>
+    <string name="uri">URI</string>
+    <string name="copy_URI">Kopeeri URI</string>
+    <string name="copy_geo_uri">Kopeeri geograafilised koordinaadid</string>
+    <string name="copy_email_address">Kopeeri e-posti aadress</string>
+    <string name="copied_email_address">Kopeerisime e-posti aadressi lõikelauale</string>
+    <string name="uri_copied_to_clipboard">Kopeerisime URI lõikelauale</string>
+    <string name="copy_telephone_number">Kopeeri telefoninumber</string>
+    <string name="copied_phone_number">Kopeerisime telefoninumbri lõikelauale</string>
+</resources>

src/main/res/values-eu/strings.xml 🔗

@@ -732,4 +732,4 @@
         <item quantity="one">Parte-hartzaile %1$d ikusi</item>
         <item quantity="other">%1$d parte-hartzaile ikusi</item>
     </plurals>
-</resources>
+</resources>

src/main/res/values-fa-rIR/strings.xml 🔗

@@ -1027,4 +1027,4 @@
     <string name="quicksy_wants_your_consent">این برنامه برای به‌کاربردن داده‌های شما نیازمند موافقت شماست</string>
     <string name="call_integration_not_available">یکپارچه‌سازی تماس تلفنی در دسترس نیست!</string>
     <string name="no_permission_to_place_call">اجازهٔ تماس تلفنی وجود ندارد</string>
-</resources>
+</resources>

src/main/res/values-fr/strings.xml 🔗

@@ -66,10 +66,10 @@
     <string name="crash_report_title">%1$s a planté</string>
     <string name="crash_report_message">En utilisant votre compte XMPP pour envoyer des rapports de crash, vous aidez le développement de %1$s.</string>
     <string name="send_now">Envoyer</string>
-    <string name="send_never">Ne plus me demander</string>
-    <string name="problem_connecting_to_account">Impossible de se connecter au compte.</string>
-    <string name="problem_connecting_to_accounts">Impossible de se connecter aux comptes.</string>
-    <string name="touch_to_fix">Appuyez pour gérer vos comptes.</string>
+    <string name="send_never">Ne plus demander</string>
+    <string name="problem_connecting_to_account">Impossible de se connecter au compte</string>
+    <string name="problem_connecting_to_accounts">Impossible de se connecter à plusieurs comptes</string>
+    <string name="touch_to_fix">Appuyez pour gérer vos comptes</string>
     <string name="attach_file">Joindre un fichier</string>
     <string name="not_in_roster">Ajouter ce contact manquant à votre liste de contact ?</string>
     <string name="add_contact">Ajouter un contact</string>
@@ -87,15 +87,15 @@
 \n
 \n<b>Avertissement :</b> Cela ne supprimera pas les copies de ce fichier qui sont stockées sur d\'autres appareils ou serveurs. </string>
     <string name="choose_presence">Choisir l\'appareil</string>
-    <string name="send_unencrypted_message">Envoyer un message en clair</string>
+    <string name="send_unencrypted_message">Envoyer un message non-chiffré</string>
     <string name="send_message">Envoyer le message</string>
     <string name="send_message_to_x">Envoyer un message à %s</string>
-    <string name="send_omemo_x509_message">Envoyer un message chiffré avec \\OMEMO</string>
-    <string name="your_nick_has_been_changed">Votre identifiant a été changé</string>
-    <string name="send_unencrypted">Envoyer en clair</string>
-    <string name="decryption_failed">Échec du déchiffrement. Avez-vous la bonne clé privée ?</string>
+    <string name="send_omemo_x509_message">Envoyer un message chiffré avec v\\OMEMO</string>
+    <string name="your_nick_has_been_changed">Votre pseudo a été changé</string>
+    <string name="send_unencrypted">Envoyer non-chiffré</string>
+    <string name="decryption_failed">Échec du déchiffrement. Peut-être que vous n\'avez pas la bonne clé privée.</string>
     <string name="openkeychain_required">OpenKeychain</string>
-    <string name="openkeychain_required_long">%1$s utilise &lt;b&gt;OpenKeychain&lt;/b&gt; pour chiffrer et déchiffrer les messages et pour gérer vos clés publiques.&lt;br&gt;&lt;br&gt;OpenKeychain est sous licence GPLv3 et est disponible sur F-Droid et Google Play.&lt;br&gt;&lt;br&gt;&lt;small&gt;(Veuillez redémarrer %1$s après l\'installation de l\'application.)&lt;/small&gt;</string>
+    <string name="openkeychain_required_long"><![CDATA[ %1$s utilise <b>OpenKeychain</b> pour chiffrer et déchiffrer les messages et pour gérer vos clés publiques.<br><br>OpenKeychain est sous licence GPLv3 et est disponible sur F-Droid et Google Play.<br><br><small>(Veuillez redémarrer %1$s après l\'installation de l\'application.)</small>]]></string>
     <string name="restart">Redémarrer</string>
     <string name="install">Installer</string>
     <string name="openkeychain_not_installed">Veuillez installer OpenKeychain</string>
@@ -132,9 +132,9 @@
     <string name="error">Une erreur s\'est produite</string>
     <string name="recording_error">Erreur</string>
     <string name="your_account">Votre compte</string>
-    <string name="send_presence_updates">Envoyer mes màj de disponibilité</string>
-    <string name="receive_presence_updates">Recevoir ses màj de disponibilité</string>
-    <string name="ask_for_presence_updates">Demander les màj de disponibilité</string>
+    <string name="send_presence_updates">Envoyer mes màj de statut</string>
+    <string name="receive_presence_updates">Recevoir ses màj de statut</string>
+    <string name="ask_for_presence_updates">Demander les màj de statut</string>
     <string name="attach_choose_picture">Choisir une image</string>
     <string name="attach_take_picture">Prendre une photo</string>
     <string name="preemptively_grant">Accepter par avance les demandes de publication</string>
@@ -169,14 +169,14 @@
     <string name="encryption_choice_otr">OTR</string>
     <string name="encryption_choice_pgp">OpenPGP</string>
     <string name="encryption_choice_omemo">OMEMO</string>
-    <string name="mgmt_account_delete">Supprimer</string>
+    <string name="mgmt_account_delete">Supprimer le compte</string>
     <string name="mgmt_account_disable">Désactiver temporairement</string>
     <string name="mgmt_account_publish_avatar">Publier une image de profil</string>
     <string name="mgmt_account_publish_pgp">Publier la clé publique OpenPGP</string>
     <string name="unpublish_pgp">Supprimer la clé publique OpenPGP</string>
     <string name="unpublish_pgp_message">Êtes-vous sûr de vouloir supprimer votre clé publique OpenPGP de votre annonce de présence ?\nVos contacts ne pourront plus vous envoyer de message chiffrés avec OpenPGP.</string>
     <string name="openpgp_has_been_published">Clé publique OpenPGP publiée.</string>
-    <string name="mgmt_account_enable">Activer le compter</string>
+    <string name="mgmt_account_enable">Activer le compte</string>
     <string name="mgmt_account_delete_confirm_text">Êtes-vous sûr de vouloir supprimer votre compte ? Supprimer votre compte effacera l\'historique de vos conversations</string>
     <string name="attach_record_voice">Enregistrer un son</string>
     <string name="account_settings_jabber_id">Adresse XMPP</string>
@@ -191,10 +191,10 @@
     <string name="server_info_carbon_messages">XEP-0280 : Copies carbone</string>
     <string name="server_info_csi">XEP-0352 : Indication statut client</string>
     <string name="server_info_blocking">XEP-0191 : Commande de blocage</string>
-    <string name="server_info_roster_version">XEP-0237 : Révision contacts</string>
+    <string name="server_info_roster_version">XEP-0237 : Versioning des contacts</string>
     <string name="server_info_stream_management">XEP-0198 : Gestion des flux</string>
     <string name="server_info_external_service_discovery">XEP-0215 : Découverte de service externe</string>
-    <string name="server_info_pep">XEP-0163 : PEP (Avatars / OMEMO)</string>
+    <string name="server_info_pep">XEP-0163 : PEP (Image de profil / OMEMO)</string>
     <string name="server_info_http_upload">XEP-0363 : Envoi de fichiers via HTTP</string>
     <string name="server_info_push">XEP-0357 : Notifications Push</string>
     <string name="server_info_available">supportée</string>
@@ -224,11 +224,11 @@
     <string name="view_contact_details">Afficher les détails du contact</string>
     <string name="block_contact">Bloquer le contact</string>
     <string name="unblock_contact">Débloquer le contact</string>
-    <string name="create">Ajouter</string>
+    <string name="create">Créer</string>
     <string name="select">Sélectionner</string>
     <string name="contact_already_exists">Le contact existe déjà</string>
     <string name="join">Rejoindre</string>
-    <string name="channel_full_jid_example">salon@conference.example.com/surnom</string>
+    <string name="channel_full_jid_example">salon@conference.example.com/pseudo</string>
     <string name="channel_bare_jid_example">salon@conference.example.com</string>
     <string name="save_as_bookmark">Enregistrer comme favori</string>
     <string name="delete_bookmark">Supprimer le favori</string>
@@ -244,14 +244,14 @@
     <string name="topic">Sujet</string>
     <string name="joining_conference">Rejoindre le groupe…</string>
     <string name="leave">Partir</string>
-    <string name="contact_added_you">Votre correspondant vous a ajouté dans sa liste de contacts</string>
+    <string name="contact_added_you">Votre correspondant·e vous a ajouté dans sa liste de contacts</string>
     <string name="add_back">Ajouter en retour</string>
     <string name="contact_has_read_up_to_this_point">%s a tout lu jusqu\'ici</string>
     <string name="contacts_have_read_up_to_this_point">%s ont tout lu jusqu\'ici</string>
-    <string name="contacts_and_n_more_have_read_up_to_this_point">%1$s+%2$d autres ont tout lu jusqu\'ici</string>
+    <string name="contacts_and_n_more_have_read_up_to_this_point">%1$s +%2$d autres ont tout lu jusqu\'ici</string>
     <string name="everyone_has_read_up_to_this_point">Tout le monde a lu jusqu\'ici</string>
     <string name="publish">Publier</string>
-    <string name="touch_to_choose_picture">Appuyer sur l\'image de profil pour choisir une image depuis la galerie</string>
+    <string name="touch_to_choose_picture">Appuyer sur l\'image de profil pour en choisir une depuis la galerie</string>
     <string name="publishing">Mise à jour…</string>
     <string name="error_publish_avatar_server_reject">Le serveur a rejeté votre publication</string>
     <string name="error_publish_avatar_converting">Impossible de convertir votre image</string>
@@ -304,7 +304,7 @@
     <string name="message_options">Options du message</string>
     <string name="quote">Citation</string>
     <string name="paste_as_quote">Coller en tant que citation</string>
-    <string name="copy_original_url">Copier l\'URL</string>
+    <string name="copy_original_url">Copier l\'URL originale</string>
     <string name="send_again">Envoyer de nouveau</string>
     <string name="file_url">URL du fichier</string>
     <string name="url_copied_to_clipboard">URL copiée dans le presse-papier</string>
@@ -361,7 +361,7 @@
     <string name="updating">Mise à jour…</string>
     <string name="password_changed">Mot de passe modifié !</string>
     <string name="could_not_change_password">Impossible de changer le mot de passe</string>
-    <string name="change_password">Changer de mot de passe</string>
+    <string name="change_password">Changer le mot de passe</string>
     <string name="current_password">Mot de passe actuel</string>
     <string name="new_password">Nouveau mot de passe</string>
     <string name="password_should_not_be_empty">Le mot de passe ne peut pas être vide</string>
@@ -370,7 +370,7 @@
     <string name="perform_action_with">Faire une action avec</string>
     <string name="no_affiliation">Aucune affiliation</string>
     <string name="no_role">Hors ligne</string>
-    <string name="outcast">Banni</string>
+    <string name="outcast">Banni·e</string>
     <string name="member">Membre</string>
     <string name="advanced_mode">Mode avancé</string>
     <string name="grant_membership">Accorder des privilèges aux membres</string>
@@ -404,7 +404,7 @@
     <string name="pref_enter_is_send">Touche Entrée pour envoyer</string>
     <string name="pref_enter_is_send_summary">Utilisez la touche Entrée pour envoyer un message. Vous pourrez toujours utiliser la combinaison Ctrl+Entrée pour envoyer un message, même si cette option est désactivée.</string>
     <string name="pref_display_enter_key">Afficher la touche Entrée</string>
-    <string name="pref_display_enter_key_summary">Remplacer la touche Émoticônes par la touche Entrée</string>
+    <string name="pref_display_enter_key_summary">Remplacer la touche Emoji par la touche Entrée</string>
     <string name="audio">audio</string>
     <string name="video">vidéo</string>
     <string name="image">image</string>
@@ -427,7 +427,7 @@
     <string name="location">Position</string>
     <string name="title_undo_swipe_out_group_chat">Quitter le groupe privé</string>
     <string name="title_undo_swipe_out_channel">Quitte le salon public</string>
-    <string name="pref_dont_trust_system_cas_title">Ne pas utiliser les CAs système</string>
+    <string name="pref_dont_trust_system_cas_title">Ne pas utiliser les AC du système</string>
     <string name="pref_dont_trust_system_cas_summary">Tous les certificats doivent être approuvés manuellement</string>
     <string name="pref_remove_trusted_certificates_title">Retirer les certificats</string>
     <string name="pref_remove_trusted_certificates_summary">Supprimer les certificats approuvés manuellement</string>
@@ -457,7 +457,7 @@
     <string name="download_failed_invalid_file">Échec du téléchargement : Fichier non valide</string>
     <string name="account_status_tor_unavailable">Réseau Tor inaccessible</string>
     <string name="account_status_bind_failure">La liaison a échoué</string>
-    <string name="account_status_host_unknown">Le serveur n\'est pas responsable pour ce domaine</string>
+    <string name="account_status_host_unknown">Pas responsable pour le domaine</string>
     <string name="server_info_broken">Détraqué</string>
     <string name="pref_presence_settings">Disponibilité</string>
     <string name="pref_away_when_screen_off">Absent quand l\'appareil est verrouillé</string>
@@ -772,7 +772,7 @@
     <string name="phone_number">Numéro de téléphone</string>
     <string name="verify_your_phone_number">Vérifier votre numéro de téléphone</string>
     <string name="enter_country_code_and_phone_number">Quicksy va envoyer un message SMS (des frais opérateurs sont possibles) pour vérifier votre numéro de téléphone. Saisissez votre code de pays et votre numéro de téléphone :</string>
-    <string name="we_will_be_verifying"><![CDATA[Nous vérifierons le numéro de téléphone<br/><br/><b>%s</b><br/><br/>. Est-ce correct ou souhaitez-vous modifier le numéro ?]]></string>
+    <string name="we_will_be_verifying"><![CDATA[Nous vérifierons le numéro de téléphone<br/><br/><b>%s</b><br/><br/>. Est-ce correct ou souhaitez-vous modifier le numéro ?]]></string>
     <string name="not_a_valid_phone_number">%s n\'est pas un numéro de téléphone valide.</string>
     <string name="please_enter_your_phone_number">Veuillez saisir votre numéro de téléphone.</string>
     <string name="search_countries">Recherche de pays</string>
@@ -877,7 +877,7 @@
     <string name="unable_to_perform_this_action">Impossible de réaliser cette action</string>
     <string name="open_join_dialog">Rejoindre le salon public…</string>
     <string name="sharing_application_not_grant_permission">L\'application de partage n\'a pas accordé la permission d\'accéder à ce fichier.</string>
-    <string name="group_chats_and_channels">Salons et groupes de discussion</string>
+    <string name="group_chats_and_channels"><![CDATA[Group Chats & Channels]]></string>
     <string name="jabber_network">jabber.network</string>
     <string name="local_server">Serveur local</string>
     <string name="pref_channel_discovery_summary">La plupart des utilisateur·ices devraient choisir « jabber.network » pour de meilleures suggestions provenant de l’écosystème public entier de XMPP.</string>
@@ -1099,7 +1099,7 @@
     <string name="change_notification_settings">Modifier les paramètres de notification</string>
     <string name="call_is_using_earpiece_tap_to_switch_to_speaker">L\'appel passe par les écouteurs. Tapotez pour passer sur haut-parleur.</string>
     <string name="call_is_using_earpiece">L\'appel passe par les écouteurs.</string>
-    <string name="server_info_bind2">XEP-0386 : Bind 2</string>
+    <string name="server_info_bind2">XEP-0386: Bind 2</string>
     <string name="edit_nick">Éditer le pseudo</string>
     <string name="delete_pgp_key">Supprimer la clé OpenPGP</string>
     <string name="call_is_using_bluetooth">L\'appel passe par le bluetooth.</string>
@@ -1110,4 +1110,27 @@
     <string name="call_is_using_speaker_tap_to_switch_to_earpiece">L\'appel passe par le haut-parleur. Tapotez pour passer sur les écouteurs.</string>
     <string name="call_is_using_speaker">L\'appel passe par le haut-parleur.</string>
     <string name="server_info_login_mechanism">Mécanisme de connexion</string>
+    <string name="account_status_connection_timeout">Délai d\'attente de connexion dépassé</string>
+    <string name="add_reaction">Ajouter une réaction…</string>
+    <string name="pref_chat_bubbles_summary">Couleur de fond, taille de police, images de profil</string>
+    <string name="word_document">Document Word</string>
+    <string name="pref_show_avatars_summary">Montrer les images de profil pour vos messages et dans les discussions un-à-un, en plus des groupes de discussion.</string>
+    <string name="delete_avatar_message">Voulez-vous supprimer votre image de profil ? Certains clients pourraient continuer d\'en afficher une version mise en cache.</string>
+    <string name="retry_with_p2p">Réessayer avec P2P</string>
+    <string name="pref_chat_bubbles">Bulles de discussion</string>
+    <string name="show_to_contacts_only">Montrer aux contacts uniquement</string>
+    <string name="pref_title_bubbles">Bulles de discussion</string>
+    <string name="could_not_modify_call">Impossible de modifier l\'appel</string>
+    <string name="account_status_channel_binding">Channel binding indisponible</string>
+    <string name="clients_may_not_support_av">Le client XMPP de votre contact peut ne pas prendre en charge les appels audio/vidéo.</string>
+    <string name="could_not_add_reaction">Impossible d\'ajouter une réaction</string>
+    <string name="pref_call_integration">Intégration d\'appel</string>
+    <string name="pref_align_start">Messages alignés à gauche</string>
+    <string name="pref_align_start_summary">Afficher tous les messages, y compris ceux envoyés, à gauche pour un rendu uniforme.</string>
+    <string name="custom_notifications">Notifications personnalisées</string>
+    <string name="custom_notifications_enable">Activer les paramètres de notifications personnalisées (importance, son, vibration) pour cette discussion ?</string>
+    <string name="pref_call_integration_summary">Les appels de cette application intéragissent avec les appels téléphoniques normaux, comme en coupant un appel quand un autre commence.</string>
+    <string name="more_reactions">Plus de réactions</string>
+    <string name="add_reaction_title">Ajouter une réaction</string>
+    <string name="pref_show_avatars">Montrer l\'image de profil</string>
 </resources>

src/main/res/values-gl/strings.xml 🔗

@@ -321,7 +321,7 @@
     <string name="pref_keep_foreground_service">Servizo en primeiro plano</string>
     <string name="pref_keep_foreground_service_summary">Evita que o sistema operativo corte a conexión</string>
     <string name="pref_create_backup">Crear copia de apoio</string>
-    <string name="pref_create_backup_summary">Os ficheiros de copia gardaranse en %s</string>
+    <string name="pref_create_backup_summary">As copias vanse gardar en %s</string>
     <string name="notification_create_backup_title">Creando ficheiros de apoio</string>
     <string name="notification_backup_created_title">Creouse o ficheiro</string>
     <string name="notification_backup_created_subtitle">Os ficheiros de apoio gardáronse en %s</string>
@@ -819,12 +819,12 @@
     <string name="ebook">e-book</string>
     <string name="video_original">Orixinal (non comprimido)</string>
     <string name="open_with">Abrir con…</string>
-    <string name="set_profile_picture">Imaxe de perfil en Conversations</string>
+    <string name="set_profile_picture">Avatar</string>
     <string name="choose_account">Elixir conta</string>
     <string name="restore_backup">Restablecer copia de apoio</string>
     <string name="restore">Restablecer</string>
     <string name="enter_password_to_restore">Escribe o contrasinal da conta %s para restablecer a copia.</string>
-    <string name="restore_warning">Non utilices a función de restaurar a copia nun intento de clonar (utilizar simultaneamente) unha instalación. Restaurar unha copia só ten sentido para migrar ou en caso de perda do dispositivo orixinal.</string>
+    <string name="restore_warning">Non restaures as claves OMEMO nun intento de clonar (utilizar simultaneamente) unha instalación. Restaurar as claves OMEMO só ten sentido para migrar ou en caso de perda do dispositivo orixinal.</string>
     <string name="unable_to_restore_backup">Non se puido restaurar a copia.</string>
     <string name="unable_to_decrypt_backup">Non se puido descifrar a copia. É correcto o contrasinal?</string>
     <string name="backup_channel_name">Respaldar &amp; Restaurar</string>
@@ -987,7 +987,7 @@
     <string name="could_not_delete_account_from_server">Non se puido eliminar a conta no servidor</string>
     <string name="search_group_chats">Buscar parolas en grupo</string>
     <string name="group_chats">Parolas en grupo</string>
-    <string name="restore_warning_continued">Non intentes restablecer unha copia de apoio que non tiveses creado ti!</string>
+    <string name="restore_warning_continued">Restaura só copias de apoio que crearas ti persoalmente.</string>
     <string name="outdated_backup_file_format">Estás intentando importar un ficheiro de apoio co formato antigo</string>
     <string name="audiobook">Audiolibro</string>
     <string name="reconnect_on_other_host">Volver conectar noutro servidor</string>
@@ -1109,4 +1109,16 @@
     <string name="account_status_connection_timeout">Caducidade da conexión</string>
     <string name="retry_with_p2p">Reintentar con P2P</string>
     <string name="account_status_channel_binding">Non está dispoñible a vinculación de canles</string>
+    <string name="word_document">Documento de Word</string>
+    <string name="restore_omemo_key">Restaurar claves OMEMO</string>
+    <string name="non_quicksy_backup">Quicksy só pode restaurar copias de apoio de contas quicksy.im</string>
+    <string name="pref_backup_location">Localización das copias</string>
+    <string name="uri_copied_to_clipboard">URI copiado ao portapapeis</string>
+    <string name="uri">URI</string>
+    <string name="copy_URI">Copiar URI</string>
+    <string name="copy_geo_uri">Copiar xeolocalización</string>
+    <string name="copy_email_address">Copiar enderezo de correo</string>
+    <string name="copied_email_address">Copiouse o enderezo ao portapapeis</string>
+    <string name="copied_phone_number">Copiouse o número ao portapapeis</string>
+    <string name="copy_telephone_number">Copiar número de teléfono</string>
 </resources>

src/main/res/values-hu/strings.xml 🔗

@@ -1097,4 +1097,4 @@
     <string name="pref_chat_bubbles">Csevegőbuborékok</string>
     <string name="pref_chat_bubbles_summary">Háttérszín, betűméret, avatárok</string>
     <string name="pref_title_bubbles">Csevegőbuborékok</string>
-</resources>
+</resources>

src/main/res/values-id/strings.xml 🔗

@@ -472,4 +472,4 @@
     <string name="xmpp_address">alamat XMPP</string>
     <string name="creating_channel">Buat channel publik...</string>
     <string name="rtp_state_declined_or_busy">Sibuk</string>
-</resources>
+</resources>

src/main/res/values-it/strings.xml 🔗

@@ -322,7 +322,7 @@
     <string name="pref_keep_foreground_service">Servizio in primo piano</string>
     <string name="pref_keep_foreground_service_summary">Evita che il sistema operativo chiuda la connessione</string>
     <string name="pref_create_backup">Crea un backup</string>
-    <string name="pref_create_backup_summary">I file di backup verranno salvati in %s</string>
+    <string name="pref_create_backup_summary">I backup verranno salvati in %s</string>
     <string name="notification_create_backup_title">Creazione dei file di backup</string>
     <string name="notification_backup_created_title">Il tuo backup è stato creato</string>
     <string name="notification_backup_created_subtitle">I file di backup sono stati salvati in %s</string>
@@ -828,12 +828,12 @@
     <string name="ebook">e-book</string>
     <string name="video_original">Originale (non compresso)</string>
     <string name="open_with">Apri con…</string>
-    <string name="set_profile_picture">Immagine profilo di Conversations</string>
+    <string name="set_profile_picture">Avatar</string>
     <string name="choose_account">Scegli un profilo</string>
     <string name="restore_backup">Ripristina backup</string>
     <string name="restore">Ripristina</string>
     <string name="enter_password_to_restore">Inserisci la tua password per il profilo %s per ripristinare il backup.</string>
-    <string name="restore_warning">Non usare la funzione di ripristino del backup tentando di clonare (eseguire simultaneamente) un\'installazione. Il ripristino di un backup è inteso solo per migrazioni o in caso di smarrimento del dispositivo.</string>
+    <string name="restore_warning">Non ripristinare le chiavi OMEMO nel tentativo di clonare (eseguire simultaneamente) un\'installazione. Il ripristino delle chiavi OMEMO è inteso solo per migrazioni o in caso di smarrimento del dispositivo.</string>
     <string name="unable_to_restore_backup">Impossibile ripristinare il backup.</string>
     <string name="unable_to_decrypt_backup">Impossibile decifrare il backup. La password è giusta?</string>
     <string name="backup_channel_name">Backup e ripristino</string>
@@ -1001,7 +1001,7 @@
     <string name="could_not_delete_account_from_server">Impossibile eliminare il profilo dal server</string>
     <string name="group_chats">Chat di gruppo</string>
     <string name="search_group_chats">Cerca chat di gruppo</string>
-    <string name="restore_warning_continued">Non tentare di ripristinare dei backup che non hai creato te stesso!</string>
+    <string name="restore_warning_continued">Ripristina solo i backup che hai creato personalmente.</string>
     <string name="outdated_backup_file_format">Stai tentando di importare un formato di file di backup obsoleto</string>
     <string name="audiobook">Audiolibro</string>
     <string name="reconnect_on_other_host">Riconnetti su altro host</string>
@@ -1125,4 +1125,8 @@
     <string name="pref_chat_bubbles_summary">Colore di sfondo, dimensione caratteri, avatar</string>
     <string name="pref_title_bubbles">Messaggi di chat</string>
     <string name="account_status_channel_binding">Associazione dei canali non disponibile</string>
+    <string name="word_document">Documento Word</string>
+    <string name="restore_omemo_key">Ripristina chiavi OMEMO</string>
+    <string name="non_quicksy_backup">Quicksy può ripristinare backup solo per profili quicksy.im</string>
+    <string name="pref_backup_location">Percorso backup</string>
 </resources>

src/main/res/values-iw/strings.xml 🔗

@@ -52,7 +52,7 @@
     <string name="action_clear_history">נקה היסטוריה</string>
     <string name="clear_conversation_history">נקה היסטוריית שיחה</string>
     <string name="send_unencrypted_message">שלח הודעה בלתי מוצפנת</string>
-    <string name="send_unencrypted">שלח ללא הצפנה</string>
+    <string name="send_unencrypted">שלח טקסט נקי</string>
     <string name="decryption_failed">פענוח נכשל. אולי אין לך את המפתח הפרטי המתאים.</string>
     <string name="openkeychain_required">OpenKeychain</string>
     <string name="restart">התחל מחדש</string>
@@ -273,4 +273,104 @@
     <string name="presence_online">מקוון</string>
     <string name="message_copied_to_clipboard">ההודעה הועתקה</string>
     <string name="title_activity_show_location">הראה מיקום</string>
-</resources>
+    <string name="action_account">נהל חשבון</string>
+    <string name="action_muc_details">פרטי צ\'אט קבוצתי</string>
+    <string name="channel_details">פרטי הערוץ</string>
+    <string name="crash_report_title">%1$s קרס</string>
+    <string name="account_state_logged_out">התנתק</string>
+    <string name="account_status_policy_violation">הפרת מדיניות</string>
+    <string name="account_status_connection_timeout">פסק זמן התחברות</string>
+    <plurals name="x_unread_conversations">
+        <item quantity="one">%d צ\'אט שלא נקרא</item>
+        <item quantity="two">%d צ\'אטים שלא נקראו</item>
+        <item quantity="other">%d צ\'אטים שלא נקראו</item>
+    </plurals>
+    <string name="pref_send_crash_reports">שלח דיווחי קריסות</string>
+    <string name="clear_histor_msg">האם אתה רוצה למחוק את כל ההודעות בצ\'אט הזה?\n\n<b>אזהרה:</b> זה לא ישפיע על הודעות המאוחסנות במכשירים או שרתים אחרים.</string>
+    <string name="account_status_tls_error_domain">תחום לא חוקי</string>
+    <string name="account_status_stream_error">שגיאת זרימה</string>
+    <string name="remove_bookmark">האם ברצונך להסיר את הסימניה עבור %s?</string>
+    <string name="remove_bookmark_and_close">האם תרצה להסיר את הסימניה עבור %s ולהעביר את הצ\'אט לארכיון?</string>
+    <string name="invite_contact">הזמן איש קשר</string>
+    <string name="invite">הזמן</string>
+    <string name="crash_report_message">השימוש בחשבון XMPP שלך לשליחת באגים עוזר לפיתוח מתמשך של %1$s.</string>
+    <string name="title_activity_share_with">שתף עם…</string>
+    <string name="action_archive_chat">ארכיון צ\'אט</string>
+    <string name="action_add_phone_book">הוסף לפנקס הכתובות</string>
+    <string name="action_block_participant">חסום משתתף</string>
+    <string name="action_unblock_participant">בטל חסימת משתמש</string>
+    <string name="title_activity_choose_contacts">בחר אנשי קשר</string>
+    <string name="title_activity_share_via_account">שתף באמצעות חשבון</string>
+    <string name="title_activity_new_chat">צא\'ט חדש</string>
+    <string name="invalid_muc_nick">כינוי לא חוקי</string>
+    <string name="remove_contact_text">"האם תרצה להסיר את %s מרשימת אנשי הקשר שלך?  הצ\'אט עם איש הקשר הזה לא יוסר."</string>
+    <string name="title_activity_choose_contact">בחר איש קשר</string>
+    <string name="blocked">חסום</string>
+    <string name="problem_connecting_to_account">לא ניתן להתחבר לחשבון</string>
+    <string name="problem_connecting_to_accounts">לא ניתן להתחבר למספר חשבונות</string>
+    <string name="touch_to_fix">הקש כדי לנהל את החשבונות שלך</string>
+    <string name="not_in_roster">האם להוסיף את איש הקשר החסר הזה לרשימת אנשי הקשר שלך?</string>
+    <string name="preparing_image">מתכונן לשלוח תמונה</string>
+    <string name="preparing_images">מתכון לשלוח תמונות</string>
+    <string name="sharing_files_please_wait">משתף קבצים. נא להמתין…</string>
+    <string name="delete_file_dialog">מחק קובץ</string>
+    <string name="archive_this_chat">מחק את הצ\'אט לאחר מכן</string>
+    <string name="choose_presence">בחר מכשיר</string>
+    <string name="send_message">שלח הודעה</string>
+    <string name="send_message_to_x">שלח הודעה ל%s</string>
+    <string name="send_omemo_x509_message">שלח הודעה מוצפנת v\\OMEMO</string>
+    <string name="your_nick_has_been_changed">כינוי חדש בשימוש</string>
+    <string name="contacts_have_no_pgp_keys">לא ניתן היה להצפין את ההודעה שלך כי אנשי הקשר שלך לא מכריזים על המפתחות הציבוריים שלהם.\n\n<small>בקש מהם להגדיר את OpenPGP.</small></string>
+    <string name="send_encrypted_message">שלח הודעה מוצפנת</string>
+    <string name="contact_has_no_pgp_key">לא ניתן היה להצפין את ההודעה שלך כי איש הקשר שלך לא הכריז על המפתח הציבורי שלו.\n\n<small>אנא בקש מאיש הקשר שלך להגדיר את OpenPGP.</small></string>
+    <string name="pref_attachments">מצורפים</string>
+    <string name="pref_notification_settings">התראות</string>
+    <string name="pref_vibrate_summary">רטט כשמגיעה הודעה חדשה</string>
+    <string name="pref_notification_sound">התראות שמע</string>
+    <string name="pref_led">התראת LED</string>
+    <string name="pref_led_summary">נורית התראה מהבהבת כשמגיעה הודעה חדשה</string>
+    <string name="pref_ringtone">רינגטון</string>
+    <string name="pref_advanced_options">מתקדם</string>
+    <string name="pref_never_send_crash_summary">על ידי שליחת דיווחי קריסות אתה עוזר לפיתוח</string>
+    <string name="pref_confirm_messages_summary">אפשר לאנשי הקשר שלך לדעת כאשר קיבלת וקראת את ההודעות שלהם</string>
+    <string name="pref_prevent_screenshots">מניעת צילום מסך</string>
+    <string name="error_compressing_image">לא ניתן להמיר את התמונה</string>
+    <string name="error_security_exception">האפליקציה שבה השתמשת כדי לשתף את הקובץ הזה לא סיפקה מספיק הרשאות.</string>
+    <string name="pref_notification_sound_summary">צליל התראה עבור הודעות חדשות</string>
+    <string name="openpgp_error">OpenKeychain יצר שגיאה.</string>
+    <string name="pref_prevent_screenshots_summary">הסתר את תוכן האפליקציה במחליף האפליקציות וחסום צילומי מסך</string>
+    <string name="openkeychain_required_long"><![CDATA[%1$s משתמש ב-<b>OpenKeychain</b> כדי להצפין ולפענח הודעות ולנהל את המפתחות הציבוריים שלך.<br><br>הוא מורשה תחת GPLv3+ וזמין ב-F-Droid וב-Google Play.<br><br><small>(אנא הפעל מחדש את %1$s לאחר מכן.)</small>]]></string>
+    <string name="delete_file_dialog_msg">האם אתה בטוח שברצונך למחוק את הקובץ הזה?\n\n<b>אזהרה:</b> פעולה זו לא תמחק עותקים של קובץ זה המאוחסנים במכשירים או שרתים אחרים.</string>
+    <string name="pref_call_ringtone_summary">רינגטון לשיחות נכנסות</string>
+    <string name="pref_notification_grace_period">תקופת החסד</string>
+    <string name="account_status_regis_not_sup">הרישום אינו נתמך על ידי השרת</string>
+    <string name="account_status_incompatible_client">לקוח לא תואם</string>
+    <string name="account_status_regis_invalid_token">אסימון רישום לא חוקי</string>
+    <string name="account_status_tls_error">משא ומתן TLS נכשל</string>
+    <string name="account_status_stream_opening_error">שגיאת פתיחת זרם</string>
+    <string name="pref_notification_grace_period_summary">משך זמן השתקת התראות לאחר זיהוי פעילות באחד מהמכשירים האחרים שלך.</string>
+    <string name="pref_ui_options">UI</string>
+    <string name="bad_key_for_encryption">מפתח הצפנה שגוי.</string>
+    <string name="recording_error">שגיאה</string>
+    <string name="error_security_exception_during_image_copy">האפליקציה שבה השתמשת כדי לבחור תמונה זו לא סיפקה מספיק הרשאות לקרוא את הקובץ.\n\n<small>השתמש במנהל קבצים אחר כדי לבחור תמונה</small>.</string>
+    <string name="account_status_channel_binding">עטיפת ערוץ אינה זמינה</string>
+    <string name="unpublish_pgp">הסר את המפתח הציבורי של OpenPGP</string>
+    <string name="unpublish_pgp_message">האם אתה בטוח שברצונך להסיר את מפתח OpenPGP הציבורי שלך מהודעת הנוכחות שלך?\nאנשי הקשר שלך לא יוכלו יותר לשלוח לך הודעות מוצפנות OpenPGP.</string>
+    <string name="openpgp_has_been_published">מפתח ציבורי OpenPGP פורסם.</string>
+    <string name="mgmt_account_delete_confirm_text">האם אתה בטוח שברצונך למחוק את חשבונך? מחיקת החשבון שלך מוחקת את כל היסטוריית הצ\'אט שלך</string>
+    <string name="account_settings_jabber_id">כתובת XMPP</string>
+    <string name="add_phone_book_text">האם ברצונך להוסיף את %s לפנקס הכתובות שלך?</string>
+    <string name="block_jabber_id">חסום כתובת XMPP</string>
+    <string name="invalid_jid">זו אינה כתובת XMPP חוקית</string>
+    <string name="error_out_of_memory">נגמר הזיכרון. תמונה גדולה מדי</string>
+    <string name="server_info_external_service_discovery">XEP-0215: גילוי שירות חיצוני</string>
+    <string name="server_info_push">XEP-0357: דחיפה</string>
+    <string name="last_seen_min">נראה לאחרונה לפני דקה</string>
+    <string name="last_seen_hour">נראה לאחרונה לפני שעה</string>
+    <string name="last_seen_day">נראה לאחרונה לפני יום אחד</string>
+    <string name="install_openkeychain">הודעה מוצפנת. אנא התקן את OpenKeychain כדי לפענח אותו.</string>
+    <string name="openpgp_messages_found">נמצאו הודעות מוצפנות חדשות של OpenPGP</string>
+    <string name="openpgp_key_id">מזהה מפתח OpenPGP</string>
+    <string name="server_info_bind2">XEP-0386: כריכה 2</string>
+    <string name="server_info_sasl2">XEP-0388: פרופיל SASL הניתן להרחבה</string>
+</resources>

src/main/res/values-ja/strings.xml 🔗

@@ -242,7 +242,7 @@
     <string name="joining_conference">グループチャットに参加しています…</string>
     <string name="leave">退出</string>
     <string name="contact_added_you">連絡先があなたを連絡先リストに追加しました</string>
-    <string name="add_back">戻りを追加</string>
+    <string name="add_back">連絡先を追加</string>
     <string name="contact_has_read_up_to_this_point">%s はここまで読みました</string>
     <string name="contacts_have_read_up_to_this_point">%s はここまで読みました</string>
     <string name="contacts_and_n_more_have_read_up_to_this_point">%1$s +%2$d人がここまで読みました</string>
@@ -317,7 +317,7 @@
     <string name="pref_keep_foreground_service">フォアグラウンドサービス</string>
     <string name="pref_keep_foreground_service_summary">OSが接続を切断するのを防止します</string>
     <string name="pref_create_backup">バックアップを作成</string>
-    <string name="pref_create_backup_summary">バックアップファイルは %s に保存されます </string>
+    <string name="pref_create_backup_summary">バックアップファイルは %s に保存されます</string>
     <string name="notification_create_backup_title">バックアップファイルを作成しています</string>
     <string name="notification_backup_created_title">バックアップを作成しました</string>
     <string name="notification_backup_created_subtitle">バックアップファイルは %s に保存されました</string>
@@ -1090,4 +1090,8 @@
     <string name="pref_chat_bubbles_summary">背景色、文字サイズ、プロフィール画像など</string>
     <string name="pref_title_bubbles">ふきだし</string>
     <string name="account_status_connection_timeout">接続タイムアウト</string>
+    <string name="restore_omemo_key">OMEMO鍵を復元</string>
+    <string name="non_quicksy_backup">Quicksyはquicksy.imのアカウントのバックアップしか復元できません</string>
+    <string name="pref_backup_location">バックアップの保存先</string>
+    <string name="word_document">Word 文書</string>
 </resources>

src/main/res/values-kab/strings.xml 🔗

@@ -0,0 +1,234 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="hostname_example">xmpp.example.com</string>
+    <string name="continue_btn">Ddu</string>
+    <string name="done">Immed</string>
+    <string name="create">Snulfu-d</string>
+    <string name="openkeychain_required">OpenKeychain</string>
+    <string name="always">Yal tikkelt</string>
+    <string name="nickname">Meferisem</string>
+    <string name="pref_category_application">Asnas</string>
+    <string name="title_activity_manage_accounts">Asefrek n imiḍan</string>
+    <string name="file_url">URL n ufaylu</string>
+    <string name="updating">Aleqqem…</string>
+    <string name="disable">Ssens-it</string>
+    <string name="presence_online">Uqqin</string>
+    <string name="presence_dnd">Ur yestuffi ara</string>
+    <string name="action_settings">Iɣewwaṛen</string>
+    <string name="action_account">Sefrek amiḍan</string>
+    <string name="action_accounts">Sefrek imiḍan</string>
+    <string name="action_contact_details">Talqayt unermis</string>
+    <string name="action_add_account">Rnu amiḍan</string>
+    <string name="action_edit_contact">Snifel isem</string>
+    <string name="action_muc_details">Talqayt n wegraw n usqerdec</string>
+    <string name="title_activity_share_with">Bḍu akked …</string>
+    <string name="title_activity_choose_contact">Fren anermis</string>
+    <string name="title_activity_choose_contacts">Fren inermisen</string>
+    <string name="title_activity_new_chat">Asqerdec amaynut</string>
+    <string name="just_now">tura yakan</string>
+    <string name="title_activity_share_via_account">Bḍu-t s umiḍan</string>
+    <string name="sending">Tuzzna…</string>
+    <string name="admin">Anebdal</string>
+    <string name="owner">Bab-is</string>
+    <string name="moderator">Imḍebbar</string>
+    <string name="participant">Imttekki</string>
+    <string name="contact">Anermis</string>
+    <string name="cancel">Semmet</string>
+    <string name="add">Rnu</string>
+    <string name="edit">Ẓreg</string>
+    <string name="delete">Kkes</string>
+    <string name="block">Sewḥel</string>
+    <string name="save">Sekles</string>
+    <string name="ok">Ih</string>
+    <string name="share_with">Bḍu akked …</string>
+    <string name="invite_contact">Snubeg anermis</string>
+    <string name="send_now">Azen-it tura</string>
+    <string name="attach_file">Seddu afaylu</string>
+    <string name="add_contact">Rnu anermis</string>
+    <string name="action_clear_history">Sfeḍ amazray</string>
+    <string name="delete_file_dialog">Kkes afaylu</string>
+    <string name="choose_presence">Fren ibenk</string>
+    <string name="send_message">Azen izen</string>
+    <string name="restart">Ales asenker</string>
+    <string name="install">Sebded</string>
+    <string name="waiting">Ṛǧu…</string>
+    <string name="pref_general">Amatu</string>
+    <string name="pref_ringtone">Aṭenṭen</string>
+    <string name="pref_advanced_options">Iɣewwaren leqqayen</string>
+    <string name="accept">Qbel</string>
+    <string name="recording_error">Tuccḍa</string>
+    <string name="your_account">Amiḍan-inek·inem</string>
+    <string name="attach_choose_picture">Fren tugna</string>
+    <string name="attach_take_picture">Ṭṭef tawlaft</string>
+    <string name="account_status_unknown">D arussin</string>
+    <string name="account_status_online">Uqqin</string>
+    <string name="account_status_connecting">Tuqqna…</string>
+    <string name="account_status_offline">Aruqqin</string>
+    <string name="account_status_unauthorized">Ur yettusireg ara</string>
+    <string name="account_state_logged_out">Teffɣeḍ</string>
+    <string name="account_status_no_internet">Ulac tuqqna</string>
+    <string name="encryption_choice_otr">OTR</string>
+    <string name="encryption_choice_pgp">OpenPGP</string>
+    <string name="encryption_choice_omemo">OMEMO</string>
+    <string name="mgmt_account_delete">Kkes amiḍan</string>
+    <string name="account_settings_example_jabber_id">aseqdac@example.com</string>
+    <string name="password">Awal n uɛeddi</string>
+    <string name="mgmt_account_enable">Sermed amiḍan-a</string>
+    <string name="account_settings_jabber_id">Tansa XMPP</string>
+    <string name="server_info_mam">XEP-0313: MAM</string>
+    <string name="other_devices">Ibenkan niḍen</string>
+    <string name="delete_contact">Kkes anermis</string>
+    <string name="block_contact">Sewḥel anermis</string>
+    <string name="select">Fren</string>
+    <string name="join">Ttekki</string>
+    <string name="topic">Asentel</string>
+    <string name="publish">Suffeɣ</string>
+    <string name="next">Uḍfir</string>
+    <string name="skip">Zgel</string>
+    <string name="enable">Sermed</string>
+    <string name="private_message_to">i %s</string>
+    <string name="ignore">Zgel</string>
+    <string name="pref_security_settings">Taɣellist</string>
+    <string name="pref_expert_options_other">Wiyyaḍ</string>
+    <string name="enter_password">Sekcem awal n uɛeddi</string>
+    <string name="request_now">Suter tura</string>
+    <string name="pref_expert_options">Iɣewwaṛen leqqayen</string>
+    <string name="title_activity_about_x">Ɣef %s</string>
+    <string name="confirm">Sentem</string>
+    <string name="web_address">tansa web</string>
+    <string name="account_details">Talqayt n umiḍan</string>
+    <string name="try_again">Ɛreḍ tikkelt nniḍen</string>
+    <string name="file">afaylu</string>
+    <string name="choose_file">Fren afaylu</string>
+    <string name="download_x_file">Zḍem-d %S</string>
+    <string name="delete_x_file">Kkes %s</string>
+    <string name="open_x_file">Ldi %s</string>
+    <string name="file_deleted">Afaylu yettwakkes</string>
+    <string name="clear_other_devices">Kkes ibenkan</string>
+    <string name="password_changed">Awal uffir yettusnifel!</string>
+    <string name="change_password">Snifel awal n uɛeddi</string>
+    <string name="current_password">Awal n uɛeddi amiran</string>
+    <string name="no_role">Aruqqin</string>
+    <string name="member">Aεeggal</string>
+    <string name="never">Werǧin</string>
+    <string name="advanced_mode">Askar alqayan</string>
+    <string name="reply">Err</string>
+    <string name="audio">ameslaw</string>
+    <string name="video">tavidyutt</string>
+    <string name="image">tugna</string>
+    <string name="pdf_document">isemli PDF</string>
+    <string name="vcard">Anermis</string>
+    <string name="word_document">Isemli Word</string>
+    <string name="apk">Asnas Android</string>
+    <string name="sending_x_file">Tuzzna n %s</string>
+    <string name="none">Ula d yiwen</string>
+    <string name="pref_quick_action">Tigawt taruradt</string>
+    <string name="username">Isem n useqdac</string>
+    <string name="username_hint">Isem n useqdac</string>
+    <string name="search_contacts">Nadi inermisen</string>
+    <string name="pref_connection_options">Tuqqna</string>
+    <string name="account_settings_hostname">Asenneftaɣ</string>
+    <string name="account_settings_port">Tawwurt</string>
+    <string name="correct_message">Seɣti izen</string>
+    <string name="presence_away">Ulac-it</string>
+    <string name="create_account">Snulfu-d amiḍan</string>
+    <string name="gp_disable">Ssens-it</string>
+    <string name="pref_privacy">Tabaḍnit</string>
+    <string name="pref_theme_options">Asentel</string>
+    <string name="pref_theme_automatic">Awurman</string>
+    <string name="pref_theme_light">Aceεlal</string>
+    <string name="pref_theme_dark">Ubrik</string>
+    <string name="type_tablet">Taṭablit</string>
+    <string name="type_console">Tadiwent</string>
+    <string name="me">Nekk</string>
+    <string name="allow">Sireg</string>
+    <string name="type_web">Iminig web</string>
+    <string name="error_message">Izen n tuccḍa</string>
+    <string name="today">Ass-a</string>
+    <string name="yesterday">Iḍelli</string>
+    <string name="message">Izen</string>
+    <string name="open_website">Ldi asmel web</string>
+    <string name="attach_record_video">Sekles tavidyutt</string>
+    <string name="once">Tikkelt</string>
+    <string name="draft">Arewway:</string>
+    <string name="share">Bḍu</string>
+    <string name="gif">GIF</string>
+    <string name="group_chat_name">Isem</string>
+    <string name="create_shortcut">Rnu anegzum</string>
+    <string name="action_copy_location">Nɣel adeg</string>
+    <string name="action_share_location">Bḍu adeg</string>
+    <string name="title_activity_share_location">Bḍu adeg</string>
+    <string name="title_activity_show_location">Sken-d adeg</string>
+    <string name="please_wait">Ttxil rǧu…</string>
+    <string name="search_messages">Nadi deg iznan</string>
+    <string name="contact_name">Isem n unermis</string>
+    <string name="notification_group_messages">Iznan</string>
+    <string name="messages_channel_name">Iznan</string>
+    <string name="group_chat_members">Imttekkiyen</string>
+    <string name="cancelled">Yettwasefsex</string>
+    <string name="pref_video_compression">Taɣara n tvidyutt</string>
+    <string name="video_360p">D talemmast (360p)</string>
+    <string name="back">Uɣal</string>
+    <string name="yes">Ih</string>
+    <string name="no">Uhu</string>
+    <string name="phone_number">Uṭṭun n tiliɣri</string>
+    <string name="search_countries">Nadi timura</string>
+    <string name="verifying">Asenqed iteddu…</string>
+    <string name="your_name">Isem-ik·im</string>
+    <string name="start_orbot">Sekker Orbot</string>
+    <string name="open_with">Ldi s…</string>
+    <string name="choose_account">Fren amiḍan</string>
+    <string name="xmpp_address">Tansa XMPP</string>
+    <string name="search_participants">Nadi imttekkiyen</string>
+    <string name="event">Tadyant</string>
+    <string name="jabber_network">jabber.network</string>
+    <string name="local_server">Aqeddac adigan</string>
+    <string name="rtp_state_ringing">Yettṣuni…</string>
+    <string name="rtp_state_declined_or_busy">Ur yestuffi ara</string>
+    <string name="help">Tallalt</string>
+    <string name="ongoing_call">Asiwel iteddu</string>
+    <string name="incoming_call">Asiwel i ikcem-d</string>
+    <string name="video_call">Asiwel s uvidyu</string>
+    <string name="exit">Ffeɣ</string>
+    <string name="search_this_conversation">Asqerdec-a</string>
+    <string name="pref_up_push_account_title">Amiḍan XMPP</string>
+    <string name="decline">Agi</string>
+    <string name="pref_up_push_server_title">Aqeddac Push</string>
+    <string name="log_out">Senser</string>
+    <string name="log_in">Qqen</string>
+    <string name="start_chat">Bdu asqerdec</string>
+    <string name="pref_category_e2ee">Awgelhen seg yixef ɣer wayeḍ</string>
+    <string name="pref_category_operating_system">Anagraw n wammud</string>
+    <string name="pref_category_interaction">Tamyigawt</string>
+    <string name="pref_category_on_this_device">Ɣef yibenk</string>
+    <string name="contacts">Inermisen</string>
+    <string name="search">Nadi</string>
+    <string name="type_pc">Aselkim</string>
+    <string name="rtp_state_connecting">Yetteqqen</string>
+    <string name="rtp_state_connected">D uqqin</string>
+    <string name="pref_title_interface">Agrudem</string>
+    <string name="connect">Qqen</string>
+    <string name="undo">semmet</string>
+    <string name="update">Aleqqem</string>
+    <string name="ebook">e-book</string>
+    <string name="attach">Seddu</string>
+    <string name="restore">Err-it-id</string>
+    <string name="category_about">Ɣef</string>
+    <string name="dismiss_call">Agi</string>
+    <string name="appearance">Udem</string>
+    <string name="send_again">azen-it tikkelt-nniḍen</string>
+    <string name="install_orbot">Sebded Orbot</string>
+    <string name="make_call">Siwel</string>
+    <string name="rtp_state_incoming_call">Asiwel i ikcem-d</string>
+    <string name="more_options">Ugar n yiɣewwaṛen</string>
+    <string name="privacy_policy">Tasertit n tbaḍnit</string>
+    <string name="pref_automatic_download">Azdam awurman</string>
+    <string name="pref_category_server_connection">Tuqqna ɣer uqeddac</string>
+    <string name="pref_title_security">Taɣellist</string>
+    <string name="pref_category_sending">Tuzna</string>
+    <string name="pref_keyboard_options">Anasiw</string>
+    <string name="enter_contact">Rnu anermis</string>
+    <string name="new_password">Awal n uɛeddi amaynut</string>
+    <string name="view_conversation">Wali asqerdec</string>
+    <string name="add_anway">Rnu-t akken yebɣu yili</string>
+</resources>

src/main/res/values-nl/strings.xml 🔗

@@ -1127,4 +1127,4 @@
     <string name="account_status_connection_timeout">Time-out voor verbinding</string>
     <string name="retry_with_p2p">Opnieuw proberen met P2P</string>
     <string name="account_status_channel_binding">Kanaalbinding onbeschikbaar</string>
-</resources>
+</resources>

src/main/res/values-pl/strings.xml 🔗

@@ -325,7 +325,7 @@
     <string name="pref_keep_foreground_service">Usługa na pierwszym planie</string>
     <string name="pref_keep_foreground_service_summary">Uniemożliwia systemowi przerwanie połączenia</string>
     <string name="pref_create_backup">Utwórz kopię zapasową</string>
-    <string name="pref_create_backup_summary">Kopia zapasowa będzie zapisana w %s</string>
+    <string name="pref_create_backup_summary">Kopie zapasowe będą przechowywane w %s</string>
     <string name="notification_create_backup_title">Tworzenie kopii zapasowej</string>
     <string name="notification_backup_created_title">Kopia zapasowa została utworzona</string>
     <string name="notification_backup_created_subtitle">Kopia zapasowa zapisana w %s</string>
@@ -415,7 +415,7 @@
     <string name="image">obraz</string>
     <string name="vector_graphic">grafika wektorowa</string>
     <string name="multimedia_file">plik multimediów</string>
-    <string name="pdf_document">Dokument PDF</string>
+    <string name="pdf_document">dokument PDF</string>
     <string name="apk">Aplikacja Androida</string>
     <string name="vcard">Kontakt</string>
     <string name="avatar_has_been_published">Avatar został pomyślnie opublikowany!</string>
@@ -464,7 +464,7 @@
     <string name="download_failed_could_not_write_file">Pobieranie niepowiodło się: brak możliwości zapisu pliku</string>
     <string name="download_failed_invalid_file">Pobieranie nieudane: Nieprawidłowy plik</string>
     <string name="account_status_tor_unavailable">Sieć TOR jest niedostepna</string>
-    <string name="account_status_bind_failure">Błąd połączenia (zasób)</string>
+    <string name="account_status_bind_failure">Błąd przywiązania kanału (zasobu)</string>
     <string name="account_status_host_unknown">Nie odpowiada za domenę</string>
     <string name="server_info_broken">Zepsute</string>
     <string name="pref_presence_settings">Dostępność</string>
@@ -841,12 +841,12 @@
     <string name="ebook">e-book</string>
     <string name="video_original">Oryginalne (nieskompresowane)</string>
     <string name="open_with">Otwórz za pomocą…</string>
-    <string name="set_profile_picture">Obrazek profilowy Conversations</string>
+    <string name="set_profile_picture">Awatar</string>
     <string name="choose_account">Wybierz konto</string>
     <string name="restore_backup">Przywróć kopię zapasową</string>
     <string name="restore">Przywróć</string>
     <string name="enter_password_to_restore">Wpisz swoje hasło do konta %s aby przywrócić kopię zapasową.</string>
-    <string name="restore_warning">Nie używaj kopii zapasowej aby klonować (uruchamiać równolegle) instalację. Przywracanie kopii jest przeznaczone tylko do migracji albo kiedy urządzenie zostało zgubione.</string>
+    <string name="restore_warning">Nie przywracaj kluczy OMEMO aby klonować (uruchamiać równocześnie) instalację. Przywracanie kluczy OMEMO jest przeznaczone tylko do migracji albo kiedy urządzenie zostało zgubione.</string>
     <string name="unable_to_restore_backup">Nie można przywrócić kopii zapasowej.</string>
     <string name="unable_to_decrypt_backup">Nie można odszyfrować kopii zapasowej. Czy hasło jest poprawne?</string>
     <string name="backup_channel_name">Kopia i Przywracanie</string>
@@ -1019,7 +1019,7 @@
     <string name="could_not_delete_account_from_server">Nie można usunąć konta z serwera</string>
     <string name="search_group_chats">Przeszukaj rozmowy grupowe</string>
     <string name="group_chats">Rozmowy grupowe</string>
-    <string name="restore_warning_continued">Nie próbuj przywracać kopii zapasowych, których nie utworzono samodzielnie!</string>
+    <string name="restore_warning_continued">Przywracaj jedynie kopie zapasowe, które samodzielnie utworzyłeś.</string>
     <string name="outdated_backup_file_format">Próbujesz zaimportować plik kopii zapasowej o przestarzałym formacie</string>
     <string name="audiobook">Audiobook</string>
     <string name="reconnect_on_other_host">Połącz się ponownie na innym hoście</string>
@@ -1140,4 +1140,17 @@
     <string name="show_to_contacts_only">Pokazuj wyłącznie kontaktom</string>
     <string name="account_status_connection_timeout">Limit czasu połączenia</string>
     <string name="retry_with_p2p">Spróbuj ponownie używając P2P</string>
+    <string name="word_document">dokument Microsoft Word</string>
+    <string name="account_status_channel_binding">Przywiązywanie kanału niedostępne</string>
+    <string name="restore_omemo_key">Przywróć klucze OMEMO</string>
+    <string name="non_quicksy_backup">Quicksy potrafi przywracać kopie zapasowe jedynie dla kont quicksy.im</string>
+    <string name="pref_backup_location">Lokalizacja kopii zapasowej</string>
+    <string name="uri">URI</string>
+    <string name="copy_telephone_number">Kopiuj numer telefonu</string>
+    <string name="copy_geo_uri">Kopiuj lokalizację geograficzną</string>
+    <string name="copy_email_address">Kopiuj adres e‑mail</string>
+    <string name="copied_phone_number">Skopiowano numer telefonu do schowka</string>
+    <string name="copy_URI">Kopiuj URI</string>
+    <string name="copied_email_address">Skopiowano adres e‑mail do schowka</string>
+    <string name="uri_copied_to_clipboard">Skopiowano URI do schowka</string>
 </resources>

src/main/res/values-pt-rBR/strings.xml 🔗

@@ -281,7 +281,7 @@
     <string name="without_mutual_presence_updates"><b>Aviso:</b> Enviar isso sem atualizações mútuas de presença pode provocar problemas inesperados.\n\n<small>Verifique nos detalhes do contato suas inscrições de presença.</small></string>
     <string name="pref_security_settings">Segurança</string>
     <string name="pref_allow_message_correction">Correção de mensagem</string>
-    <string name="pref_allow_message_correction_summary">Permita que seus contatos editem suas mensagens retroativamente.</string>
+    <string name="pref_allow_message_correction_summary">Permita que seus contatos editem suas mensagens retroativamente</string>
     <string name="pref_expert_options">Configurações avançadas</string>
     <string name="pref_expert_options_summary">Por favor, tenha cuidado com isso</string>
     <string name="title_activity_about_x">Sobre o %s</string>
@@ -326,7 +326,7 @@
     <string name="pref_keep_foreground_service">Serviço ativo</string>
     <string name="pref_keep_foreground_service_summary">Impede que o sistema operacional encerre sua conexão</string>
     <string name="pref_create_backup">Criar backup</string>
-    <string name="pref_create_backup_summary">Os arquivos de backup serão armazenados em %s</string>
+    <string name="pref_create_backup_summary">Os backups serão armazenados em %s</string>
     <string name="notification_create_backup_title">Criando arquivos de backup</string>
     <string name="notification_backup_created_title">O seu backup foi criado</string>
     <string name="notification_backup_created_subtitle">Os arquivos de backup foram armazenados em %s</string>
@@ -832,12 +832,12 @@
     <string name="ebook">e-book</string>
     <string name="video_original">Original (não comprimido)</string>
     <string name="open_with">Abrir com…</string>
-    <string name="set_profile_picture">Imagem de perfil do Conversations</string>
+    <string name="set_profile_picture">Avatar</string>
     <string name="choose_account">Selecione a conta</string>
     <string name="restore_backup">Restaurar o backup</string>
     <string name="restore">Restaurar</string>
     <string name="enter_password_to_restore">Digite sua senha para a conta %s para restaurar o backup.</string>
-    <string name="restore_warning">Não use o recurso de restaurar um backup para tentar clonar (rodar simultaneamente) uma instalação. A restauração de backups é destinada a migrações ou caso você tenha perdido o dispositivo original.</string>
+    <string name="restore_warning">Não restaure chaves OMEMO na tentativa de clonar (rodar simultaneamente) uma instalação. A restauração de chaves OMEMO é destinada a migrações ou caso você tenha perdido o dispositivo original.</string>
     <string name="unable_to_restore_backup">Não foi possível restaurar o backup.</string>
     <string name="unable_to_decrypt_backup">Não foi possível descriptografar o backup. A senha está correta?</string>
     <string name="backup_channel_name">Backup &amp; Restauração</string>
@@ -1042,7 +1042,7 @@
     <string name="hide_notification">Esconder notificação</string>
     <string name="pref_up_long_summary">Ao atuar como um distribuidor UnifiedPush, a conexão persistente, estável, e amigável à bateria do XMPP será usada para alertar outros apps compatíveis com o UnifiedPush, como o Tusky, Ltt.RS, FluffyChat, e mais.</string>
     <string name="corresponding_chats_closed">Conversas correspondentes arquivadas.</string>
-    <string name="restore_warning_continued">Não tente restaurar backups que você não criou!</string>
+    <string name="restore_warning_continued">Restaure somente backups que você mesmo criou.</string>
     <string name="rtp_state_contact_offline">O contato não está disponível</string>
     <string name="video_is_enabled_tap_to_disable">O vídeo está ativado. Toque para desativar.</string>
     <string name="channel_discover_opt_in_message">A descoberta de canais usa um serviço de terceiros chamado &lt;a href=https://search.jabber.network&gt;search.jabber.network&lt;/a&gt;.&lt;br&gt;&lt;br&gt;Usar esta funcionalidade transmitirá seu endereço de IP e termos de pesquisa ao serviço. Leia sua &lt;a href=https://search.jabber.network/privacy&gt;Política de Privacidade&lt;/a&gt; para mais informações.</string>
@@ -1129,4 +1129,16 @@
     <string name="account_status_connection_timeout">Conexão demorou muito</string>
     <string name="retry_with_p2p">Tentar novamente com P2P</string>
     <string name="account_status_channel_binding">Vínculo de canal indisponível</string>
+    <string name="word_document">Documento do Word</string>
+    <string name="restore_omemo_key">Restaurar as chaves OMEMO</string>
+    <string name="non_quicksy_backup">O Quicksy só pode restaurar backups de contas quicksy.im</string>
+    <string name="pref_backup_location">Local do backup</string>
+    <string name="uri">URI</string>
+    <string name="copy_URI">Copiar URI</string>
+    <string name="copy_telephone_number">Copiar número de telefone</string>
+    <string name="copy_geo_uri">Copiar localização geográfica</string>
+    <string name="copy_email_address">Copiar endereço de e-mail</string>
+    <string name="copied_email_address">Endereço de e-mail copiado para a área de transferência</string>
+    <string name="uri_copied_to_clipboard">URI copiada para a área de transferência</string>
+    <string name="copied_phone_number">Número de telefone copiado para a área de transferência</string>
 </resources>

src/main/res/values-ro-rRO/strings.xml 🔗

@@ -173,7 +173,7 @@
     <string name="encryption_choice_omemo">OMEMO</string>
     <string name="mgmt_account_delete">Șterge cont</string>
     <string name="mgmt_account_disable">Dezactivare temporară</string>
-    <string name="mgmt_account_publish_avatar">Publică avatar</string>
+    <string name="mgmt_account_publish_avatar">Publică poză profil</string>
     <string name="mgmt_account_publish_pgp">Publică cheia publică OpenPGP</string>
     <string name="unpublish_pgp">Șterge cheia publică OpenPGP</string>
     <string name="unpublish_pgp_message">Sigur doriți să vă ștergeți cheia publică OpenPGP din mesajele de prezență?\nContactele dumneavoastră nu vor mai putea să vă trimită mesaje criptate cu OpenPGP.</string>
@@ -196,7 +196,7 @@
     <string name="server_info_roster_version">XEP-0237: Creare de versiuni listă</string>
     <string name="server_info_stream_management">XEP-0198: Management flux</string>
     <string name="server_info_external_service_discovery">XEP-0215: Descoperirea serviciilor externe</string>
-    <string name="server_info_pep">XEP-0163: PEP (Avatare / OMEMO)</string>
+    <string name="server_info_pep">XEP-0163: PEP (Poză profil / OMEMO)</string>
     <string name="server_info_http_upload">XEP-0363: Încărcare fișiere prin HTTP</string>
     <string name="server_info_push">XEP-0357: Push</string>
     <string name="server_info_available">disponibil</string>
@@ -252,13 +252,13 @@
     <string name="contacts_and_n_more_have_read_up_to_this_point">%1$s și încă %2$d au citit până aici</string>
     <string name="everyone_has_read_up_to_this_point">Toate persoanele au citit până aici</string>
     <string name="publish">Publică</string>
-    <string name="touch_to_choose_picture">Atingeți avatarul pentru a selecta o poză din galerie</string>
+    <string name="touch_to_choose_picture">Atingeți poza de profil pentru a selecta o poză din galerie</string>
     <string name="publishing">Se publică…</string>
     <string name="error_publish_avatar_server_reject">Acest server v-a refuzat publicarea</string>
     <string name="error_publish_avatar_converting">Nu s-a putut face convertirea pozei</string>
-    <string name="error_saving_avatar">Nu s-a putut salva avatarul pe disc</string>
+    <string name="error_saving_avatar">Nu s-a putut salva poza de profil pe disc</string>
     <string name="or_long_press_for_default">(Sau apasă îndelung pentru a reseta la implicit)</string>
-    <string name="error_publish_avatar_no_server_support">Serverul dumneavoastră nu permite publicarea de avatare</string>
+    <string name="error_publish_avatar_no_server_support">Serverul dumneavoastră nu permite publicarea de poze de profil</string>
     <string name="private_message">șoptește</string>
     <string name="private_message_to">către %s</string>
     <string name="send_private_message_to">Trimite mesaj privat catre %s</string>
@@ -324,7 +324,7 @@
     <string name="pref_keep_foreground_service">Serviciul activ în prim-plan</string>
     <string name="pref_keep_foreground_service_summary">Previne închiderea conexiunii de către sistemul de operare</string>
     <string name="pref_create_backup">Creează o copie de siguranță</string>
-    <string name="pref_create_backup_summary">Fișierele copiei de siguranță vor fi salvate în %s</string>
+    <string name="pref_create_backup_summary">Copiile de siguranță vor fi salvate în %s</string>
     <string name="notification_create_backup_title">Se creează copia de siguranță</string>
     <string name="notification_backup_created_title">Copia de siguranță a fost creată</string>
     <string name="notification_backup_created_subtitle">Fișierele copiei de siguranță au fost salvate în %s</string>
@@ -352,7 +352,7 @@
     <string name="enable_notifications">Activează notificările</string>
     <string name="no_conference_server_found">Nu s-a găsit serverul pentru discuția de grup</string>
     <string name="conference_creation_failed">Nu s-a putut crea discuția de grup</string>
-    <string name="account_image_description">Avatar cont</string>
+    <string name="account_image_description">Poză de profil cont</string>
     <string name="copy_omemo_clipboard_description">Copiază amprenta OMEMO în memorie</string>
     <string name="regenerate_omemo_key">Generează din nou cheia OMEMO</string>
     <string name="clear_other_devices">Curață lista dispozitivelor</string>
@@ -417,7 +417,7 @@
     <string name="pdf_document">document PDF</string>
     <string name="apk">Aplicație Android</string>
     <string name="vcard">Contact</string>
-    <string name="avatar_has_been_published">Avatarul a fost publicat!</string>
+    <string name="avatar_has_been_published">Poza de profil a fost publicată!</string>
     <string name="sending_x_file">Trimit %s</string>
     <string name="offering_x_file">Ofer %s</string>
     <string name="hide_offline">Ascunde deconectat</string>
@@ -593,7 +593,7 @@
     <string name="pref_delete_omemo_identities">Șterge identitățile OEMO</string>
     <string name="pref_delete_omemo_identities_summary">Regenerează cheile personale OMEMO. Toate contactele vor fi obligate să verifice cheile dumneavoastră din nou. Folosiți asta doar ca o ultimă opțiune.</string>
     <string name="delete_selected_keys">Șterge cheile selectate</string>
-    <string name="error_publish_avatar_offline">Pentru a putea publica avatarul trebuie să existe o conexiune.</string>
+    <string name="error_publish_avatar_offline">Pentru a putea publica poza de profil trebuie să existe o conexiune.</string>
     <string name="show_error_message">Arată mesaj de eroare</string>
     <string name="error_message">Mesaj de eroare</string>
     <string name="data_saver_enabled">Economizorul de date este activat</string>
@@ -741,9 +741,9 @@
     <string name="p1_s3_filetransfer">Partajare fișiere prin HTTP pentru S3</string>
     <string name="pref_start_search">Activează direct căutarea</string>
     <string name="pref_start_search_summary">În ecranul \"Discuție nouă\" focalizează câmpul de căutare și arată tastatura</string>
-    <string name="group_chat_avatar">Avatar discuție de grup</string>
-    <string name="host_does_not_support_group_chat_avatars">Serverul gazdă nu suporta avatare pentru grupuri</string>
-    <string name="only_the_owner_can_change_group_chat_avatar">Doar proprietarul grupului poate schimba avatarul</string>
+    <string name="group_chat_avatar">Poză de profil discuție de grup</string>
+    <string name="host_does_not_support_group_chat_avatars">Serverul gazdă nu suporta poze de profil pentru grupuri</string>
+    <string name="only_the_owner_can_change_group_chat_avatar">Doar proprietarul grupului poate schimba poza de profil</string>
     <string name="contact_name">Nume contact</string>
     <string name="nickname">Numele dumneavoastră</string>
     <string name="group_chat_name">Titlu discuție de grup</string>
@@ -833,12 +833,12 @@
     <string name="ebook">carte electronică</string>
     <string name="video_original">Original (necompresat)</string>
     <string name="open_with">Deschide cu…</string>
-    <string name="set_profile_picture">Poză profil Conversations</string>
+    <string name="set_profile_picture">Poză de profil</string>
     <string name="choose_account">Alegeți contul</string>
     <string name="restore_backup">Restaurează o copie de siguranță</string>
     <string name="restore">Restaurează</string>
     <string name="enter_password_to_restore">Introduceți parola contului %s pentru a restaura copia de siguranță.</string>
-    <string name="restore_warning">Nu folosiți funcția de restaurare a copiei de siguranță pentru a încerca clonarea (rularea simultană a) instalării. Restaurarea copiei de siguranță este gândită doar pentru a migra pe un alt dispozitiv sau în cazul în care ați pierdut dispozitivul original.</string>
+    <string name="restore_warning">Nu restaurați cheile OMEMO în încercarea de a clona (rula simultan) aplicația. Restaurarea cheilor OMEMO este destinată doar migrărilor sau în cazul în care ați pierdut dispozitivul original.</string>
     <string name="unable_to_restore_backup">Nu s-a putut restaura copia de siguranță.</string>
     <string name="unable_to_decrypt_backup">Nu s-a putut decripta copia de siguranță. Este parola corectă?</string>
     <string name="backup_channel_name">Copie de siguranță &amp; Restaurare</string>
@@ -956,9 +956,9 @@
     <string name="could_not_correct_message">Nu s-a putut corecta mesajul</string>
     <string name="search_all_conversations">Toate discuțiile</string>
     <string name="search_this_conversation">Această discuție</string>
-    <string name="your_avatar">Avatarul dumneavoastră</string>
-    <string name="avatar_for_x">Avatar pentru %s</string>
-    <string name="encrypted_with_omemo">Criptare OMEMO</string>
+    <string name="your_avatar">Poza dumneavoastră de profil</string>
+    <string name="avatar_for_x">Poză de profil pentru %s</string>
+    <string name="encrypted_with_omemo">Criptat cu OMEMO</string>
     <string name="encrypted_with_openpgp">Criptare OpenPGP</string>
     <string name="not_encrypted">Fără criptare</string>
     <string name="exit">Ieșire</string>
@@ -989,7 +989,7 @@
     <string name="account_registrations_are_not_supported">Nu este posibilă înregistrarea unui cont</string>
     <string name="no_xmpp_adddress_found">Nu a fost găsită o adresă XMPP</string>
     <string name="account_status_temporary_auth_failure">Eroare temporară de autentificare</string>
-    <string name="delete_avatar">Șterge avatar</string>
+    <string name="delete_avatar">Șterge poza de profil</string>
     <string name="audio_video_disabled_tor">Apelurile sunt dezactivate atunci când utilizați Tor</string>
     <string name="switch_to_video">Comută la video</string>
     <string name="reject_switch_to_video">Respinge solicitarea de comutare la video</string>
@@ -1006,7 +1006,7 @@
     <string name="delete_from_server">Șterge contul de pe server</string>
     <string name="group_chats">Discuții de grup</string>
     <string name="search_group_chats">Caută discuții de grup</string>
-    <string name="restore_warning_continued">Nu încercați să restaurați copii de rezervă pe care nu le-ați creat personal!</string>
+    <string name="restore_warning_continued">Restaurați numai copii de rezervă pe care le-ați creat personal.</string>
     <string name="outdated_backup_file_format">Încercați să importați un fișier copie de rezervă format vechi</string>
     <string name="audiobook">Carte audio</string>
     <string name="reconnect_on_other_host">Reconectat pe altă gazdă</string>
@@ -1088,7 +1088,7 @@
     <string name="pref_fullscreen_notification_summary">Atunci când dispozitivul este blocat permite aplicației să arate notificările apelurilor pe tot ecranul.</string>
     <string name="allow_private_messages">Permite mesaje private</string>
     <string name="could_not_disable_video">Nu s-a putut dezactiva videoul.</string>
-    <string name="your_avatar_tap_to_select_new_avatar">Avatarul dumneavoastră. Atingeți pentru a selecta un nou avatar din galerie.</string>
+    <string name="your_avatar_tap_to_select_new_avatar">Poza dumneavoastră de profil. Atingeți pentru a selecta una nouă din galerie.</string>
     <string name="change_notification_settings">Schimbă setările notificărilor</string>
     <string name="edit_configuration">Schimbă configurația</string>
     <string name="edit_name_and_topic">Editare nume și subiect</string>
@@ -1112,20 +1112,32 @@
     <string name="more_reactions">Mai multe reacții</string>
     <string name="could_not_modify_call">Nu s-a putut modifica apelul</string>
     <string name="clients_may_not_support_av">Clientul XMPP al contactului dvs. este posibil să nu accepte apeluri audio/video.</string>
-    <string name="pref_chat_bubbles_summary">Culoare de fundal, mărime font, avatare</string>
-    <string name="pref_show_avatars_summary">Afișați avatare pentru mesajele dvs. și în discuțiile 1:1, în plus față de cele de grup.</string>
+    <string name="pref_chat_bubbles_summary">Culoare de fundal, mărime font, poze profil</string>
+    <string name="pref_show_avatars_summary">Afișați poze de profil pentru mesajele dvs. și în discuțiile 1:1, în plus față de cele de grup.</string>
     <string name="pref_title_bubbles">Bule de mesaj</string>
     <string name="pref_chat_bubbles">Bule de mesaj</string>
-    <string name="pref_show_avatars">Arată avatare</string>
+    <string name="pref_show_avatars">Arată poze de profil</string>
     <string name="custom_notifications_enable">Activați setările de notificare personalizate (importanță, sunet, vibrații) pentru această conversație?</string>
     <string name="custom_notifications">Notificări personalizate</string>
     <string name="pref_align_start_summary">Afișați toate mesajele, inclusiv cele trimise, în partea stângă pentru un aspect uniform al discuției.</string>
     <string name="pref_align_start">Mesaje aliniate la stânga</string>
     <string name="pref_call_integration_summary">Apelurile din această aplicație interacționează cu apelurile telefonice obișnuite, cum ar fi terminarea unui apel atunci când începe altul.</string>
     <string name="pref_call_integration">Integrarea apelurilor</string>
-    <string name="delete_avatar_message">Ați dori să vă ștergeți avatarul? Unii clienți ar putea să continue să arate o copie a avatarului.</string>
+    <string name="delete_avatar_message">Ați dori să vă ștergeți poza de profil? Unii clienți ar putea să continue să arate o copie a ei.</string>
     <string name="show_to_contacts_only">Arată doar contactelor</string>
     <string name="account_status_connection_timeout">Timp limită de conectare expirat</string>
     <string name="retry_with_p2p">Reîncearcă cu P2P</string>
     <string name="account_status_channel_binding">Channel binding indisponibil</string>
+    <string name="word_document">Document Word</string>
+    <string name="non_quicksy_backup">Quicksy poate restaura doar copiile de rezervă pentru conturile quicksy.im</string>
+    <string name="restore_omemo_key">Restaurare chei OMEMO</string>
+    <string name="pref_backup_location">Locație copie de siguranță</string>
+    <string name="uri">adresă</string>
+    <string name="copy_URI">Copiere adresă</string>
+    <string name="copy_geo_uri">Copiere locație</string>
+    <string name="copied_phone_number">Telefon copiat</string>
+    <string name="copy_email_address">Copiere e-mail</string>
+    <string name="uri_copied_to_clipboard">Adresă copiată</string>
+    <string name="copy_telephone_number">Copiere telefon</string>
+    <string name="copied_email_address">E-mail copiat</string>
 </resources>

src/main/res/values-ru/strings.xml 🔗

@@ -91,7 +91,7 @@
     <string name="send_unencrypted_message">Отправить сообщение без шифрования</string>
     <string name="send_message">Сообщение</string>
     <string name="send_message_to_x">Сообщение для %s</string>
-    <string name="send_omemo_x509_message">Зашифрованное vOMEMO сообщение</string>
+    <string name="send_omemo_x509_message">Зашифрованное v\\OMEMO сообщение</string>
     <string name="your_nick_has_been_changed">Используется новое имя</string>
     <string name="send_unencrypted">Отправить в незашифрованном виде</string>
     <string name="decryption_failed">Расшифровка невозможна. Вероятно, у вас нет надлежащего ключа.</string>
@@ -218,9 +218,9 @@
     <string name="openpgp_messages_found">Найдены новые зашифрованные OpenPGP сообщения</string>
     <string name="openpgp_key_id">ID ключа OpenPGP</string>
     <string name="omemo_fingerprint">Отпечаток OMEMO</string>
-    <string name="omemo_fingerprint_x509">Отпечаток vOMEMO</string>
+    <string name="omemo_fingerprint_x509">Отпечаток v\\OMEMO</string>
     <string name="omemo_fingerprint_selected_message">Отпечаток OMEMO (выбранного сообщения)</string>
-    <string name="omemo_fingerprint_x509_selected_message">Отпечаток vOMEMO (выбранного сообщения)</string>
+    <string name="omemo_fingerprint_x509_selected_message">Отпечаток v\\OMEMO (выбранного сообщения)</string>
     <string name="other_devices">Другие устройства</string>
     <string name="trust_omemo_fingerprints">Доверенные отпечатки OMEMO</string>
     <string name="fetching_keys">Получение ключей…</string>
@@ -332,7 +332,7 @@
     <string name="pref_keep_foreground_service">Процесс переднего плана</string>
     <string name="pref_keep_foreground_service_summary">Не позволять операционной системе закрывать ваше соединение</string>
     <string name="pref_create_backup">Создать резервную копию</string>
-    <string name="pref_create_backup_summary">Файлы резервной копии будут сохранены в %s</string>
+    <string name="pref_create_backup_summary">Резервные копии будут сохранены в %s</string>
     <string name="notification_create_backup_title">Создание резервной копии</string>
     <string name="notification_backup_created_title">Ваша резервная копия создана</string>
     <string name="notification_backup_created_subtitle">Файлы резервной копии сохранены в %s</string>
@@ -422,7 +422,7 @@
     <string name="video">видео</string>
     <string name="image">изображение</string>
     <string name="vector_graphic">векторная графика</string>
-    <string name="pdf_document">PDF-документ</string>
+    <string name="pdf_document">Документ PDF</string>
     <string name="apk">Приложение Android</string>
     <string name="vcard">Контакт</string>
     <string name="avatar_has_been_published">Аватар загружен!</string>
@@ -847,12 +847,12 @@
     <string name="ebook">Электронная книга</string>
     <string name="video_original">Оригинал (без сжатия)</string>
     <string name="open_with">Открыть с помощью…</string>
-    <string name="set_profile_picture">Изображение профиля Conversations</string>
+    <string name="set_profile_picture">Аватар</string>
     <string name="choose_account">Выбрать аккаунт</string>
     <string name="restore_backup">Восстановить из резервной копии</string>
     <string name="restore">Восстановить</string>
     <string name="enter_password_to_restore">Введите пароль аккаунта %s для восстановления резервной копии.</string>
-    <string name="restore_warning">Не используйте восстановление резервной копии для дублирования установленного приложения (одновременного исполнения). Восстановление резервной копии нужно лишь для того, чтобы перенести данные на другое устройство или на случай потери своего устройства.</string>
+    <string name="restore_warning">Не используйте восстановление ключей OMEMO для дублирования установленного приложения (одновременного использования). Восстановление ключей OMEMO нужно только для переноса данных на другое устройство или на случай потери своего устройства.</string>
     <string name="unable_to_restore_backup">Невозможно восстановить резервную копию.</string>
     <string name="unable_to_decrypt_backup">Невозможно расшифровать резервную копию. Вы ввели верный пароль?</string>
     <string name="backup_channel_name">Резервное копирование и восстановление</string>
@@ -1022,7 +1022,7 @@
     <string name="pref_up_push_account_summary">Аккаунт для получения push-уведомлений</string>
     <string name="no_account_deactivated">Нет (неактивно)</string>
     <string name="verifying_omemo_keys_trusted_source_account">Вы собираетесь проверить ключи OMEMO своего аккаунта. Это безопасно только в том случае, если вы перешли по этой ссылке из надёжного источника, где только вы могли опубликовать эту ссылку.</string>
-    <string name="restore_warning_continued">Не пытайтесь восстановить резервные копии, которые не были созданы вами!</string>
+    <string name="restore_warning_continued">Восстанавливайте только те резервные копии, которые были созданы лично вами!</string>
     <plurals name="n_missed_calls_from_x">
         <item quantity="one">%1$d пропущенный вызов от %2$s</item>
         <item quantity="few">%1$d пропущенных вызова от %2$s</item>
@@ -1158,4 +1158,16 @@
     <string name="account_status_connection_timeout">Истекло время ожидания подключения</string>
     <string name="retry_with_p2p">Повторить через P2P</string>
     <string name="account_status_channel_binding">Привязка канала недоступна</string>
+    <string name="word_document">Документ Word</string>
+    <string name="restore_omemo_key">Восстановить ключи OMEMO</string>
+    <string name="non_quicksy_backup">Quicksy может восстанавливать резервные копии только для аккаунтов quicksy.im</string>
+    <string name="pref_backup_location">Расположение резервной копии</string>
+    <string name="uri_copied_to_clipboard">URI скопирован в буфер обмена</string>
+    <string name="uri">URI</string>
+    <string name="copy_telephone_number">Копировать номер телефона</string>
+    <string name="copied_phone_number">Номер телефона скопирован в буфер обмена</string>
+    <string name="copy_geo_uri">Копировать местоположение</string>
+    <string name="copy_email_address">Копировать адрес почты</string>
+    <string name="copy_URI">Копировать URI</string>
+    <string name="copied_email_address">Адрес электронной почты скопирован в буфер обмена</string>
 </resources>

src/main/res/values-sk/strings.xml 🔗

@@ -500,4 +500,4 @@
     <string name="encrypted_with_omemo">Zašifrované s OMEMO</string>
     <string name="failed_deliveries">Zlyhané doručenia</string>
     <string name="more_options">Viac možnosťí</string>
-</resources>
+</resources>

src/main/res/values-sq-rAL/strings.xml 🔗

@@ -253,7 +253,7 @@
     <string name="try_again">Riprovoni</string>
     <string name="pref_keep_foreground_service">Shërbim në prapaskenë</string>
     <string name="pref_create_backup">Krijo kopjeruajtje</string>
-    <string name="pref_create_backup_summary">Kartelat kopjeruajtje do të depozitohen në %s</string>
+    <string name="pref_create_backup_summary">Kopjeruajtjet do të depozitohen në %s</string>
     <string name="notification_create_backup_title">Po krijohen kartela kopjeruajtje</string>
     <string name="notification_backup_created_title">Kopjeruajtja juaj u krijua</string>
     <string name="restoring_backup">Po rikthehet kopjeruajtje</string>
@@ -623,7 +623,7 @@
     <string name="ebook">e-libër</string>
     <string name="video_original">Origjinalja (e pangjeshur)</string>
     <string name="open_with">Hape me…</string>
-    <string name="set_profile_picture">Foto profili Conversations</string>
+    <string name="set_profile_picture">Avatar</string>
     <string name="choose_account">Zgjidhni llogari</string>
     <string name="restore_backup">Riktheje kopjeruajtjen</string>
     <string name="restore">Riktheje</string>
@@ -953,7 +953,7 @@
     <string name="we_will_be_verifying"><![CDATA[Do të verifikojmë numrin e telefonit<br/><br/><b>%s</b><br/><br/>Dakord, apo do të donit të përpunonit numrin??]]></string>
     <string name="we_have_sent_you_an_sms_to_x"><![CDATA[Ju kemi dërguar një SMS te <b>%s</b>.]]></string>
     <string name="enter_password_to_restore">Që të rikthehet kopjeruajtja, jepni fjalëkalimin tuaj për llogarinë %s.</string>
-    <string name="restore_warning">Mos përdorni veçorinë e rikthimit të një kopjeruajtje në një përpjekje për të klonuar (xhiruar në të njëjtën kohë) një instalim. Rikthimi i një kopjeruajtje është menduar vetëm për migrime, ose në rast se humbët pajisjen origjinale.</string>
+    <string name="restore_warning">Mos riktheni kyçe OMEMO, në një përpjekje për të klonuar (xhiruar në të njëjtën kohë) një instalim. Rikthimi i kyçeve OMEMO është menduar vetëm për migrime, ose në rast se humbët pajisjen origjinale.</string>
     <string name="no_users_hint_channel">Ky kanal publik s’ka pjesëmarrës. Ftoni kontaktet tuaj, ose përdorni butonin e ndarjes me të tjerët për të dhënë adresën XMPP të tij.</string>
     <string name="sharing_application_not_grant_permission">Aplikacioni dhënës nuk akordoi leje për hyrje në këtë kartelë.</string>
     <string name="pref_channel_discovery_summary">Shumica e përdoruesve duhet të zgjedhin ‘jabber.network’ për sugjerime më të mira nga krejt ekosistemi publik XMPP.</string>
@@ -968,7 +968,7 @@
         <item quantity="one">%1$d thirrje të humbur prej %2$d kontakti</item>
         <item quantity="other">%1$d thirrje të humbur prej %2$d kontaktesh</item>
     </plurals>
-    <string name="restore_warning_continued">Mos u rrekni të riktheni kopjeruajtje që s’i keni krijuar ju vetë!</string>
+    <string name="restore_warning_continued">Riktheni vetëm kopjeruajtje që ’i keni krijuar ju vetë.</string>
     <string name="rtp_state_content_add">Të shtohen pjesë shtesë?</string>
     <string name="server_info_carbon_messages">XEP-0280: Message Carbons</string>
     <string name="reconnect_on_other_host">Rilidhu te një tjetër strehë</string>
@@ -1121,4 +1121,16 @@
     <string name="delete_avatar_message">Doni të fshihet avatari jua? Disa klientë mund të vazhdojnë të shfaqin një kopje të ruajtur në fshehtinat e tyre të avatarit tuaj.</string>
     <string name="account_status_connection_timeout">Mbarim kohe për lidhjen</string>
     <string name="retry_with_p2p">Riprovo me P2P</string>
-</resources>
+    <string name="word_document">Dokument Word</string>
+    <string name="restore_omemo_key">Rikthe kyçe OMEMO</string>
+    <string name="non_quicksy_backup">Quicksy mund të rikthejë vetëm kopjeruajtje për llogari quicksy.im</string>
+    <string name="pref_backup_location">Vendndodhje kopjeruajtjesh</string>
+    <string name="uri">URI</string>
+    <string name="copy_telephone_number">Kopjo numër telefoni</string>
+    <string name="copy_geo_uri">Kopjo vendndodhje gjeografike</string>
+    <string name="copy_email_address">Kopjo adresë email</string>
+    <string name="copied_phone_number">Numri i telefonit u kopjua në të papastër</string>
+    <string name="uri_copied_to_clipboard">URI u kopjua në të papastër</string>
+    <string name="copied_email_address">Adresa email u kopjua në të papastër</string>
+    <string name="copy_URI">Kopjo URI-n</string>
+</resources>

src/main/res/values-sr/strings.xml 🔗

@@ -314,7 +314,7 @@
     <string name="copy_original_url">Копирај оригинални линк</string>
     <string name="send_again">Пошаљи поново</string>
     <string name="file_url">Линк ка фајлу</string>
-    <string name="url_copied_to_clipboard">Линк је копиран у клипборд</string>
+    <string name="url_copied_to_clipboard">Линк копиран у клипборд</string>
     <string name="jabber_id_copied_to_clipboard">XMPP адреса копирана у клипборд</string>
     <string name="error_message_copied_to_clipboard">Текст грешке копиран у клипборд</string>
     <string name="web_address">веб адреса</string>
@@ -720,9 +720,9 @@
     <string name="view_media">Прикажи садржај</string>
     <string name="group_chat_members">Учесници</string>
     <string name="media_browser">Прегледач садржаја</string>
-    <string name="set_profile_picture">Conversations профилна слика</string>
+    <string name="set_profile_picture">Аватар</string>
     <string name="open_with">Отвори користећи…</string>
-    <string name="restore_warning_continued">Не покушавај да вратиш резервну копију коју ниси направио/ла сам!</string>
+    <string name="restore_warning_continued">Враћај само оне резервне копије које си сам/а направио/ла.</string>
     <string name="create_public_channel">Направи јавни канал</string>
     <string name="allow_participants_to_edit_subject">Дозволи било коме да измени тему</string>
     <string name="share_backup_files">Подели резервне копије</string>
@@ -1005,7 +1005,7 @@
     <string name="install_orbot">Инсталирај Orbot</string>
     <string name="restore">Врати</string>
     <string name="enter_password_to_restore">Унеси своју лозинку за налог %s да вратиш резервну копију.</string>
-    <string name="restore_warning">Не употребљавај функцију враћања резервне копије ради клонирања инсталације (за рад у паралели). Враћање резервне копије је предвиђено само за миграције или у случају да си изгубио/ла оригинални уређај.</string>
+    <string name="restore_warning">Не враћај резервну копију OMEMO кључева ради клонирања инсталације (за рад у паралели). Враћање резервне копије OMEMO кључева је предвиђено само за миграције или у случају да си изгубио/ла оригинални уређај.</string>
     <string name="unable_to_restore_backup">Није могуће вратити резервну копију.</string>
     <string name="unable_to_decrypt_backup">Није могуће дешифровати резервну копију. Да ли је лозинка исправна?</string>
     <string name="backup_channel_name">Резервна копија и Враћање</string>
@@ -1145,4 +1145,16 @@
     <string name="account_status_connection_timeout">Истекла веза</string>
     <string name="retry_with_p2p">Покушај поново са P2P</string>
     <string name="account_status_channel_binding">Везивање канала недоступно</string>
+    <string name="word_document">Word документ</string>
+    <string name="non_quicksy_backup">Quicksy може да врати резервне копије само за quicksy.im налоге</string>
+    <string name="pref_backup_location">Локација резервних копија</string>
+    <string name="restore_omemo_key">Врати OMEMO кључеве</string>
+    <string name="uri_copied_to_clipboard">URI копиран у клипборд</string>
+    <string name="uri">URI</string>
+    <string name="copy_geo_uri">Копирај геолокацију</string>
+    <string name="copy_email_address">Копирај имејл адресу</string>
+    <string name="copied_email_address">Копирана имејл адреса у клипборд</string>
+    <string name="copy_telephone_number">Копирај број телефона</string>
+    <string name="copied_phone_number">Копиран број телефона у клипборд</string>
+    <string name="copy_URI">Копирај URI</string>
 </resources>

src/main/res/values-szl/strings.xml 🔗

@@ -981,4 +981,4 @@
     <string name="plain_text_document">Dokumynt ze samym tekstym</string>
     <string name="account_registrations_are_not_supported">Registracyjo kōnt niy je spiyrano</string>
     <string name="no_xmpp_adddress_found">Żodno adresa XMPP niyznojdziōno</string>
-</resources>
+</resources>

src/main/res/values-tr-rTR/strings.xml 🔗

@@ -1068,4 +1068,4 @@
     <string name="pref_accept_invites_from_strangers">Yabancılardan gelen davetler</string>
     <string name="pref_accept_invites_from_strangers_summary">Yabancılardan gelen grup davetlerini kabul et</string>
     <string name="edit_configuration">Yapılandırmayı değiştir</string>
-</resources>
+</resources>

src/main/res/values-uk/strings.xml 🔗

@@ -305,7 +305,7 @@
     <string name="file_url">URL файлу</string>
     <string name="url_copied_to_clipboard">URL скопійовано</string>
     <string name="jabber_id_copied_to_clipboard">Адресу XMPP скопійовано</string>
-    <string name="error_message_copied_to_clipboard">Текст повідомлення про помилку скопійовано</string>
+    <string name="error_message_copied_to_clipboard">Повідомлення про помилку скопійовано</string>
     <string name="web_address">вебадреса</string>
     <string name="scan_qr_code">Розпізнати QR-код</string>
     <string name="show_qr_code">Показати QR-код</string>
@@ -701,7 +701,7 @@
     <string name="location_disabled">Доступ до місцезнаходження вимкнено</string>
     <string name="action_fix_to_location">Закріпити розташування</string>
     <string name="action_unfix_from_location">Відкріпити розташування</string>
-    <string name="action_copy_location">Скопіювати місцезнаходження</string>
+    <string name="action_copy_location">Копіювати місцезнаходження</string>
     <string name="action_share_location">Поділитися місцезнаходженням</string>
     <string name="action_directions">Напрямки</string>
     <string name="title_activity_share_location">Поділитися місцезнаходженням</string>
@@ -808,12 +808,12 @@
     <string name="ebook">Електронна книга</string>
     <string name="video_original">Оригінал (нестиснений)</string>
     <string name="open_with">Відкрити…</string>
-    <string name="set_profile_picture">Зображення профілю для Conversations</string>
+    <string name="set_profile_picture">Аватар</string>
     <string name="choose_account">Виберіть обліковий запис</string>
     <string name="restore_backup">Відновити з резервної копії</string>
     <string name="restore">Відновити</string>
     <string name="enter_password_to_restore">Введіть пароль до облікового запису %s, щоб відновити з резервної копії.</string>
-    <string name="restore_warning">Не використовуйте відновлення з резервної копії з метою клонування застосунку (запускати одночасно ще один примірник). Відновлення з резервної копії призначене виключно для перенесення даних або на випадок втрати оригінального пристрою.</string>
+    <string name="restore_warning">Не відновлюйте ключі OMEMO з метою клонування застосунку (запускати одночасно ще один примірник). Відновлення ключів OMEMO призначене виключно для перенесення даних або на випадок втрати оригінального пристрою.</string>
     <string name="unable_to_restore_backup">Неможливо відновити з резервної копії.</string>
     <string name="unable_to_decrypt_backup">Не вдалося розшифрувати резервну копію. Чи правильний пароль?</string>
     <string name="backup_channel_name">Створити або відновити резервну копію</string>
@@ -1035,7 +1035,7 @@
     <string name="no_application_found">Не знайдено застосунку</string>
     <string name="unified_push_distributor">Дистриб\'ютор UnifiedPush</string>
     <string name="rtp_state_content_add">Додати ще пісні\?</string>
-    <string name="restore_warning_continued">Не намагайтеся відновити резервні копії, які створили не Ви!</string>
+    <string name="restore_warning_continued">Відновлюйте лише ті резервні копії, які Ви створили особисто.</string>
     <string name="outdated_backup_file_format">Ви намагаєтеся імпортувати файл резервної копії у застарілому форматі</string>
     <string name="audiobook">аудіокнига</string>
     <string name="reconnect_on_other_host">Відновити з\'єднання на іншому вузлі</string>
@@ -1157,4 +1157,16 @@
     <string name="account_status_connection_timeout">Час очікування з\'єднання вичерпано</string>
     <string name="retry_with_p2p">Повторити спробу з P2P</string>
     <string name="account_status_channel_binding">Прив\'язка каналу недоступна</string>
+    <string name="word_document">документ Word</string>
+    <string name="restore_omemo_key">Відновити ключі OMEMO</string>
+    <string name="non_quicksy_backup">Quicksy може відновлювати резервні копії лише для облікових записів quicksy.im</string>
+    <string name="pref_backup_location">Розташування резервних копій</string>
+    <string name="uri">URI</string>
+    <string name="copied_email_address">Адресу Email скопійовано</string>
+    <string name="copy_URI">Копіювати URI</string>
+    <string name="copy_telephone_number">Копіювати номер телефону</string>
+    <string name="copy_geo_uri">Копіювати місцезнаходження</string>
+    <string name="copied_phone_number">Номер телефону скопійовано</string>
+    <string name="copy_email_address">Копіювати адресу Email</string>
+    <string name="uri_copied_to_clipboard">URI скопійовано</string>
 </resources>

src/main/res/values-vi/strings.xml 🔗

@@ -975,4 +975,4 @@
     <string name="reject_switch_to_video">Từ chối yêu cầu chuyển sang video</string>
     <string name="delete_from_server">Xóa tài khoản khỏi máy chủ</string>
     <string name="could_not_delete_account_from_server">Không thể xóa tài khoản khỏi máy chủ</string>
-</resources>
+</resources>

src/main/res/values-zh-rCN/strings.xml 🔗

@@ -75,7 +75,7 @@
     <string name="preparing_image">正在准备发送图片</string>
     <string name="preparing_images">正在准备发送图片</string>
     <string name="sharing_files_please_wait">正在分享文件。请稍候…</string>
-    <string name="action_clear_history">清除历史记录</string>
+    <string name="action_clear_history">清除历史</string>
     <string name="clear_conversation_history">清除聊天记录</string>
     <string name="clear_histor_msg">是否要删除此对话中的所有消息?
 \n
@@ -93,7 +93,7 @@
     <string name="send_unencrypted">发送未加密</string>
     <string name="decryption_failed">解密失败。也许您没有合适的私钥。</string>
     <string name="openkeychain_required">OpenKeychain</string>
-    <string name="openkeychain_required_long"><![CDATA[%1$s 使用 <b>OpenKeychain</b> 来加密和解密消息并管理公钥。<br><br>它在 GPLv3+ 许可证下授权并可在 F-Droid 和 Google Play 上获得。<br><br><small>(请之后重启 %1$s。)</small>]]></string>
+    <string name="openkeychain_required_long"><![CDATA[%1$s 使用 <b>OpenKeychain</b> 来加密和解密消息并管理公钥。<br><br>它采用 GPLv3+ 许可,可在 F-Droid 和 Google Play 上获取。<br><br><small>(之后请重启 %1$s。)</small>]]></string>
     <string name="restart">重启</string>
     <string name="install">安装</string>
     <string name="openkeychain_not_installed">请安装 OpenKeychain</string>
@@ -108,8 +108,8 @@
 \n
 \n<small>请通知对方设置 OpenPGP。</small></string>
     <string name="pref_general">常规</string>
-    <string name="pref_accept_files">接收文件</string>
-    <string name="pref_accept_files_summary">自动接收小于此大小的文件…</string>
+    <string name="pref_accept_files">接受文件</string>
+    <string name="pref_accept_files_summary">自动接受小于此大小的文件…</string>
     <string name="pref_attachments">附件</string>
     <string name="pref_notification_settings">通知</string>
     <string name="pref_vibrate">振动</string>
@@ -182,7 +182,7 @@
 \n您的联系人将无法再向您发送 OpenPGP 加密消息。</string>
     <string name="openpgp_has_been_published">OpenPGP 公钥已发布。</string>
     <string name="mgmt_account_enable">启用账号</string>
-    <string name="mgmt_account_delete_confirm_text">是否确定要删除账号?删除账号会清除全部聊天记录</string>
+    <string name="mgmt_account_delete_confirm_text">是否确定要删除账号?删除账号会清空全部聊天记录</string>
     <string name="attach_record_voice">录制语音</string>
     <string name="account_settings_jabber_id">XMPP 地址</string>
     <string name="block_jabber_id">屏蔽 XMPP 地址</string>
@@ -248,8 +248,8 @@
 \n<b>警告:</b>将在服务器上完全移除频道。</string>
     <string name="could_not_destroy_room">无法解散群聊</string>
     <string name="could_not_destroy_channel">无法解散频道</string>
-    <string name="action_edit_subject">编辑群聊话题</string>
-    <string name="topic">话题</string>
+    <string name="action_edit_subject">编辑群聊主题</string>
+    <string name="topic">主题</string>
     <string name="joining_conference">正在加入群聊…</string>
     <string name="leave">离开</string>
     <string name="contact_added_you">对方已将您添加到联系人列表</string>
@@ -288,7 +288,7 @@
 \n<small>请前往“联系人详情”以验证在线状态订阅。</small></string>
     <string name="pref_security_settings">安全</string>
     <string name="pref_allow_message_correction">消息更正</string>
-    <string name="pref_allow_message_correction_summary">允许您的联系人发送后编辑其消息</string>
+    <string name="pref_allow_message_correction_summary">允许您的联系人重新编辑已发送的消息</string>
     <string name="pref_expert_options">专家设置</string>
     <string name="pref_expert_options_summary">请谨慎设置</string>
     <string name="title_activity_about_x">关于 %s</string>
@@ -320,9 +320,9 @@
     <string name="copy_original_url">复制原始 URL</string>
     <string name="send_again">再次发送</string>
     <string name="file_url">文件 URL</string>
-    <string name="url_copied_to_clipboard">已复制 URL 到剪贴板</string>
-    <string name="jabber_id_copied_to_clipboard">已复制 XMPP 地址到剪贴板</string>
-    <string name="error_message_copied_to_clipboard">已复制出错信息到剪贴板</string>
+    <string name="url_copied_to_clipboard">URL 已复制到剪贴板</string>
+    <string name="jabber_id_copied_to_clipboard">XMPP 地址已复制到剪贴板</string>
+    <string name="error_message_copied_to_clipboard">错误消息已复制到剪贴板</string>
     <string name="web_address">网址</string>
     <string name="scan_qr_code">扫描二维码</string>
     <string name="show_qr_code">显示二维码</string>
@@ -333,7 +333,7 @@
     <string name="pref_keep_foreground_service">前台服务</string>
     <string name="pref_keep_foreground_service_summary">防止操作系统中断连接</string>
     <string name="pref_create_backup">创建备份</string>
-    <string name="pref_create_backup_summary">备份文件将存储在 %s</string>
+    <string name="pref_create_backup_summary">备份将存储在 %s</string>
     <string name="notification_create_backup_title">正在创建备份文件</string>
     <string name="notification_backup_created_title">备份已创建</string>
     <string name="notification_backup_created_subtitle">此备份文件已存储在 %s</string>
@@ -365,7 +365,7 @@
     <string name="copy_omemo_clipboard_description">复制 OMEMO 指纹到剪贴板</string>
     <string name="regenerate_omemo_key">重新生成 OMEMO 密钥</string>
     <string name="clear_other_devices">清除设备</string>
-    <string name="clear_other_devices_desc">是否确定要从 OMEMO 公布中清除所有其他设备?下次连接时,设备将会重新公布,但可能不会收到在此期间发送的消息。</string>
+    <string name="clear_other_devices_desc">是否确定要从 OMEMO 公布中清除所有其他设备?下次连接时,设备将会重新公布,但可能无法接收在此期间发送的消息。</string>
     <string name="error_no_keys_to_trust_server_error">此联系人没有可用密钥。 \n无法从服务器获取新密钥。也许是对方的服务器有问题?</string>
     <string name="error_no_keys_to_trust_presence">此联系人没有可用密钥。 \n请确保双方都有在线状态订阅。</string>
     <string name="error_trustkeys_title">出了点问题</string>
@@ -381,7 +381,7 @@
     <string name="enable_all_accounts">启用所有账号</string>
     <string name="disable_all_accounts">禁用所有账号</string>
     <string name="perform_action_with">执行操作</string>
-    <string name="no_affiliation">无</string>
+    <string name="no_affiliation">访客</string>
     <string name="no_role">离线</string>
     <string name="outcast">被驱逐者</string>
     <string name="member">成员</string>
@@ -404,7 +404,7 @@
     <string name="channel_options">公开频道配置</string>
     <string name="members_only">私人,仅成员</string>
     <string name="non_anonymous">对任何参与者显示用户 XMPP 地址</string>
-    <string name="moderated">开启频道发言审核</string>
+    <string name="moderated">启用频道发言审核</string>
     <string name="you_are_not_participating">您没有发言权</string>
     <string name="modified_conference_options">群聊配置修改成功!</string>
     <string name="could_not_modify_conference_options">无法修改群聊配置</string>
@@ -442,7 +442,7 @@
     <string name="location">位置</string>
     <string name="title_undo_swipe_out_group_chat">离开私人群聊</string>
     <string name="title_undo_swipe_out_channel">离开公开频道</string>
-    <string name="pref_dont_trust_system_cas_title">不信任系统 CA</string>
+    <string name="pref_dont_trust_system_cas_title">不要信任系统 CA</string>
     <string name="pref_dont_trust_system_cas_summary">所有证书必须手动批准</string>
     <string name="pref_remove_trusted_certificates_title">移除证书</string>
     <string name="pref_remove_trusted_certificates_summary">删除手动批准的证书</string>
@@ -470,7 +470,7 @@
     <string name="download_failed_invalid_file">下载失败:文件无效</string>
     <string name="account_status_tor_unavailable">无法连接到 Tor 网络</string>
     <string name="account_status_bind_failure">绑定失败</string>
-    <string name="account_status_host_unknown">域名未响应</string>
+    <string name="account_status_host_unknown">不对域名负责</string>
     <string name="server_info_broken">损坏</string>
     <string name="pref_presence_settings">在线状态</string>
     <string name="pref_away_when_screen_off">设备锁定时离开</string>
@@ -535,25 +535,25 @@
     <string name="correct_message">更正消息</string>
     <string name="send_corrected_message">发送更正后的消息</string>
     <string name="no_keys_just_confirm">您已信任此人的指纹。选择“完成”即表示您确认 %s 是此群聊的成员。</string>
-    <string name="this_account_is_disabled">您禁用了此账号</string>
+    <string name="this_account_is_disabled">您已禁用此账号</string>
     <string name="security_error_invalid_file_access">安全错误:文件访问无效!</string>
     <string name="no_application_to_share_uri">未找到可以分享 URI 的应用</string>
     <string name="share_uri_with">分享 URI…</string>
     <string name="agree_and_continue">同意并继续</string>
-    <string name="magic_create_text">指导您在 conversations.im 上创建账号。\n选择 conversations.im 作为提供者时,向别人提供您的完整 XMPP 地址,就能和对方交流。</string>
+    <string name="magic_create_text">conversations.im 账号创建引导流程。\n当选择 conversations.im 作为服务提供者时,您只需向其他服务提供者的用户提供您的完整 XMPP 地址,即可与对方互通消息。</string>
     <string name="your_full_jid_will_be">您的完整 XMPP 地址将是:%s</string>
     <string name="create_account">创建账号</string>
     <string name="use_own_provider">使用我自己的提供者</string>
     <string name="pick_your_username">选择您的用户名</string>
     <string name="pref_manually_change_presence">手动更改在线状态</string>
-    <string name="pref_manually_change_presence_summary">在编辑状态信息时,让您的联系人知道您是否可以聊天。</string>
-    <string name="status_message">状态信息</string>
+    <string name="pref_manually_change_presence_summary">在编辑状态消息时设置您的在线状态。</string>
+    <string name="status_message">状态消息</string>
     <string name="presence_chat">有空聊天</string>
     <string name="presence_online">在线</string>
     <string name="presence_away">离开</string>
     <string name="presence_xa">没空</string>
     <string name="presence_dnd">忙碌</string>
-    <string name="secure_password_generated">安全密码已生成</string>
+    <string name="secure_password_generated">已生成安全密码</string>
     <string name="device_does_not_support_battery_op">您的设备不支持选择退出电池优化</string>
     <string name="registration_please_wait">注册失败:请稍后重试</string>
     <string name="registration_password_too_weak">注册失败:密码太弱</string>
@@ -593,10 +593,10 @@
     <string name="pref_delete_omemo_identities_summary">重新生成 OMEMO 密钥。您的所有联系人将必须再次验证您。仅将此作为最后的方法。</string>
     <string name="delete_selected_keys">删除所选密钥</string>
     <string name="error_publish_avatar_offline">连接后才能发布头像。</string>
-    <string name="show_error_message">显示出错信息</string>
-    <string name="error_message">出错信息</string>
+    <string name="show_error_message">显示错误消息</string>
+    <string name="error_message">错误消息</string>
     <string name="data_saver_enabled">流量节省程序已启用</string>
-    <string name="data_saver_enabled_explained">操作系统正限制 %1$s 在后台时访问互联网。要接收新消息通知,应当在“流量节省程序”开启时允许 %1$s 无限制访问。 \n在可能的情况下,%1$s 仍会尽可能节省数据。</string>
+    <string name="data_saver_enabled_explained">您的操作系统正在限制 %1$s 在后台时访问互联网。要接收新消息通知,应当在“流量节省程序”开启时允许 %1$s 无限制访问。\n%1$s 仍会在可能的情况下尽可能节省数据。</string>
     <string name="device_does_not_support_data_saver">设备不支持为 %1$s 禁用流量节省程序。</string>
     <string name="error_unable_to_create_temporary_file">无法创建临时文件</string>
     <string name="this_device_has_been_verified">此设备已经过验证</string>
@@ -609,22 +609,22 @@
     <string name="share_as_uri">以 XMPP URI 形式分享</string>
     <string name="share_as_http">以 HTTP 链接形式分享</string>
     <string name="pref_blind_trust_before_verification">验证前盲目信任</string>
-    <string name="pref_blind_trust_before_verification_summary">信任未经验证的联系人的新设备,但提示手动确认已验证的联系人的新设备。</string>
-    <string name="blindly_trusted_omemo_keys">盲目信任的 OMEMO 密钥,这意味着它们可能是别人的,可能会有人窃听。</string>
+    <string name="pref_blind_trust_before_verification_summary">自动信任未经验证的联系人的新设备,但已验证的联系人的新设备需手动确认。</string>
+    <string name="blindly_trusted_omemo_keys">盲目信任的 OMEMO 密钥,意味着存在密钥冒用或第三方窃听的风险。</string>
     <string name="not_trusted">未受信任</string>
     <string name="invalid_barcode">二维码无效</string>
     <string name="pref_clean_cache_summary">清理缓存文件夹(由相机使用)</string>
     <string name="pref_clean_cache">清理缓存</string>
     <string name="pref_clean_private_storage">清理私人存储空间</string>
     <string name="pref_clean_private_storage_summary">清理保存文件的私人存储(可从服务器重新下载)</string>
-    <string name="i_followed_this_link_from_a_trusted_source">我从可信来源获得此链接</string>
-    <string name="verifying_omemo_keys_trusted_source">点击链接后,您将验证 %1$s 的 OMEMO 密钥。只有从可信来源(只有 %2$s 可以发布此链接)获得此链接才是安全的。</string>
-    <string name="verifying_omemo_keys_trusted_source_account">您将验证自己账号的 OMEMO 密钥。只有从可信来源(只有您可以发布此链接)获得此链接才是安全的。</string>
+    <string name="i_followed_this_link_from_a_trusted_source">我从可信来源访问此链接</string>
+    <string name="verifying_omemo_keys_trusted_source">点击链接后,您即将验证 %1$s 的 OMEMO 密钥。只有从可信来源(只有 %2$s 可以发布此链接)访问此链接才是安全的。</string>
+    <string name="verifying_omemo_keys_trusted_source_account">您即将验证自己账号的 OMEMO 密钥。只有从可信来源(只有您可以发布此链接)访问此链接才是安全的。</string>
     <string name="continue_btn">继续</string>
     <string name="verify_omemo_keys">验证 OMEMO 密钥</string>
     <string name="show_inactive_devices">显示非活动设备</string>
     <string name="hide_inactive_devices">隐藏非活动设备</string>
-    <string name="distrust_omemo_key">不再信任设备</string>
+    <string name="distrust_omemo_key">不信任设备</string>
     <string name="distrust_omemo_key_text">是否确定要移除此设备的验证?\n此设备及其消息将标记为“未受信任”。</string>
     <plurals name="seconds">
         <item quantity="other">%d 秒</item>
@@ -685,24 +685,23 @@
     <string name="qr_code_scanner_needs_access_to_camera">需要访问相机来扫描二维码</string>
     <string name="pref_scroll_to_bottom">滚动至底部</string>
     <string name="pref_scroll_to_bottom_summary">发送消息后向下滚屏</string>
-    <string name="edit_status_message_title">编辑状态信息</string>
-    <string name="edit_status_message">编辑状态信息</string>
+    <string name="edit_status_message_title">编辑状态消息</string>
+    <string name="edit_status_message">编辑状态消息</string>
     <string name="disable_encryption">禁用加密</string>
-    <string name="error_trustkey_general">%1$s 无法向 %2$s 发送加密消息。可能是由于您的联系人使用了无法处理 OMEMO 的过时服务器或客户端。</string>
+    <string name="error_trustkey_general">%1$s 无法向 %2$s 发送加密消息。可能是由于对方使用了无法处理 OMEMO 的过时服务器或客户端。</string>
     <string name="error_trustkey_device_list">无法获取设备列表</string>
     <string name="error_trustkey_bundle">无法获取密钥</string>
     <string name="error_trustkey_hint_mutual">提示:某些情况下,双方可以添加到联系人列表解决此问题。</string>
-    <string name="disable_encryption_message">是否确定要禁用此对话的 OMEMO 加密?
-\n将允许服务器管理员读取您的消息,但可能是与使用过时客户端的用户交流的唯一方法。</string>
+    <string name="disable_encryption_message">是否确定要禁用此对话的 OMEMO 加密?\n这将允许服务器管理员读取您的消息,但可能是与使用过时客户端的用户交流的唯一方式。</string>
     <string name="disable_now">立即禁用</string>
     <string name="draft">草稿:</string>
     <string name="pref_omemo_setting">OMEMO 加密</string>
     <string name="pref_omemo_setting_summary_always">OMEMO 将始终用于一对一聊天和私人群聊。</string>
     <string name="pref_omemo_setting_summary_default_on">新对话将默认使用 OMEMO。</string>
-    <string name="pref_omemo_setting_summary_default_off">新对话必须明确开启 OMEMO。</string>
+    <string name="pref_omemo_setting_summary_default_off">新对话必须手动启用 OMEMO。</string>
     <string name="create_shortcut">创建快捷方式</string>
-    <string name="default_on">默认开启</string>
-    <string name="default_off">默认关闭</string>
+    <string name="default_on">默认启用</string>
+    <string name="default_off">默认禁用</string>
     <string name="not_encrypted_for_this_device">未对此设备加密消息。</string>
     <string name="omemo_decryption_failed">无法解密 OMEMO 消息。</string>
     <string name="undo">撤销</string>
@@ -820,12 +819,12 @@
     <string name="ebook">电子书</string>
     <string name="video_original">未压缩(原始)</string>
     <string name="open_with">打开…</string>
-    <string name="set_profile_picture">Conversations 个人资料照片</string>
+    <string name="set_profile_picture">头像</string>
     <string name="choose_account">选择账号</string>
     <string name="restore_backup">恢复备份</string>
     <string name="restore">恢复</string>
     <string name="enter_password_to_restore">请输入 %s 的密码以恢复备份。</string>
-    <string name="restore_warning">请勿使用恢复备份功能尝试克隆(同时运行)安装。恢复备份仅适用于迁移或您丢失原始设备的情况。</string>
+    <string name="restore_warning">请勿通过恢复 OMEMO 密钥尝试克隆(同时运行)安装。恢复 OMEMO 密钥仅适用于迁移或您丢失原始设备的情况。</string>
     <string name="unable_to_restore_backup">无法恢复备份。</string>
     <string name="unable_to_decrypt_backup">无法解密备份。密码是否正确?</string>
     <string name="backup_channel_name">备份和恢复</string>
@@ -843,15 +842,15 @@
     <string name="channel_already_exists">此频道已存在</string>
     <string name="joined_an_existing_channel">您已加入现有的频道</string>
     <string name="unable_to_set_channel_configuration">无法保存频道配置</string>
-    <string name="allow_participants_to_edit_subject">允许参与者编辑话题</string>
+    <string name="allow_participants_to_edit_subject">允许参与者编辑主题</string>
     <string name="allow_participants_to_invite_others">允许参与者邀请他人</string>
-    <string name="anyone_can_edit_subject">参与者可以编辑话题。</string>
-    <string name="owners_can_edit_subject">所有者可以编辑话题。</string>
-    <string name="admins_can_edit_subject">管理员可以编辑话题。</string>
+    <string name="anyone_can_edit_subject">参与者可以编辑主题。</string>
+    <string name="owners_can_edit_subject">所有者可以编辑主题。</string>
+    <string name="admins_can_edit_subject">管理员可以编辑主题。</string>
     <string name="owners_can_invite_others">所有者可以邀请他人。</string>
     <string name="anyone_can_invite_others">参与者可以邀请他人。</string>
-    <string name="jabber_ids_are_visible_to_admins">管理员可以看到用户 XMPP 地址。</string>
-    <string name="jabber_ids_are_visible_to_anyone">任何参与者可以看到用户 XMPP 地址。</string>
+    <string name="jabber_ids_are_visible_to_admins">管理员可以查看用户 XMPP 地址。</string>
+    <string name="jabber_ids_are_visible_to_anyone">任何参与者可以查看用户 XMPP 地址。</string>
     <string name="no_users_hint_channel">此公开频道无参与者。邀请联系人或使用分享按钮分发频道的 XMPP 地址。</string>
     <string name="no_users_hint_group_chat">此私人群聊无参与者。</string>
     <string name="manage_permission">管理权限</string>
@@ -951,7 +950,7 @@
         <item quantity="other">查看 %1$d 位参与者</item>
     </plurals>
     <plurals name="some_messages_could_not_be_delivered">
-        <item quantity="other">一些消息发送失败</item>
+        <item quantity="other">部分消息无法成功发送</item>
     </plurals>
     <string name="failed_deliveries">发送失败</string>
     <string name="more_options">更多选项</string>
@@ -983,14 +982,14 @@
     <string name="group_chats">群聊</string>
     <string name="search_group_chats">搜索群聊</string>
     <string name="delete_from_server">从服务器移除账号</string>
-    <string name="restore_warning_continued">请勿尝试恢复您尚未自行创建的备份!</string>
+    <string name="restore_warning_continued">仅恢复您亲自创建的备份。</string>
     <string name="outdated_backup_file_format">您正尝试导入过时的备份文件格式</string>
     <string name="audiobook">有声读物</string>
     <string name="reconnect_on_other_host">在其他主机上重新连接</string>
-    <string name="this_account_is_logged_out">您登出了此账号</string>
-    <string name="log_in">登入</string>
+    <string name="this_account_is_logged_out">您已登出此账号</string>
+    <string name="log_in">登录</string>
     <string name="hide_notification">隐藏通知</string>
-    <string name="contact_uses_unverified_keys">您的联系人使用未经验证的设备。扫描对方二维码进行验证并阻止主动式中间人攻击。</string>
+    <string name="contact_uses_unverified_keys">对方使用未经验证的设备。扫描其二维码进行验证并阻止主动式中间人攻击。</string>
     <string name="log_out">登出</string>
     <string name="account_state_logged_out">已登出</string>
     <string name="unverified_devices">您正在使用未经验证的设备。扫描您其他设备的二维码进行验证并阻止主动式中间人攻击。</string>
@@ -1062,7 +1061,7 @@
     <string name="pref_create_backup_one_off_summary">创建一次性备份</string>
     <string name="pref_backup_recurring">定期备份</string>
     <string name="pref_fullscreen_notification">全屏通知</string>
-    <string name="pref_fullscreen_notification_summary">当设备锁定时,允许此应用显示占据全屏的来电通知。</string>
+    <string name="pref_fullscreen_notification_summary">允许此应用在设备锁定时显示占据整个屏幕的来电通知。</string>
     <string name="unsupported_operation">不支持的操作</string>
     <string name="pref_backup_summary">创建一次性备份、设置定期备份</string>
     <string name="allow_private_messages">允许私信</string>
@@ -1070,7 +1069,7 @@
     <string name="your_avatar_tap_to_select_new_avatar">您的头像。点按即可从图库选择新头像。</string>
     <string name="could_not_disable_video">无法禁用视频。</string>
     <string name="delete_pgp_key">删除 OpenPGP 密钥</string>
-    <string name="edit_name_and_topic">编辑名称和话题</string>
+    <string name="edit_name_and_topic">编辑名称和主题</string>
     <string name="edit_configuration">更改配置</string>
     <string name="change_notification_settings">更改通知设置</string>
     <string name="call_is_using_earpiece">正在使用听筒进行通话。</string>
@@ -1084,27 +1083,39 @@
     <string name="call_is_using_earpiece_tap_to_switch_to_speaker">正在使用听筒进行通话,点按即可切换到扬声器。</string>
     <string name="server_info_login_mechanism">登录机制</string>
     <string name="server_info_bind2">XEP-0386:绑定 2</string>
-    <string name="server_info_sasl2">XEP-0388:可扩展 SASL 配置文件</string>
+    <string name="server_info_sasl2">XEP-0388:可扩展 SASL 配置</string>
     <string name="could_not_add_reaction">无法添加回应</string>
     <string name="add_reaction">添加回应…</string>
     <string name="more_reactions">更多回应</string>
     <string name="add_reaction_title">添加回应</string>
     <string name="could_not_modify_call">无法修改通话</string>
-    <string name="clients_may_not_support_av">您的联系人的 XMPP 客户端可能不支持音频/视频通话。</string>
+    <string name="clients_may_not_support_av">对方的 XMPP 客户端可能不支持音频/视频通话。</string>
     <string name="pref_show_avatars">显示头像</string>
     <string name="pref_chat_bubbles_summary">背景颜色、字体大小、头像</string>
     <string name="pref_chat_bubbles">消息气泡</string>
     <string name="pref_title_bubbles">消息气泡</string>
-    <string name="pref_show_avatars_summary">在群聊和一对一聊天中为消息显示头像。</string>
+    <string name="pref_show_avatars_summary">在群聊和一对一聊天中,在消息旁显示头像。</string>
     <string name="pref_call_integration">通话集成</string>
     <string name="custom_notifications">自定义通知</string>
     <string name="custom_notifications_enable">是否为此对话启用自定义通知(重要程度、声音、振动)设置?</string>
     <string name="pref_call_integration_summary">此应用的通话与常规通话交互,例如另一个通话开始时结束一个通话。</string>
     <string name="pref_align_start_summary">在左侧显示所有消息,包括发送的消息,以实现统一的聊天布局。</string>
     <string name="pref_align_start">左对齐消息</string>
-    <string name="delete_avatar_message">是否删除头像?某些客户端可能会继续显示您头像的缓存副本。</string>
-    <string name="show_to_contacts_only">仅显示给联系人</string>
+    <string name="delete_avatar_message">是否删除头像?部分客户端可能会继续显示已缓存的头像副本。</string>
+    <string name="show_to_contacts_only">仅对联系人显示</string>
     <string name="account_status_connection_timeout">连接超时</string>
     <string name="retry_with_p2p">使用 P2P 重试</string>
     <string name="account_status_channel_binding">不支持通道绑定</string>
+    <string name="word_document">Word 文档</string>
+    <string name="restore_omemo_key">恢复 OMEMO 密钥</string>
+    <string name="non_quicksy_backup">Quicksy 只能恢复 quicksy.im 账号的备份</string>
+    <string name="pref_backup_location">备份位置</string>
+    <string name="uri_copied_to_clipboard">URI 已复制到剪贴板</string>
+    <string name="copy_email_address">复制电子邮件地址</string>
+    <string name="copied_email_address">电子邮件地址已复制到剪贴板</string>
+    <string name="copied_phone_number">电话号码已复制到剪贴板</string>
+    <string name="copy_geo_uri">复制地理位置</string>
+    <string name="uri">URI</string>
+    <string name="copy_URI">复制 URI</string>
+    <string name="copy_telephone_number">复制电话号码</string>
 </resources>

src/main/res/values-zh-rTW/strings.xml 🔗

@@ -432,7 +432,7 @@
     <string name="download_failed_invalid_file">下載失敗:無效的檔案</string>
     <string name="account_status_tor_unavailable">Tor 網路無法使用</string>
     <string name="account_status_bind_failure">繫結失敗</string>
-    <string name="account_status_host_unknown">伺服器無法回應此網域</string>
+    <string name="account_status_host_unknown">無法回應網域</string>
     <string name="server_info_broken">已損毀</string>
     <string name="pref_presence_settings">可用性</string>
     <string name="pref_away_when_screen_off">裝置上鎖時離開</string>
@@ -1099,4 +1099,22 @@
     <string name="call_is_using_speaker">呼叫正在使用揚聲器。</string>
     <string name="could_not_add_reaction">無法添加回應</string>
     <string name="add_reaction">添加回應…</string>
+    <string name="account_status_connection_timeout">連接超時</string>
+    <string name="word_document">Word 文件</string>
+    <string name="custom_notifications">自訂通知</string>
+    <string name="retry_with_p2p">使用 P2P 重試</string>
+    <string name="account_status_channel_binding">通道綁定不可用</string>
+    <string name="custom_notifications_enable">為此對話啟用自定義通知設定(重要性、聲音、振動)設定?</string>
+    <string name="clients_may_not_support_av">您聯絡人的 XMPP 用戶端可能不支援音訊/視訊通話。</string>
+    <string name="pref_align_start">左對齊訊息</string>
+    <string name="pref_align_start_summary">在左側顯示所有訊息,包括已傳送的訊息,以實現統一的聊天佈局。</string>
+    <string name="pref_call_integration">呼叫集成</string>
+    <string name="pref_call_integration_summary">來自此應用程式的呼叫與常規電話交互,例如在另一個呼叫開始時結束一個呼叫。</string>
+    <string name="delete_avatar_message">是否要刪除您的大頭貼?某些用戶端可能會繼續顯示您的大頭貼的緩存副本。</string>
+    <string name="show_to_contacts_only">僅向聯絡人顯示</string>
+    <string name="pref_chat_bubbles">聊天氣泡</string>
+    <string name="pref_chat_bubbles_summary">背景顏色、字體大小、大頭貼</string>
+    <string name="pref_title_bubbles">聊天氣泡</string>
+    <string name="pref_show_avatars">顯示大頭貼</string>
+    <string name="pref_show_avatars_summary">除了群聊之外,還可以顯示您的訊息和 1 對 1 聊天的大頭貼。</string>
 </resources>

src/main/res/values/strings.xml 🔗

@@ -321,6 +321,8 @@
     <string name="retry_with_p2p">Retry with P2P</string>
     <string name="file_url">File URL</string>
     <string name="url_copied_to_clipboard">Copied URL to clipboard</string>
+    <string name="uri_copied_to_clipboard">Copied URI to clipboard</string>
+    <string name="uri">URI</string>
     <string name="jabber_id_copied_to_clipboard">Copied Jabber ID to clipboard</string>
     <string name="error_message_copied_to_clipboard">Copied error message to clipboard</string>
     <string name="web_address">web address</string>
@@ -333,7 +335,7 @@
     <string name="pref_keep_foreground_service">Foreground service</string>
     <string name="pref_keep_foreground_service_summary">Prevents the operating system from killing your connection</string>
     <string name="pref_create_backup">Create backup</string>
-    <string name="pref_create_backup_summary">Backup files will be stored in %s</string>
+    <string name="pref_create_backup_summary">Backups will be stored in %s</string>
     <string name="notification_create_backup_title">Creating backup files</string>
     <string name="notification_backup_created_title">Your backup has been created</string>
     <string name="notification_backup_created_subtitle">The backup files have been stored in %s</string>
@@ -424,6 +426,7 @@
     <string name="vector_graphic">vector graphic</string>
     <string name="multimedia_file">multimedia file</string>
     <string name="pdf_document">PDF document</string>
+    <string name="word_document">Word document</string>
     <string name="apk">Android App</string>
     <string name="audiobook">Audiobook</string>
     <string name="vcard">Contact</string>
@@ -753,6 +756,12 @@
     <string name="pref_use_share_location_plugin_summary">Use the Share Location Plugin instead of the built-in map</string>
     <string name="copy_link">Copy web address</string>
     <string name="copy_jabber_id">Copy Jabber ID</string>
+    <string name="copy_URI">Copy URI</string>
+    <string name="copy_telephone_number">Copy phone number</string>
+    <string name="copy_geo_uri">Copy geo location</string>
+    <string name="copy_email_address">Copy email address</string>
+    <string name="copied_email_address">Copied email address to clipboard</string>
+    <string name="copied_phone_number">Copied phone number to clipboard</string>
     <string name="p1_s3_filetransfer">HTTP File Sharing for S3</string>
     <string name="pref_start_search">Direct Search</string>
     <string name="pref_start_search_summary">At ‘New chat’ screen open keyboard and place cursor in search field</string>
@@ -848,13 +857,14 @@
     <string name="ebook">e-book</string>
     <string name="video_original">Original (uncompressed)</string>
     <string name="open_with">Open with…</string>
-    <string name="set_profile_picture">Conversations profile picture</string>
+    <string name="set_profile_picture">Avatar</string>
     <string name="choose_account">Choose account</string>
     <string name="restore_backup">Restore backup</string>
     <string name="restore">Restore</string>
     <string name="enter_password_to_restore">Enter your password for the account %s to restore the backup.</string>
-    <string name="restore_warning">Do not use the restore backup feature in an attempt to clone (run simultaneously) an installation. Restoring a backup is only meant for migrations or in case you’ve lost the original device.</string>
-    <string name="restore_warning_continued">Do not attempt to restore backups that you have not created yourself!</string>
+    <string name="restore_omemo_key">Restore OMEMO keys</string>
+    <string name="restore_warning">Do not restore OMEMO keys in an attempt to clone (run simultaneously) an installation. Restoring OMEMO keys is only meant for migrations or in case you’ve lost the original device.</string>
+    <string name="restore_warning_continued">Only restore backups you’ve personally created.</string>
     <string name="unable_to_restore_backup">Could not restore backup.</string>
     <string name="unable_to_decrypt_backup">Could not decrypt backup. Is the password correct?</string>
     <string name="backup_channel_name">Backup &amp; Restore</string>
@@ -904,6 +914,7 @@
     <string name="open_backup">Open backup</string>
     <string name="not_a_backup_file">The file you selected is not a Conversations backup file</string>
     <string name="outdated_backup_file_format">You are trying to import an outdated backup file format</string>
+    <string name="non_quicksy_backup">Quicksy can only restore backups for quicksy.im accounts</string>
     <string name="account_already_setup">This account has already been setup</string>
     <string name="please_enter_password">Please enter the password for this account</string>
     <string name="unable_to_perform_this_action">Could not perform this action</string>
@@ -1115,4 +1126,5 @@
     <string name="custom_notifications_enable">Enable customized notification settings (importance, sound, vibration) settings for this conversation?</string>
     <string name="delete_avatar_message">Would you like to delete your avatar? Some clients might continue to display a cached copy of your avatar.</string>
     <string name="show_to_contacts_only">Show to contacts only</string>
+    <string name="pref_backup_location">Backup location</string>
 </resources>

src/main/res/xml/preferences_backup.xml 🔗

@@ -26,5 +26,9 @@
     <Preference
         android:key="backup_directory"
         android:summary="@string/pref_create_backup_summary" />
+        android:icon="@drawable/ic_folder_open_24dp"
+        android:key="backup_location"
+        android:summary="@string/pref_create_backup_summary"
+        android:title="@string/pref_backup_location" />
 
 </PreferenceScreen>

src/quicksy/fastlane/metadata/android/iw-IL/full_description.txt 🔗

@@ -0,0 +1,38 @@
+קל לשימוש, אמין, ידידותי לסוללה.  עם תמיכה מובנית בתמונות, צ'אטים קבוצתיים והצפנת e2e.
+
+עקרונות עיצוב:
+
+* היה כמה שיותר יפה וקל לשימוש מבלי לוותר על אבטחה או פרטיות
+* הסתמכו על פרוטוקולים קיימים ומבוססים היטב
+* אין צורך בחשבון Google או ספציפית Google Cloud Messaging (GCM)
+* דרוש כמה שפחות הרשאות
+
+תכונות:
+
+* הצפנה מקצה לקצה באמצעות <a href="http://conversations.im/omemo/">OMEMO</a> או <a href="http://openpgp.org/about/">OpenPGP</a>
+* שליחת וקבלת תמונות
+* שיחות שמע ווידאו מוצפנות (DTLS-SRTP)
+* ממשק משתמש אינטואיטיבי העומד בהנחיות לעיצוב אנדרואיד
+* תמונות / אווטארים עבור אנשי הקשר שלך
+* מסתנכרן עם לקוח שולחן העבודה * ועידות (עם תמיכה בסימניות)
+* שילוב ספר כתובות
+* מספר חשבונות / תיבת דואר נכנס מאוחדת
+* השפעה נמוכה מאוד על חיי הסוללה
+
+Conversations מקלה מאוד על יצירת חשבון בשרת conversations.im החינמי.  עם זאת, שיחות יעבדו גם עם כל שרת XMPP אחר.  שרתי XMPP רבים מנוהלים על ידי מתנדבים והם ללא תשלום.
+
+תכונות XMPP:
+
+Conversations עובדות עם כל שרת XMPP בחוץ.  עם זאת XMPP הוא פרוטוקול הניתן להרחבה. הרחבות אלה סטנדרטיות גם במה שנקרא XEP's. שיחות תומכות בכמה כאלה כדי לשפר את חווית המשתמש הכוללת. יש סיכוי ששרת ה-XMPP הנוכחי שלך אינו תומך בהרחבות אלו. כן כדי להפיק את המרב משיחות, עליך לשקול לעבור לשרת XMPP שעושה זאת או - אפילו טוב יותר - להפעיל שרת XMPP משלך עבורך ועבור חבריך.
+
+XEPs אלה הם - נכון לעכשיו:
+
+* XEP-0065: SOCKS5 Bytestreams (או mod_proxy65). ישמש להעברת קבצים אם שני הצדדים נמצאים מאחורי חומת אש או NAT.
+* XEP-0163: פרוטוקול אירועים אישיים לאוואטרים
+* XEP-0191: פקודת חסימה מאפשרת לך לרשום שולחי דואר זבל או לחסום אנשי קשר מבלי להסיר אותם מהסגל שלך.
+* XEP-0198: ניהול זרמים מאפשר ל-XMPP לשרוד הפסקות רשת קטנות ושינויים בחיבור ה-TCP הבסיסי.
+* XEP-0280: Message Carbons שמסנכרן אוטומטית את ההודעות שאתה שולח ללקוח שולחן העבודה שלך ובכך מאפשר לך לעבור בצורה חלקה מהלקוח הנייד שלך ללקוח שולחן העבודה שלך ובחזרה תוך שיחה אחת.
+* XEP-0237: גרסת רוסטר בעיקר כדי לחסוך ברוחב פס בחיבורים ניידים גרועים
+* XEP-0313: ניהול ארכיון הודעות סנכרן את היסטוריית ההודעות עם השרת.  התעדכן בהודעות שנשלחו בזמן ששיחות היו במצב לא מקוון.
+* XEP-0352: חיווי מצב לקוח מאפשר לשרת לדעת אם שיחות נמצאות ברקע או לא. מאפשר לשרת לחסוך ברוחב פס על ידי מניעת חבילות לא חשובות.
+* XEP-0363: העלאת קבצי HTTP מאפשרת לך לשתף קבצים בוועידות ועם אנשי קשר לא מקוונים. דורש רכיב נוסף בשרת שלך.

src/quicksy/java/eu/siacs/conversations/services/QuickConversationsService.java 🔗

@@ -1,21 +1,17 @@
 package eu.siacs.conversations.services;
 
-
 import static eu.siacs.conversations.utils.Random.SECURE_RANDOM;
 
 import android.content.Context;
 import android.content.Intent;
 import android.net.Uri;
-import android.os.Build;
 import android.os.Bundle;
 import android.os.SystemClock;
 import android.util.Log;
-
 import com.google.common.collect.ImmutableMap;
-
+import de.gultsch.common.TrustManagers;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.android.PhoneNumberContact;
-import eu.siacs.conversations.crypto.TrustManagers;
 import eu.siacs.conversations.crypto.sasl.Plain;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Contact;
@@ -30,11 +26,8 @@ import eu.siacs.conversations.utils.TLSSocketFactory;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
-
 import im.conversations.android.xmpp.model.stanza.Iq;
-
 import io.michaelrocks.libphonenumber.android.Phonenumber;
-
 import java.io.BufferedWriter;
 import java.io.IOException;
 import java.io.OutputStream;
@@ -63,7 +56,6 @@ import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
-
 import javax.net.ssl.HttpsURLConnection;
 import javax.net.ssl.SSLException;
 import javax.net.ssl.SSLHandshakeException;
@@ -73,7 +65,6 @@ import javax.net.ssl.X509TrustManager;
 
 public class QuickConversationsService extends AbstractQuickConversationsService {
 
-
     public static final int API_ERROR_OTHER = -1;
     public static final int API_ERROR_UNKNOWN_HOST = -2;
     public static final int API_ERROR_CONNECT = -3;
@@ -87,8 +78,10 @@ public class QuickConversationsService extends AbstractQuickConversationsService
 
     private static final String BASE_URL = "https://" + API_DOMAIN;
 
-    private final Set<OnVerificationRequested> mOnVerificationRequested = Collections.newSetFromMap(new WeakHashMap<>());
-    private final Set<OnVerification> mOnVerification = Collections.newSetFromMap(new WeakHashMap<>());
+    private final Set<OnVerificationRequested> mOnVerificationRequested =
+            Collections.newSetFromMap(new WeakHashMap<>());
+    private final Set<OnVerification> mOnVerification =
+            Collections.newSetFromMap(new WeakHashMap<>());
 
     private final AtomicBoolean mVerificationInProgress = new AtomicBoolean(false);
     private final AtomicBoolean mVerificationRequestInProgress = new AtomicBoolean(false);
@@ -97,7 +90,8 @@ public class QuickConversationsService extends AbstractQuickConversationsService
 
     private Attempt mLastSyncAttempt = Attempt.NULL;
 
-    private final SerialSingleThreadExecutor mSerialSingleThreadExecutor = new SerialSingleThreadExecutor(QuickConversationsService.class.getSimpleName());
+    private final SerialSingleThreadExecutor mSerialSingleThreadExecutor =
+            new SerialSingleThreadExecutor(QuickConversationsService.class.getSimpleName());
 
     QuickConversationsService(XmppConnectionService xmppConnectionService) {
         super(xmppConnectionService);
@@ -105,19 +99,22 @@ public class QuickConversationsService extends AbstractQuickConversationsService
 
     private static long retryAfter(HttpURLConnection connection) {
         try {
-            return SystemClock.elapsedRealtime() + (Long.parseLong(connection.getHeaderField("Retry-After")) * 1000L);
+            return SystemClock.elapsedRealtime()
+                    + (Long.parseLong(connection.getHeaderField("Retry-After")) * 1000L);
         } catch (Exception e) {
             return 0;
         }
     }
 
-    public void addOnVerificationRequestedListener(OnVerificationRequested onVerificationRequested) {
+    public void addOnVerificationRequestedListener(
+            OnVerificationRequested onVerificationRequested) {
         synchronized (mOnVerificationRequested) {
             mOnVerificationRequested.add(onVerificationRequested);
         }
     }
 
-    public void removeOnVerificationRequestedListener(OnVerificationRequested onVerificationRequested) {
+    public void removeOnVerificationRequestedListener(
+            OnVerificationRequested onVerificationRequested) {
         synchronized (mOnVerificationRequested) {
             mOnVerificationRequested.remove(onVerificationRequested);
         }
@@ -139,62 +136,63 @@ public class QuickConversationsService extends AbstractQuickConversationsService
         final String e164 = PhoneNumberUtilWrapper.normalize(service, phoneNumber);
         if (mVerificationRequestInProgress.compareAndSet(false, true)) {
             SmsRetrieverWrapper.start(service);
-            new Thread(() -> {
-                try {
-                    final URL url = new URL(BASE_URL + "/authentication/" + e164);
-                    final HttpURLConnection connection = (HttpURLConnection) url.openConnection();
-                    setBundledLetsEncrypt(service, connection);
-                    connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
-                    connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
-                    setHeader(connection);
-                    final int code = connection.getResponseCode();
-                    if (code == 200) {
-                        createAccountAndWait(phoneNumber, 0L);
-                    } else if (code == 429) {
-                        createAccountAndWait(phoneNumber, retryAfter(connection));
-                    } else {
-                        synchronized (mOnVerificationRequested) {
-                            for (OnVerificationRequested onVerificationRequested : mOnVerificationRequested) {
-                                onVerificationRequested.onVerificationRequestFailed(code);
-                            }
-                        }
-                    }
-                } catch (IOException e) {
-                    final int code = getApiErrorCode(e);
-                    synchronized (mOnVerificationRequested) {
-                        for (OnVerificationRequested onVerificationRequested : mOnVerificationRequested) {
-                            onVerificationRequested.onVerificationRequestFailed(code);
-                        }
-                    }
-                } finally {
-                    mVerificationRequestInProgress.set(false);
-                }
-            }).start();
+            new Thread(
+                            () -> {
+                                try {
+                                    final URL url = new URL(BASE_URL + "/authentication/" + e164);
+                                    final HttpURLConnection connection =
+                                            (HttpURLConnection) url.openConnection();
+                                    setBundledLetsEncrypt(service, connection);
+                                    connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
+                                    connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
+                                    setHeader(connection);
+                                    final int code = connection.getResponseCode();
+                                    if (code == 200) {
+                                        createAccountAndWait(phoneNumber, 0L);
+                                    } else if (code == 429) {
+                                        createAccountAndWait(phoneNumber, retryAfter(connection));
+                                    } else {
+                                        synchronized (mOnVerificationRequested) {
+                                            for (OnVerificationRequested onVerificationRequested :
+                                                    mOnVerificationRequested) {
+                                                onVerificationRequested.onVerificationRequestFailed(
+                                                        code);
+                                            }
+                                        }
+                                    }
+                                } catch (IOException e) {
+                                    final int code = getApiErrorCode(e);
+                                    synchronized (mOnVerificationRequested) {
+                                        for (OnVerificationRequested onVerificationRequested :
+                                                mOnVerificationRequested) {
+                                            onVerificationRequested.onVerificationRequestFailed(
+                                                    code);
+                                        }
+                                    }
+                                } finally {
+                                    mVerificationRequestInProgress.set(false);
+                                }
+                            })
+                    .start();
         }
     }
 
     private static void setBundledLetsEncrypt(
             final Context context, final HttpURLConnection connection) {
         if (connection instanceof HttpsURLConnection httpsURLConnection) {
-            final X509TrustManager trustManager;
-            if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) {
-                try {
-                    trustManager = TrustManagers.defaultWithBundledLetsEncrypt(context);
-                } catch (final NoSuchAlgorithmException
-                        | KeyStoreException
-                        | CertificateException
-                        | IOException e) {
-                    Log.e(Config.LOGTAG, "could not configured bundled LetsEncrypt", e);
-                    return;
-                }
-            } else {
-                return;
-            }
             final SSLSocketFactory socketFactory;
             try {
                 socketFactory =
-                        new TLSSocketFactory(new X509TrustManager[] {trustManager}, SECURE_RANDOM);
-            } catch (final KeyManagementException | NoSuchAlgorithmException e) {
+                        new TLSSocketFactory(
+                                new X509TrustManager[] {
+                                    TrustManagers.createForAndroidVersion(context)
+                                },
+                                SECURE_RANDOM);
+            } catch (final KeyManagementException
+                    | NoSuchAlgorithmException
+                    | KeyStoreException
+                    | CertificateException
+                    | IOException e) {
                 Log.e(Config.LOGTAG, "could not configured bundled LetsEncrypt", e);
                 return;
             }
@@ -211,7 +209,10 @@ public class QuickConversationsService extends AbstractQuickConversationsService
 
     private void createAccountAndWait(Phonenumber.PhoneNumber phoneNumber, final long timestamp) {
         String local = PhoneNumberUtilWrapper.normalize(service, phoneNumber);
-        Log.d(Config.LOGTAG, "requesting verification for " + PhoneNumberUtilWrapper.normalize(service, phoneNumber));
+        Log.d(
+                Config.LOGTAG,
+                "requesting verification for "
+                        + PhoneNumberUtilWrapper.normalize(service, phoneNumber));
         Jid jid = Jid.of(local, Config.QUICKSY_DOMAIN, null);
         Account account = AccountUtils.getFirst(service);
         if (account == null || !account.getJid().asBareJid().equals(jid.asBareJid())) {
@@ -237,64 +238,74 @@ public class QuickConversationsService extends AbstractQuickConversationsService
 
     public void verify(final Account account, String pin) {
         if (mVerificationInProgress.compareAndSet(false, true)) {
-            new Thread(() -> {
-                try {
-                    final URL url = new URL(BASE_URL + "/password");
-                    final HttpURLConnection connection = (HttpURLConnection) url.openConnection();
-                    setBundledLetsEncrypt(service, connection);
-                    connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
-                    connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
-                    connection.setRequestMethod("POST");
-                    connection.setRequestProperty("Authorization", Plain.getMessage(account.getUsername(), pin));
-                    setHeader(connection);
-                    final OutputStream os = connection.getOutputStream();
-                    final BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(os, "UTF-8"));
-                    writer.write(account.getPassword());
-                    writer.flush();
-                    writer.close();
-                    os.close();
-                    connection.connect();
-                    final int code = connection.getResponseCode();
-                    if (code == 200 || code == 201) {
-                        account.setOption(Account.OPTION_UNVERIFIED, false);
-                        account.setOption(Account.OPTION_DISABLED, false);
-                        awaitingAccountStateChange = new CountDownLatch(1);
-                        service.updateAccount(account);
-                        try {
-                            awaitingAccountStateChange.await(5, TimeUnit.SECONDS);
-                        } catch (InterruptedException e) {
-                            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": timer expired while waiting for account to connect");
-                        }
-                        synchronized (mOnVerification) {
-                            for (OnVerification onVerification : mOnVerification) {
-                                onVerification.onVerificationSucceeded();
-                            }
-                        }
-                    } else if (code == 429) {
-                        final long retryAfter = retryAfter(connection);
-                        synchronized (mOnVerification) {
-                            for (OnVerification onVerification : mOnVerification) {
-                                onVerification.onVerificationRetryAt(retryAfter);
-                            }
-                        }
-                    } else {
-                        synchronized (mOnVerification) {
-                            for (OnVerification onVerification : mOnVerification) {
-                                onVerification.onVerificationFailed(code);
-                            }
-                        }
-                    }
-                } catch (IOException e) {
-                    final int code = getApiErrorCode(e);
-                    synchronized (mOnVerification) {
-                        for (OnVerification onVerification : mOnVerification) {
-                            onVerification.onVerificationFailed(code);
-                        }
-                    }
-                } finally {
-                    mVerificationInProgress.set(false);
-                }
-            }).start();
+            new Thread(
+                            () -> {
+                                try {
+                                    final URL url = new URL(BASE_URL + "/password");
+                                    final HttpURLConnection connection =
+                                            (HttpURLConnection) url.openConnection();
+                                    setBundledLetsEncrypt(service, connection);
+                                    connection.setConnectTimeout(Config.SOCKET_TIMEOUT * 1000);
+                                    connection.setReadTimeout(Config.SOCKET_TIMEOUT * 1000);
+                                    connection.setRequestMethod("POST");
+                                    connection.setRequestProperty(
+                                            "Authorization",
+                                            Plain.getMessage(account.getUsername(), pin));
+                                    setHeader(connection);
+                                    final OutputStream os = connection.getOutputStream();
+                                    final BufferedWriter writer =
+                                            new BufferedWriter(new OutputStreamWriter(os, "UTF-8"));
+                                    writer.write(account.getPassword());
+                                    writer.flush();
+                                    writer.close();
+                                    os.close();
+                                    connection.connect();
+                                    final int code = connection.getResponseCode();
+                                    if (code == 200 || code == 201) {
+                                        account.setOption(Account.OPTION_UNVERIFIED, false);
+                                        account.setOption(Account.OPTION_DISABLED, false);
+                                        awaitingAccountStateChange = new CountDownLatch(1);
+                                        service.updateAccount(account);
+                                        try {
+                                            awaitingAccountStateChange.await(5, TimeUnit.SECONDS);
+                                        } catch (InterruptedException e) {
+                                            Log.d(
+                                                    Config.LOGTAG,
+                                                    account.getJid().asBareJid()
+                                                            + ": timer expired while waiting for"
+                                                            + " account to connect");
+                                        }
+                                        synchronized (mOnVerification) {
+                                            for (OnVerification onVerification : mOnVerification) {
+                                                onVerification.onVerificationSucceeded();
+                                            }
+                                        }
+                                    } else if (code == 429) {
+                                        final long retryAfter = retryAfter(connection);
+                                        synchronized (mOnVerification) {
+                                            for (OnVerification onVerification : mOnVerification) {
+                                                onVerification.onVerificationRetryAt(retryAfter);
+                                            }
+                                        }
+                                    } else {
+                                        synchronized (mOnVerification) {
+                                            for (OnVerification onVerification : mOnVerification) {
+                                                onVerification.onVerificationFailed(code);
+                                            }
+                                        }
+                                    }
+                                } catch (IOException e) {
+                                    final int code = getApiErrorCode(e);
+                                    synchronized (mOnVerification) {
+                                        for (OnVerification onVerification : mOnVerification) {
+                                            onVerification.onVerificationFailed(code);
+                                        }
+                                    }
+                                } finally {
+                                    mVerificationInProgress.set(false);
+                                }
+                            })
+                    .start();
         }
     }
 
@@ -339,7 +350,6 @@ public class QuickConversationsService extends AbstractQuickConversationsService
         return mVerificationRequestInProgress.get();
     }
 
-
     @Override
     public boolean isSynchronizing() {
         return mRunningSyncJobs.get() > 0;
@@ -353,12 +363,13 @@ public class QuickConversationsService extends AbstractQuickConversationsService
     @Override
     public void considerSyncBackground(final boolean forced) {
         mRunningSyncJobs.incrementAndGet();
-        mSerialSingleThreadExecutor.execute(() -> {
-            considerSync(forced);
-            if (mRunningSyncJobs.decrementAndGet() == 0) {
-                service.updateRosterUi();
-            }
-        });
+        mSerialSingleThreadExecutor.execute(
+                () -> {
+                    considerSync(forced);
+                    if (mRunningSyncJobs.decrementAndGet() == 0) {
+                        service.updateRosterUi();
+                    }
+                });
     }
 
     @Override
@@ -380,16 +391,19 @@ public class QuickConversationsService extends AbstractQuickConversationsService
                 onVerification.startBackgroundVerification(pin);
             }
         }
-
     }
 
-
     private void considerSync(boolean forced) {
-        final ImmutableMap<String, PhoneNumberContact> allContacts = PhoneNumberContact.load(service);
+        final ImmutableMap<String, PhoneNumberContact> allContacts =
+                PhoneNumberContact.load(service);
         for (final Account account : service.getAccounts()) {
-            final Map<String, PhoneNumberContact> contacts = filtered(allContacts, account.getJid().getLocal());
+            final Map<String, PhoneNumberContact> contacts =
+                    filtered(allContacts, account.getJid().getLocal());
             if (contacts.size() < allContacts.size()) {
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": found own phone number in address book. ignoring...");
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid()
+                                + ": found own phone number in address book. ignoring...");
             }
             refresh(account, contacts.values());
             if (!considerSync(account, contacts, forced)) {
@@ -408,17 +422,24 @@ public class QuickConversationsService extends AbstractQuickConversationsService
     }
 
     private void refresh(Account account, Collection<PhoneNumberContact> contacts) {
-        for (Contact contact : account.getRoster().getWithSystemAccounts(PhoneNumberContact.class)) {
+        for (Contact contact :
+                account.getRoster().getWithSystemAccounts(PhoneNumberContact.class)) {
             final Uri uri = contact.getSystemAccount();
             if (uri == null) {
                 continue;
             }
             final String number = getNumber(contact);
-            final PhoneNumberContact phoneNumberContact = PhoneNumberContact.findByUriOrNumber(contacts, uri, number);
+            final PhoneNumberContact phoneNumberContact =
+                    PhoneNumberContact.findByUriOrNumber(contacts, uri, number);
             final boolean needsCacheClean;
             if (phoneNumberContact != null) {
                 if (!uri.equals(phoneNumberContact.getLookupUri())) {
-                    Log.d(Config.LOGTAG, "lookupUri has changed from " + uri + " to " + phoneNumberContact.getLookupUri());
+                    Log.d(
+                            Config.LOGTAG,
+                            "lookupUri has changed from "
+                                    + uri
+                                    + " to "
+                                    + phoneNumberContact.getLookupUri());
                 }
                 needsCacheClean = contact.setPhoneContact(phoneNumberContact);
             } else {
@@ -439,7 +460,10 @@ public class QuickConversationsService extends AbstractQuickConversationsService
         return null;
     }
 
-    private boolean considerSync(final Account account, final Map<String, PhoneNumberContact> contacts, final boolean forced) {
+    private boolean considerSync(
+            final Account account,
+            final Map<String, PhoneNumberContact> contacts,
+            final boolean forced) {
         final int hash = contacts.keySet().hashCode();
         Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": consider sync of " + hash);
         if (!mLastSyncAttempt.retry(hash) && !forced) {
@@ -448,59 +472,79 @@ public class QuickConversationsService extends AbstractQuickConversationsService
         }
         mRunningSyncJobs.incrementAndGet();
         final Jid syncServer = Jid.of(API_DOMAIN);
-        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": sending phone list to " + syncServer);
+        Log.d(
+                Config.LOGTAG,
+                account.getJid().asBareJid() + ": sending phone list to " + syncServer);
         final List<Element> entries = new ArrayList<>();
         for (final PhoneNumberContact c : contacts.values()) {
             entries.add(new Element("entry").setAttribute("number", c.getPhoneNumber()));
         }
         final Iq query = new Iq(Iq.Type.GET);
         query.setTo(syncServer);
-        final Element book = new Element("phone-book", Namespace.SYNCHRONIZATION).setChildren(entries);
-        final String statusQuo = Entry.statusQuo(contacts.values(), account.getRoster().getWithSystemAccounts(PhoneNumberContact.class));
+        final Element book =
+                new Element("phone-book", Namespace.SYNCHRONIZATION).setChildren(entries);
+        final String statusQuo =
+                Entry.statusQuo(
+                        contacts.values(),
+                        account.getRoster().getWithSystemAccounts(PhoneNumberContact.class));
         book.setAttribute("ver", statusQuo);
         query.addChild(book);
         mLastSyncAttempt = Attempt.create(hash);
-        service.sendIqPacket(account, query, (response) -> {
-            if (response.getType() == Iq.Type.RESULT) {
-                final Element phoneBook = response.findChild("phone-book", Namespace.SYNCHRONIZATION);
-                if (phoneBook != null) {
-                    final List<Contact> withSystemAccounts = account.getRoster().getWithSystemAccounts(PhoneNumberContact.class);
-                    for (Entry entry : Entry.ofPhoneBook(phoneBook)) {
-                        final PhoneNumberContact phoneContact = contacts.get(entry.getNumber());
-                        if (phoneContact == null) {
-                            continue;
-                        }
-                        for (final Jid jid : entry.getJids()) {
-                            final Contact contact = account.getRoster().getContact(jid);
-                            final boolean needsCacheClean = contact.setPhoneContact(phoneContact);
-                            if (needsCacheClean) {
-                                service.getAvatarService().clear(contact);
+        service.sendIqPacket(
+                account,
+                query,
+                (response) -> {
+                    if (response.getType() == Iq.Type.RESULT) {
+                        final Element phoneBook =
+                                response.findChild("phone-book", Namespace.SYNCHRONIZATION);
+                        if (phoneBook != null) {
+                            final List<Contact> withSystemAccounts =
+                                    account.getRoster()
+                                            .getWithSystemAccounts(PhoneNumberContact.class);
+                            for (Entry entry : Entry.ofPhoneBook(phoneBook)) {
+                                final PhoneNumberContact phoneContact =
+                                        contacts.get(entry.getNumber());
+                                if (phoneContact == null) {
+                                    continue;
+                                }
+                                for (final Jid jid : entry.getJids()) {
+                                    final Contact contact = account.getRoster().getContact(jid);
+                                    final boolean needsCacheClean =
+                                            contact.setPhoneContact(phoneContact);
+                                    if (needsCacheClean) {
+                                        service.getAvatarService().clear(contact);
+                                    }
+                                    withSystemAccounts.remove(contact);
+                                }
                             }
-                            withSystemAccounts.remove(contact);
-                        }
-                    }
-                    for (final Contact contact : withSystemAccounts) {
-                        final boolean needsCacheClean = contact.unsetPhoneContact(PhoneNumberContact.class);
-                        if (needsCacheClean) {
-                            service.getAvatarService().clear(contact);
+                            for (final Contact contact : withSystemAccounts) {
+                                final boolean needsCacheClean =
+                                        contact.unsetPhoneContact(PhoneNumberContact.class);
+                                if (needsCacheClean) {
+                                    service.getAvatarService().clear(contact);
+                                }
+                            }
+                        } else {
+                            Log.d(
+                                    Config.LOGTAG,
+                                    account.getJid().asBareJid()
+                                            + ": phone number contact list remains unchanged");
                         }
+                    } else if (response.getType() == Iq.Type.TIMEOUT) {
+                        mLastSyncAttempt = Attempt.NULL;
+                    } else {
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid()
+                                        + ": failed to sync contact list with api server");
                     }
-                } else {
-                    Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": phone number contact list remains unchanged");
-                }
-            } else if (response.getType() == Iq.Type.TIMEOUT) {
-                mLastSyncAttempt = Attempt.NULL;
-            } else {
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": failed to sync contact list with api server");
-            }
-            mRunningSyncJobs.decrementAndGet();
-            service.syncRoster(account);
-            service.updateRosterUi();
-        });
+                    mRunningSyncJobs.decrementAndGet();
+                    service.syncRoster(account);
+                    service.updateRosterUi();
+                });
         return true;
     }
 
-
     public interface OnVerificationRequested {
         void onVerificationRequestFailed(int code);
 
@@ -535,7 +579,9 @@ public class QuickConversationsService extends AbstractQuickConversationsService
         }
 
         public boolean retry(int hash) {
-            return hash != this.hash || SystemClock.elapsedRealtime() - timestamp >= Config.CONTACT_SYNC_RETRY_INTERVAL;
+            return hash != this.hash
+                    || SystemClock.elapsedRealtime() - timestamp
+                            >= Config.CONTACT_SYNC_RETRY_INTERVAL;
         }
     }
-}
+}

src/quicksy/java/eu/siacs/conversations/ui/EnterPhoneNumberActivity.java 🔗

@@ -1,10 +1,10 @@
 package eu.siacs.conversations.ui;
 
+import static eu.siacs.conversations.utils.PermissionUtils.allGranted;
+
+import android.Manifest;
 import android.app.AlertDialog;
 import android.content.Intent;
-
-import androidx.annotation.NonNull;
-import androidx.databinding.DataBindingUtil;
 import android.os.Bundle;
 import android.text.Editable;
 import android.text.Html;
@@ -12,11 +12,13 @@ import android.text.TextUtils;
 import android.text.TextWatcher;
 import android.util.Log;
 import android.view.KeyEvent;
+import android.view.Menu;
+import android.view.MenuItem;
 import android.view.View;
 import android.widget.EditText;
-
-import java.util.concurrent.atomic.AtomicBoolean;
-
+import android.widget.Toast;
+import androidx.annotation.NonNull;
+import androidx.databinding.DataBindingUtil;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.ActivityEnterNumberBinding;
@@ -30,57 +32,73 @@ import eu.siacs.conversations.utils.PhoneNumberUtilWrapper;
 import io.michaelrocks.libphonenumber.android.NumberParseException;
 import io.michaelrocks.libphonenumber.android.PhoneNumberUtil;
 import io.michaelrocks.libphonenumber.android.Phonenumber;
+import java.util.Arrays;
+import java.util.concurrent.atomic.AtomicBoolean;
 
-public class EnterPhoneNumberActivity extends XmppActivity implements QuickConversationsService.OnVerificationRequested {
+public class EnterPhoneNumberActivity extends XmppActivity
+        implements QuickConversationsService.OnVerificationRequested {
 
     private static final int REQUEST_CHOOSE_COUNTRY = 0x1234;
+    private static final int REQUEST_IMPORT_BACKUP = 0x63fb;
 
     private ActivityEnterNumberBinding binding;
 
     private final AtomicBoolean redirectInProgress = new AtomicBoolean(false);
 
     private String region = null;
-    private final TextWatcher countryCodeTextWatcher = new TextWatcher() {
-        @Override
-        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
-
-        }
-
-        @Override
-        public void onTextChanged(CharSequence s, int start, int before, int count) {
-
-        }
-
-        @Override
-        public void afterTextChanged(Editable editable) {
-            final String text = editable.toString();
-            try {
-                final int oldCode = region != null ? PhoneNumberUtilWrapper.getInstance(EnterPhoneNumberActivity.this).getCountryCodeForRegion(region) : 0;
-                final int code = Integer.parseInt(text);
-                if (oldCode != code) {
-                    region = PhoneNumberUtilWrapper.getInstance(EnterPhoneNumberActivity.this).getRegionCodeForCountryCode(code);
+    private final TextWatcher countryCodeTextWatcher =
+            new TextWatcher() {
+                @Override
+                public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+
+                @Override
+                public void onTextChanged(CharSequence s, int start, int before, int count) {}
+
+                @Override
+                public void afterTextChanged(Editable editable) {
+                    final String text = editable.toString();
+                    try {
+                        final int oldCode =
+                                region != null
+                                        ? PhoneNumberUtilWrapper.getInstance(
+                                                        EnterPhoneNumberActivity.this)
+                                                .getCountryCodeForRegion(region)
+                                        : 0;
+                        final int code = Integer.parseInt(text);
+                        if (oldCode != code) {
+                            region =
+                                    PhoneNumberUtilWrapper.getInstance(
+                                                    EnterPhoneNumberActivity.this)
+                                            .getRegionCodeForCountryCode(code);
+                        }
+                        if ("ZZ".equals(region)) {
+                            binding.country.setText(
+                                    TextUtils.isEmpty(text)
+                                            ? R.string.choose_a_country
+                                            : R.string.invalid_country_code);
+                        } else {
+                            binding.number.requestFocus();
+                            binding.country.setText(
+                                    PhoneNumberUtilWrapper.getCountryForCode(region));
+                        }
+                    } catch (NumberFormatException e) {
+                        binding.country.setText(
+                                TextUtils.isEmpty(text)
+                                        ? R.string.choose_a_country
+                                        : R.string.invalid_country_code);
+                    }
                 }
-                if ("ZZ".equals(region)) {
-                    binding.country.setText(TextUtils.isEmpty(text) ? R.string.choose_a_country : R.string.invalid_country_code);
-                } else {
-                    binding.number.requestFocus();
-                    binding.country.setText(PhoneNumberUtilWrapper.getCountryForCode(region));
-                }
-            } catch (NumberFormatException e) {
-                binding.country.setText(TextUtils.isEmpty(text) ? R.string.choose_a_country : R.string.invalid_country_code);
-            }
-        }
-    };
+            };
     private boolean requestingVerification = false;
 
     @Override
-    protected void refreshUiReal() {
-
-    }
+    protected void refreshUiReal() {}
 
     @Override
     public void onBackendConnected() {
-        xmppConnectionService.getQuickConversationsService().addOnVerificationRequestedListener(this);
+        xmppConnectionService
+                .getQuickConversationsService()
+                .addOnVerificationRequestedListener(this);
         final Account account = AccountUtils.getFirst(xmppConnectionService);
         if (account != null) {
             runOnUiThread(this::performRedirectToVerificationActivity);
@@ -92,7 +110,9 @@ public class EnterPhoneNumberActivity extends XmppActivity implements QuickConve
         super.onCreate(savedInstanceState);
 
         String region = savedInstanceState != null ? savedInstanceState.getString("region") : null;
-        boolean requestingVerification = savedInstanceState != null && savedInstanceState.getBoolean("requesting_verification", false);
+        boolean requestingVerification =
+                savedInstanceState != null
+                        && savedInstanceState.getBoolean("requesting_verification", false);
         if (region != null) {
             this.region = region;
         } else {
@@ -100,31 +120,73 @@ public class EnterPhoneNumberActivity extends XmppActivity implements QuickConve
         }
 
         this.binding = DataBindingUtil.setContentView(this, R.layout.activity_enter_number);
-        this.binding.countryCode.setCompoundDrawables(new TextDrawable(this.binding.countryCode, "+"), null, null, null);
+        this.binding.countryCode.setCompoundDrawables(
+                new TextDrawable(this.binding.countryCode, "+"), null, null, null);
         this.binding.country.setOnClickListener(this::onSelectCountryClick);
         this.binding.next.setOnClickListener(this::onNextClick);
         Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
         setSupportActionBar(this.binding.toolbar);
         this.binding.countryCode.addTextChangedListener(this.countryCodeTextWatcher);
-        this.binding.countryCode.setText(String.valueOf(PhoneNumberUtilWrapper.getInstance(this).getCountryCodeForRegion(this.region)));
-        this.binding.number.setOnKeyListener((v, keyCode, event) -> {
-            if (event.getAction() != KeyEvent.ACTION_DOWN) {
-                return false;
+        this.binding.countryCode.setText(
+                String.valueOf(
+                        PhoneNumberUtilWrapper.getInstance(this)
+                                .getCountryCodeForRegion(this.region)));
+        this.binding.number.setOnKeyListener(
+                (v, keyCode, event) -> {
+                    if (event.getAction() != KeyEvent.ACTION_DOWN) {
+                        return false;
+                    }
+                    final EditText editText = (EditText) v;
+                    final boolean cursorAtZero =
+                            editText.getSelectionEnd() == 0 && editText.getSelectionStart() == 0;
+                    if (keyCode == KeyEvent.KEYCODE_DEL
+                            && (cursorAtZero || editText.getText().length() == 0)) {
+                        final Editable countryCode = this.binding.countryCode.getText();
+                        if (countryCode.length() > 0) {
+                            countryCode.delete(countryCode.length() - 1, countryCode.length());
+                            this.binding.countryCode.setSelection(countryCode.length());
+                        }
+                        this.binding.countryCode.requestFocus();
+                        return true;
+                    }
+                    return false;
+                });
+        setRequestingVerificationState(requestingVerification);
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(final Menu menu) {
+        getMenuInflater().inflate(R.menu.verify_phone_number_menu, menu);
+        return super.onCreateOptionsMenu(menu);
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(final MenuItem item) {
+        if (item.getItemId() == R.id.action_import_backup) {
+            if (hasStoragePermission(REQUEST_IMPORT_BACKUP)) {
+                startActivity(new Intent(this, ImportBackupActivity.class));
             }
-            final EditText editText = (EditText) v;
-            final boolean cursorAtZero = editText.getSelectionEnd() == 0 && editText.getSelectionStart() == 0;
-            if (keyCode == KeyEvent.KEYCODE_DEL && (cursorAtZero || editText.getText().length() == 0)) {
-                final Editable countryCode = this.binding.countryCode.getText();
-                if (countryCode.length() > 0) {
-                    countryCode.delete(countryCode.length() - 1, countryCode.length());
-                    this.binding.countryCode.setSelection(countryCode.length());
+            return true;
+        } else {
+            return super.onOptionsItemSelected(item);
+        }
+    }
+
+    @Override
+    public void onRequestPermissionsResult(
+            int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+        UriHandlerActivity.onRequestPermissionResult(this, requestCode, grantResults);
+        if (grantResults.length > 0) {
+            if (allGranted(grantResults)) {
+                if (requestCode == REQUEST_IMPORT_BACKUP) {
+                    startActivity(new Intent(this, ImportBackupActivity.class));
                 }
-                this.binding.countryCode.requestFocus();
-                return true;
+            } else if (Arrays.asList(permissions)
+                    .contains(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+                Toast.makeText(this, R.string.no_storage_permission, Toast.LENGTH_SHORT).show();
             }
-            return false;
-        });
-        setRequestingVerificationState(requestingVerification);
+        }
     }
 
     @Override
@@ -139,7 +201,9 @@ public class EnterPhoneNumberActivity extends XmppActivity implements QuickConve
     @Override
     public void onStop() {
         if (xmppConnectionService != null) {
-            xmppConnectionService.getQuickConversationsService().removeOnVerificationRequestedListener(this);
+            xmppConnectionService
+                    .getQuickConversationsService()
+                    .removeOnVerificationRequestedListener(this);
         }
         super.onStop();
     }
@@ -149,18 +213,26 @@ public class EnterPhoneNumberActivity extends XmppActivity implements QuickConve
         try {
             final Editable number = this.binding.number.getText();
             final String input = number.toString();
-            final Phonenumber.PhoneNumber phoneNumber = PhoneNumberUtilWrapper.getInstance(this).parse(input, region);
+            final Phonenumber.PhoneNumber phoneNumber =
+                    PhoneNumberUtilWrapper.getInstance(this).parse(input, region);
             this.binding.countryCode.setText(String.valueOf(phoneNumber.getCountryCode()));
             number.clear();
             number.append(String.valueOf(phoneNumber.getNationalNumber()));
-            final String formattedPhoneNumber = PhoneNumberUtilWrapper.getInstance(this).format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL).replace(' ','\u202F');
+            final String formattedPhoneNumber =
+                    PhoneNumberUtilWrapper.getInstance(this)
+                            .format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL)
+                            .replace(' ', '\u202F');
 
             if (PhoneNumberUtilWrapper.getInstance(this).isValidNumber(phoneNumber)) {
-                builder.setMessage(Html.fromHtml(getString(R.string.we_will_be_verifying, formattedPhoneNumber)));
+                builder.setMessage(
+                        Html.fromHtml(
+                                getString(R.string.we_will_be_verifying, formattedPhoneNumber)));
                 builder.setNegativeButton(R.string.edit, null);
-                builder.setPositiveButton(R.string.ok, (dialog, which) -> onPhoneNumberEntered(phoneNumber));
+                builder.setPositiveButton(
+                        R.string.ok, (dialog, which) -> onPhoneNumberEntered(phoneNumber));
             } else {
-                builder.setMessage(getString(R.string.not_a_valid_phone_number, formattedPhoneNumber));
+                builder.setMessage(
+                        getString(R.string.not_a_valid_phone_number, formattedPhoneNumber));
                 builder.setPositiveButton(R.string.ok, null);
             }
             Log.d(Config.LOGTAG, phoneNumber.toString());
@@ -199,7 +271,8 @@ public class EnterPhoneNumberActivity extends XmppActivity implements QuickConve
             final String region = data.getStringExtra("region");
             if (region != null) {
                 this.region = region;
-                final int countryCode = PhoneNumberUtilWrapper.getInstance(this).getCountryCodeForRegion(region);
+                final int countryCode =
+                        PhoneNumberUtilWrapper.getInstance(this).getCountryCodeForRegion(region);
                 this.binding.countryCode.setText(String.valueOf(countryCode));
             }
         }
@@ -223,10 +296,11 @@ public class EnterPhoneNumberActivity extends XmppActivity implements QuickConve
 
     @Override
     public void onVerificationRequestFailed(int code) {
-        runOnUiThread(() -> {
-            setRequestingVerificationState(false);
-            ApiDialogHelper.createError(this, code).show();
-        });
+        runOnUiThread(
+                () -> {
+                    setRequestingVerificationState(false);
+                    ApiDialogHelper.createError(this, code).show();
+                });
     }
 
     @Override

src/quicksy/java/eu/siacs/conversations/ui/VerifyActivity.java 🔗

@@ -13,12 +13,9 @@ import android.os.Handler;
 import android.os.SystemClock;
 import android.text.Html;
 import android.view.View;
-
 import androidx.databinding.DataBindingUtil;
-
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 import com.google.android.material.snackbar.Snackbar;
-
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.ActivityVerifyBinding;
 import eu.siacs.conversations.entities.Account;
@@ -28,12 +25,13 @@ import eu.siacs.conversations.ui.util.PinEntryWrapper;
 import eu.siacs.conversations.utils.AccountUtils;
 import eu.siacs.conversations.utils.PhoneNumberUtilWrapper;
 import eu.siacs.conversations.utils.TimeFrameUtils;
-
 import io.michaelrocks.libphonenumber.android.NumberParseException;
-
 import java.util.concurrent.atomic.AtomicBoolean;
 
-public class VerifyActivity extends XmppActivity implements ClipboardManager.OnPrimaryClipChangedListener, QuickConversationsService.OnVerification, QuickConversationsService.OnVerificationRequested {
+public class VerifyActivity extends XmppActivity
+        implements ClipboardManager.OnPrimaryClipChangedListener,
+                QuickConversationsService.OnVerification,
+                QuickConversationsService.OnVerificationRequested {
 
     public static final String EXTRA_RETRY_SMS_AFTER = "retry_sms_after";
     private static final String EXTRA_RETRY_VERIFICATION_AFTER = "retry_verification_after";
@@ -46,23 +44,25 @@ public class VerifyActivity extends XmppActivity implements ClipboardManager.OnP
     private boolean verifying = false;
     private boolean requestingVerification = false;
     private long retrySmsAfter = 0;
-    private final Runnable SMS_TIMEOUT_UPDATER = new Runnable() {
-        @Override
-        public void run() {
-            if (setTimeoutLabelInResendButton()) {
-                mHandler.postDelayed(this, 300);
-            }
-        }
-    };
+    private final Runnable SMS_TIMEOUT_UPDATER =
+            new Runnable() {
+                @Override
+                public void run() {
+                    if (setTimeoutLabelInResendButton()) {
+                        mHandler.postDelayed(this, 300);
+                    }
+                }
+            };
     private long retryVerificationAfter = 0;
-    private final Runnable VERIFICATION_TIMEOUT_UPDATER = new Runnable() {
-        @Override
-        public void run() {
-            if (setTimeoutLabelInNextButton()) {
-                mHandler.postDelayed(this, 300);
-            }
-        }
-    };
+    private final Runnable VERIFICATION_TIMEOUT_UPDATER =
+            new Runnable() {
+                @Override
+                public void run() {
+                    if (setTimeoutLabelInNextButton()) {
+                        mHandler.postDelayed(this, 300);
+                    }
+                }
+            };
     private final AtomicBoolean redirectInProgress = new AtomicBoolean(false);
 
     private boolean setTimeoutLabelInResendButton() {
@@ -70,7 +70,10 @@ public class VerifyActivity extends XmppActivity implements ClipboardManager.OnP
             long remaining = retrySmsAfter - SystemClock.elapsedRealtime();
             if (remaining >= 0) {
                 binding.resendSms.setEnabled(false);
-                binding.resendSms.setText(getString(R.string.resend_sms_in, TimeFrameUtils.resolve(VerifyActivity.this, remaining)));
+                binding.resendSms.setText(
+                        getString(
+                                R.string.resend_sms_in,
+                                TimeFrameUtils.resolve(VerifyActivity.this, remaining)));
                 return true;
             }
         }
@@ -84,7 +87,10 @@ public class VerifyActivity extends XmppActivity implements ClipboardManager.OnP
             long remaining = retryVerificationAfter - SystemClock.elapsedRealtime();
             if (remaining >= 0) {
                 binding.next.setEnabled(false);
-                binding.next.setText(getString(R.string.wait_x, TimeFrameUtils.resolve(VerifyActivity.this, remaining)));
+                binding.next.setText(
+                        getString(
+                                R.string.wait_x,
+                                TimeFrameUtils.resolve(VerifyActivity.this, remaining)));
                 return true;
             }
         }
@@ -97,11 +103,20 @@ public class VerifyActivity extends XmppActivity implements ClipboardManager.OnP
     protected void onCreate(final Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         String pin = savedInstanceState != null ? savedInstanceState.getString("pin") : null;
-        boolean verifying = savedInstanceState != null && savedInstanceState.getBoolean("verifying");
-        boolean requestingVerification = savedInstanceState != null && savedInstanceState.getBoolean("requesting_verification", false);
+        boolean verifying =
+                savedInstanceState != null && savedInstanceState.getBoolean("verifying");
+        boolean requestingVerification =
+                savedInstanceState != null
+                        && savedInstanceState.getBoolean("requesting_verification", false);
         this.pasted = savedInstanceState != null ? savedInstanceState.getString("pasted") : null;
-        this.retrySmsAfter = savedInstanceState != null ? savedInstanceState.getLong(EXTRA_RETRY_SMS_AFTER, 0L) : 0L;
-        this.retryVerificationAfter = savedInstanceState != null ? savedInstanceState.getLong(EXTRA_RETRY_VERIFICATION_AFTER, 0L) : 0L;
+        this.retrySmsAfter =
+                savedInstanceState != null
+                        ? savedInstanceState.getLong(EXTRA_RETRY_SMS_AFTER, 0L)
+                        : 0L;
+        this.retryVerificationAfter =
+                savedInstanceState != null
+                        ? savedInstanceState.getLong(EXTRA_RETRY_VERIFICATION_AFTER, 0L)
+                        : 0L;
         this.binding = DataBindingUtil.setContentView(this, R.layout.activity_verify);
         Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
         setSupportActionBar(this.binding.toolbar);
@@ -126,11 +141,13 @@ public class VerifyActivity extends XmppActivity implements ClipboardManager.OnP
         if (this.account != null) {
             final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
             builder.setMessage(R.string.abort_registration_procedure);
-            builder.setPositiveButton(R.string.yes, (dialog, which) -> {
-                xmppConnectionService.deleteAccount(account);
-                startActivity(intent);
-                finish();
-            });
+            builder.setPositiveButton(
+                    R.string.yes,
+                    (dialog, which) -> {
+                        xmppConnectionService.deleteAccount(account);
+                        startActivity(intent);
+                        finish();
+                    });
             builder.setNegativeButton(R.string.no, null);
             builder.create().show();
         } else {
@@ -156,7 +173,10 @@ public class VerifyActivity extends XmppActivity implements ClipboardManager.OnP
 
     private void onResendSmsButton(View view) {
         try {
-            xmppConnectionService.getQuickConversationsService().requestVerification(PhoneNumberUtilWrapper.toPhoneNumber(this, account.getJid()));
+            xmppConnectionService
+                    .getQuickConversationsService()
+                    .requestVerification(
+                            PhoneNumberUtilWrapper.toPhoneNumber(this, account.getJid()));
             setRequestingVerificationState(true);
         } catch (NumberParseException e) {
 
@@ -182,29 +202,35 @@ public class VerifyActivity extends XmppActivity implements ClipboardManager.OnP
         } else {
             setTimeoutLabelInResendButton();
         }
-
     }
 
     @Override
-    protected void refreshUiReal() {
-
-    }
+    protected void refreshUiReal() {}
 
     @Override
     public void onBackendConnected() {
         xmppConnectionService.getQuickConversationsService().addOnVerificationListener(this);
-        xmppConnectionService.getQuickConversationsService().addOnVerificationRequestedListener(this);
+        xmppConnectionService
+                .getQuickConversationsService()
+                .addOnVerificationRequestedListener(this);
         this.account = AccountUtils.getFirst(xmppConnectionService);
         if (this.account == null) {
             return;
         }
-        if (!account.isOptionSet(Account.OPTION_UNVERIFIED) && !account.isOptionSet(Account.OPTION_DISABLED)) {
+        if (!account.isOptionSet(Account.OPTION_UNVERIFIED)
+                && !account.isOptionSet(Account.OPTION_DISABLED)) {
             runOnUiThread(this::performPostVerificationRedirect);
             return;
         }
-        this.binding.weHaveSent.setText(Html.fromHtml(getString(R.string.we_have_sent_you_an_sms_to_x, PhoneNumberUtilWrapper.toFormattedPhoneNumber(this, this.account.getJid()))));
+        this.binding.weHaveSent.setText(
+                Html.fromHtml(
+                        getString(
+                                R.string.we_have_sent_you_an_sms_to_x,
+                                PhoneNumberUtilWrapper.toFormattedPhoneNumber(
+                                        this, this.account.getJid()))));
         setVerifyingState(xmppConnectionService.getQuickConversationsService().isVerifying());
-        setRequestingVerificationState(xmppConnectionService.getQuickConversationsService().isRequestingVerification());
+        setRequestingVerificationState(
+                xmppConnectionService.getQuickConversationsService().isRequestingVerification());
     }
 
     @Override
@@ -225,7 +251,10 @@ public class VerifyActivity extends XmppActivity implements ClipboardManager.OnP
         super.onStart();
         clipboardManager.addPrimaryClipChangedListener(this);
         final Intent intent = getIntent();
-        this.retrySmsAfter = intent != null ? intent.getLongExtra(EXTRA_RETRY_SMS_AFTER, this.retrySmsAfter) : this.retrySmsAfter;
+        this.retrySmsAfter =
+                intent != null
+                        ? intent.getLongExtra(EXTRA_RETRY_SMS_AFTER, this.retrySmsAfter)
+                        : this.retrySmsAfter;
         if (this.retrySmsAfter > 0) {
             mHandler.post(SMS_TIMEOUT_UPDATER);
         }
@@ -242,7 +271,9 @@ public class VerifyActivity extends XmppActivity implements ClipboardManager.OnP
         clipboardManager.removePrimaryClipChangedListener(this);
         if (xmppConnectionService != null) {
             xmppConnectionService.getQuickConversationsService().removeOnVerificationListener(this);
-            xmppConnectionService.getQuickConversationsService().removeOnVerificationRequestedListener(this);
+            xmppConnectionService
+                    .getQuickConversationsService()
+                    .removeOnVerificationRequestedListener(this);
         }
     }
 
@@ -250,14 +281,15 @@ public class VerifyActivity extends XmppActivity implements ClipboardManager.OnP
     public void onResume() {
         super.onResume();
         if (pinEntryWrapper.isEmpty()) {
-            //starting with Android P we need input focus
+            // starting with Android P we need input focus
             pinEntryWrapper.requestFocus();
             pastePinFromClipboard();
         }
     }
 
     private void pastePinFromClipboard() {
-        final ClipDescription description = clipboardManager != null ? clipboardManager.getPrimaryClipDescription() : null;
+        final ClipDescription description =
+                clipboardManager != null ? clipboardManager.getPrimaryClipDescription() : null;
         if (description != null && description.hasMimeType(MIMETYPE_TEXT_PLAIN)) {
             final ClipData primaryClip = clipboardManager.getPrimaryClip();
             if (primaryClip != null && primaryClip.getItemCount() > 0) {
@@ -265,7 +297,11 @@ public class VerifyActivity extends XmppActivity implements ClipboardManager.OnP
                 if (PinEntryWrapper.isValidPin(clip) && !clip.toString().equals(this.pasted)) {
                     this.pasted = clip.toString();
                     pinEntryWrapper.setPin(clip.toString());
-                    final Snackbar snackbar = Snackbar.make(binding.coordinator, R.string.possible_pin, Snackbar.LENGTH_LONG);
+                    final Snackbar snackbar =
+                            Snackbar.make(
+                                    binding.coordinator,
+                                    R.string.possible_pin,
+                                    Snackbar.LENGTH_LONG);
                     snackbar.setAction(R.string.undo, v -> pinEntryWrapper.clear());
                     snackbar.show();
                 }
@@ -291,17 +327,19 @@ public class VerifyActivity extends XmppActivity implements ClipboardManager.OnP
 
     @Override
     public void onVerificationFailed(final int code) {
-        runOnUiThread(() -> {
-            setVerifyingState(false);
-            if (code == 401 || code == 404) {
-                AlertDialog.Builder builder = new AlertDialog.Builder(this);
-                builder.setMessage(code == 404 ? R.string.pin_expired : R.string.incorrect_pin);
-                builder.setPositiveButton(R.string.ok, null);
-                builder.create().show();
-            } else {
-                ApiDialogHelper.createError(this, code).show();
-            }
-        });
+        runOnUiThread(
+                () -> {
+                    setVerifyingState(false);
+                    if (code == 401 || code == 404) {
+                        AlertDialog.Builder builder = new AlertDialog.Builder(this);
+                        builder.setMessage(
+                                code == 404 ? R.string.pin_expired : R.string.incorrect_pin);
+                        builder.setPositiveButton(R.string.ok, null);
+                        builder.create().show();
+                    } else {
+                        ApiDialogHelper.createError(this, code).show();
+                    }
+                });
     }
 
     @Override
@@ -312,10 +350,11 @@ public class VerifyActivity extends XmppActivity implements ClipboardManager.OnP
     @Override
     public void onVerificationRetryAt(long timestamp) {
         this.retryVerificationAfter = timestamp;
-        runOnUiThread(() -> {
-            ApiDialogHelper.createTooManyAttempts(this).show();
-            setVerifyingState(false);
-        });
+        runOnUiThread(
+                () -> {
+                    ApiDialogHelper.createTooManyAttempts(this).show();
+                    setVerifyingState(false);
+                });
         mHandler.removeCallbacks(VERIFICATION_TIMEOUT_UPDATER);
         runOnUiThread(VERIFICATION_TIMEOUT_UPDATER);
     }
@@ -326,35 +365,38 @@ public class VerifyActivity extends XmppActivity implements ClipboardManager.OnP
         setVerifyingState(true);
     }
 
-    //send sms again button callback
+    // send sms again button callback
     @Override
     public void onVerificationRequestFailed(int code) {
-        runOnUiThread(() -> {
-            setRequestingVerificationState(false);
-            ApiDialogHelper.createError(this, code).show();
-        });
+        runOnUiThread(
+                () -> {
+                    setRequestingVerificationState(false);
+                    ApiDialogHelper.createError(this, code).show();
+                });
     }
 
-    //send sms again button callback
+    // send sms again button callback
     @Override
     public void onVerificationRequested() {
-        runOnUiThread(() -> {
-            pinEntryWrapper.clear();
-            setRequestingVerificationState(false);
-            AlertDialog.Builder builder = new AlertDialog.Builder(this);
-            builder.setMessage(R.string.we_have_sent_you_another_sms);
-            builder.setPositiveButton(R.string.ok, null);
-            builder.create().show();
-        });
+        runOnUiThread(
+                () -> {
+                    pinEntryWrapper.clear();
+                    setRequestingVerificationState(false);
+                    AlertDialog.Builder builder = new AlertDialog.Builder(this);
+                    builder.setMessage(R.string.we_have_sent_you_another_sms);
+                    builder.setPositiveButton(R.string.ok, null);
+                    builder.create().show();
+                });
     }
 
     @Override
     public void onVerificationRequestedRetryAt(long timestamp) {
         this.retrySmsAfter = timestamp;
-        runOnUiThread(() -> {
-            ApiDialogHelper.createRateLimited(this, timestamp).show();
-            setRequestingVerificationState(false);
-        });
+        runOnUiThread(
+                () -> {
+                    ApiDialogHelper.createRateLimited(this, timestamp).show();
+                    setRequestingVerificationState(false);
+                });
         mHandler.removeCallbacks(SMS_TIMEOUT_UPDATER);
         runOnUiThread(SMS_TIMEOUT_UPDATER);
     }

src/quicksy/res/menu/verify_phone_number_menu.xml 🔗

@@ -0,0 +1,8 @@
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <item
+        android:id="@+id/action_import_backup"
+        android:title="@string/restore_backup"
+        app:showAsAction="never" />
+</menu>

src/quicksy/res/values-el/strings.xml 🔗

@@ -1,9 +1,9 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <string name="pref_notification_grace_period_summary">Ο χρόνος σίγασης ειδοποιήσεων του Quicksy αφότου ανιχνευθεί δραστηριότητα σε μια από τις άλλες συσκευές σας.</string>
+    <string name="pref_notification_grace_period_summary">Ο χρόνος σίγασης ειδοποιήσεων του Quicksy αφότου ανιχνευθεί δραστηριότητα σε μια από τις άλλες συσκευές σας</string>
     <string name="pref_never_send_crash_summary">Στέλνοντας ίχνη στοίβας προωθείτε την συνεχόμενη ανάπτυξη του Quicksy</string>
     <string name="pref_broadcast_last_activity_summary">Επιτρέψτε στις επαφές σας να γνωρίζουν πότε χρησιμοποιείτε το Quicksy</string>
-    <string name="huawei_protected_apps_summary">Για να συνεχίσετε να λαμβάνετε ειδοποιήσεις, ακόμα κι όταν η οθόνη είναι σβηστή, χρειάζεται να προσθέσετε το Quicksy στον κατάλογο με τις προστατευμένες εφαρμογές.</string>
+    <string name="huawei_protected_apps_summary">Για να συνεχίσετε να λαμβάνετε ειδοποιήσεις, ακόμα κι όταν η οθόνη είναι κλειστή, χρειάζεται να προσθέσετε το Quicksy στον κατάλογο με τις προστατευμένες εφαρμογές.</string>
     <string name="set_profile_picture">Φωτογραφία προφίλ του Quicksy</string>
     <string name="not_available_in_your_country">Το Quicksy δεν είναι διαθέσιμο στην χώρα σας.</string>
     <string name="unable_to_verify_server_identity">Αδυναμία επαλήθευσης της ταυτότητας του διακομιστή.</string>

src/quicksy/res/values-es/strings.xml 🔗

@@ -1,12 +1,12 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <string name="pref_notification_grace_period_summary">El tiempo que Quicksy silencia las notificaciones tras detectar actividad en otro de tus dispositivos</string>
+    <string name="pref_notification_grace_period_summary">El tiempo que Quicksy silencia las notificaciones tras detectar actividad en otro dispositivo</string>
     <string name="pref_never_send_crash_summary">Al enviar informes de fallos, ayudará a desarrollar Quicksy aún más</string>
-    <string name="pref_broadcast_last_activity_summary">Informar a tus contactos cuando usas Quicksy</string>
+    <string name="pref_broadcast_last_activity_summary">Informar a sus contactos cuando usa Quicksy</string>
     <string name="huawei_protected_apps_summary">Para continuar recibiendo notificaciones incluso cuando la pantalla está apagada, debe agregar Quicksy a la lista de aplicaciones protegidas.</string>
     <string name="set_profile_picture">Foto de perfil de Quicksy</string>
-    <string name="not_available_in_your_country">Quicksy no está disponible en tu país.</string>
+    <string name="not_available_in_your_country">Quicksy no está disponible en su país.</string>
     <string name="unable_to_verify_server_identity">No se ha podido verificar la identidad del servidor.</string>
     <string name="unknown_security_error">Error de seguridad desconocido.</string>
     <string name="timeout_while_connecting_to_server">Se ha superado el tiempo máximo de espera conectando al servidor.</string>
-</resources>
+</resources>

src/quicksy/res/values-iw/strings.xml 🔗

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="pref_notification_grace_period_summary">משך הזמן ש-Quicksy שומרת על שקט לאחר פעילות במכשיר אחר</string>
+    <string name="pref_never_send_crash_summary">על ידי שליחת יומן קריסות אתה עוזר לפיתוח המתמשך של Quicksy</string>
+    <string name="pref_broadcast_last_activity_summary">הודע לכל אנשי הקשר שלך כשאתה משתמש ב-Quicksy</string>
+    <string name="huawei_protected_apps_summary">כדי להמשיך לקבל התראות, גם כשהמסך כבוי, עליך להוסיף את Quicksy לרשימת האפליקציות המוגנות.</string>
+    <string name="set_profile_picture">תמונת פרופיל של Quicksy</string>
+    <string name="not_available_in_your_country">Quicksy אינו זמין במדינה שלך.</string>
+    <string name="unable_to_verify_server_identity">לא ניתן לאמת את זהות השרת.</string>
+    <string name="unknown_security_error">שגיאת אבטחה לא ידועה.</string>
+    <string name="timeout_while_connecting_to_server">פסק זמן בזמן ההתחברות לשרת.</string>
+</resources>

src/quicksy/res/values-kab/strings.xml 🔗

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="set_profile_picture">Tugna n umaɣnu n Quicksy</string>
+    <string name="unknown_security_error">Tuccḍa n tɣellist tarussint</string>
+    <string name="not_available_in_your_country">Quicksy ur yelli ara deg tmurt-nnwen.</string>
+</resources>

src/test/java/de/gultsch/common/MiniUriTest.java 🔗

@@ -0,0 +1,62 @@
+package de.gultsch.common;
+
+import com.google.common.collect.ImmutableMap;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class MiniUriTest {
+
+    @Test
+    public void httpsUrl() {
+        final var miniUri = new MiniUri("https://example.com");
+        Assert.assertEquals("https", miniUri.getScheme());
+        Assert.assertEquals("example.com", miniUri.getAuthority());
+        Assert.assertNull(miniUri.getPath());
+    }
+
+    @Test
+    public void httpsUrlHtml() {
+        final var miniUri = new MiniUri("https://example.com/test.html");
+        Assert.assertEquals("https", miniUri.getScheme());
+        Assert.assertEquals("example.com", miniUri.getAuthority());
+        Assert.assertEquals("/test.html", miniUri.getPath());
+    }
+
+    @Test
+    public void httpsUrlCgiFooBar() {
+        final var miniUri = new MiniUri("https://example.com/test.cgi?foo=bar");
+        Assert.assertEquals("https", miniUri.getScheme());
+        Assert.assertEquals("example.com", miniUri.getAuthority());
+        Assert.assertEquals("/test.cgi", miniUri.getPath());
+        Assert.assertEquals(ImmutableMap.of("foo", "bar"), miniUri.getParameter());
+    }
+
+    @Test
+    public void xmppUri() {
+        final var miniUri = new MiniUri("xmpp:user@example.com");
+        Assert.assertEquals("xmpp", miniUri.getScheme());
+        Assert.assertNull(miniUri.getAuthority());
+        Assert.assertEquals("user@example.com", miniUri.getPath());
+    }
+
+    @Test
+    public void xmppUriJoin() {
+        final var miniUri = new MiniUri("xmpp:room@chat.example.com?join");
+        Assert.assertEquals("xmpp", miniUri.getScheme());
+        Assert.assertNull(miniUri.getAuthority());
+        Assert.assertEquals("room@chat.example.com", miniUri.getPath());
+        Assert.assertEquals(ImmutableMap.of("join", ""), miniUri.getParameter());
+    }
+
+    @Test
+    public void xmppUriMessage() {
+        final var miniUri =
+                new MiniUri("xmpp:romeo@montague.net?message;body=Here%27s%20a%20test%20message");
+        Assert.assertEquals("xmpp", miniUri.getScheme());
+        Assert.assertNull(miniUri.getAuthority());
+        Assert.assertEquals("romeo@montague.net", miniUri.getPath());
+        Assert.assertEquals(
+                ImmutableMap.of("message", "", "body", "Here's a test message"),
+                miniUri.getParameter());
+    }
+}

src/test/java/de/gultsch/common/PatternTest.java 🔗

@@ -0,0 +1,120 @@
+package de.gultsch.common;
+
+import com.google.common.collect.ImmutableList;
+import java.util.Arrays;
+import java.util.regex.MatchResult;
+import java.util.stream.Collectors;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class PatternTest {
+
+    @Test
+    public void shortImMessage() {
+        final var message =
+                "Hi. I'm refactoring how URIs are linked in Conversations. We now support more URI"
+                    + " schemes like mailto:user@example.com and tel:+1-269-555-0107 and obviously"
+                    + " maintain support for things like"
+                    + " xmpp:conversations@conference.siacs.eu?join and https://example.com however"
+                    + " we no longer link domains that aren't actual URIs like example.com to avoid"
+                    + " some false positives.";
+
+        final var matches =
+                Patterns.URI_GENERIC
+                        .matcher(message)
+                        .results()
+                        .map(MatchResult::group)
+                        .collect(Collectors.toList());
+
+        Assert.assertEquals(
+                Arrays.asList(
+                        "mailto:user@example.com",
+                        "tel:+1-269-555-0107",
+                        "xmpp:conversations@conference.siacs.eu?join",
+                        "https://example.com"),
+                matches);
+    }
+
+    @Test
+    public void ambiguous() {
+        final var message =
+                "Please find more information in the corresponding page on Wikipedia"
+                    + " (https://en.wikipedia.org/wiki/Ambiguity_(disambiguation)). Let me know if"
+                    + " you have questions!";
+        final var matches =
+                Patterns.URI_GENERIC
+                        .matcher(message)
+                        .results()
+                        .map(MatchResult::group)
+                        .collect(Collectors.toList());
+
+        Assert.assertEquals(
+                ImmutableList.of("https://en.wikipedia.org/wiki/Ambiguity_(disambiguation)"),
+                matches);
+    }
+
+    @Test
+    public void parenthesis() {
+        final var message = "Daniel is on Mastodon (https://gultsch.social/@daniel)";
+        final var matches =
+                Patterns.URI_GENERIC
+                        .matcher(message)
+                        .results()
+                        .map(MatchResult::group)
+                        .collect(Collectors.toList());
+
+        Assert.assertEquals(ImmutableList.of("https://gultsch.social/@daniel"), matches);
+    }
+
+    @Test
+    public void fullWidthSpace() {
+        final var message = "\u3000https://conversations.im";
+        final var matches =
+                Patterns.URI_GENERIC
+                        .matcher(message)
+                        .results()
+                        .map(MatchResult::group)
+                        .collect(Collectors.toList());
+
+        Assert.assertEquals(ImmutableList.of("https://conversations.im"), matches);
+    }
+
+    @Test
+    public void fullWidthColon() {
+        final var message = "\uFF1Ahttps://conversations.im";
+        final var matches =
+                Patterns.URI_GENERIC
+                        .matcher(message)
+                        .results()
+                        .map(MatchResult::group)
+                        .collect(Collectors.toList());
+
+        Assert.assertEquals(ImmutableList.of("https://conversations.im"), matches);
+    }
+
+    @Test
+    public void newLine() {
+        final var message = "\nxmpp:example.com";
+        final var matches =
+                Patterns.URI_GENERIC
+                        .matcher(message)
+                        .results()
+                        .map(MatchResult::group)
+                        .collect(Collectors.toList());
+
+        Assert.assertEquals(ImmutableList.of("xmpp:example.com"), matches);
+    }
+
+    @Test
+    public void code() {
+        final var message = "`xmpp:example.com`";
+        final var matches =
+                Patterns.URI_GENERIC
+                        .matcher(message)
+                        .results()
+                        .map(MatchResult::group)
+                        .collect(Collectors.toList());
+
+        Assert.assertTrue(matches.isEmpty());
+    }
+}