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

Stephen Paul Weber created

* tag '2.17.12' of https://codeberg.org/iNPUTmice/Conversations: (60 commits)
  fix quicksy registration
  Translated using Weblate (Estonian)
  Translated using Weblate (Chinese (Simplified Han script))
  Translated using Weblate (Ukrainian)
  Translated using Weblate (Estonian)
  Translated using Weblate (Romanian)
  Translated using Weblate (Chinese (Simplified Han script))
  resetting hostname when extended connection settings and tor is disabled
  version bump to 2.17.12
  run Tor connection via hostname only if extended connection ui is visible
  update conversations.doap
  Translated using Weblate (Estonian)
  Translated using Weblate (Ukrainian)
  use material switch for device fingerprints
  bump dependencies
  fix formatting in gl and fi strings
  code clean up in stream feature parsing
  ensure that account exists when restoring from DB
  use material switches
  version bump to 2.17.11
  ...

Change summary

CHANGELOG.md                                                                              |   8 
build.gradle                                                                              |   4 
fastlane/metadata/android/en-US/changelogs/4213304.txt                                    |   1 
fastlane/metadata/android/en-US/changelogs/4213404.txt                                    |   1 
fastlane/metadata/android/et/changelogs/349.txt                                           |   4 
fastlane/metadata/android/et/changelogs/351.txt                                           |   3 
fastlane/metadata/android/et/changelogs/353.txt                                           |   4 
fastlane/metadata/android/et/changelogs/360.txt                                           |   1 
fastlane/metadata/android/et/changelogs/362.txt                                           |   1 
fastlane/metadata/android/et/changelogs/364.txt                                           |   2 
fastlane/metadata/android/et/changelogs/367.txt                                           |   2 
fastlane/metadata/android/et/changelogs/379.txt                                           |   1 
fastlane/metadata/android/et/changelogs/381.txt                                           |   2 
fastlane/metadata/android/et/changelogs/382.txt                                           |   2 
fastlane/metadata/android/et/changelogs/397.txt                                           |   3 
fastlane/metadata/android/et/changelogs/404.txt                                           |   1 
fastlane/metadata/android/et/changelogs/405.txt                                           |   1 
fastlane/metadata/android/et/changelogs/42015.txt                                         |   1 
fastlane/metadata/android/et/changelogs/42038.txt                                         |   2 
fastlane/metadata/android/et/changelogs/42041.txt                                         |   5 
fastlane/metadata/android/et/changelogs/42042.txt                                         |   2 
fastlane/metadata/android/et/changelogs/4213304.txt                                       |   1 
fastlane/metadata/android/et/changelogs/4213404.txt                                       |   1 
fastlane/metadata/android/it-IT/changelogs/4212404.txt                                    |   2 
fastlane/metadata/android/it-IT/changelogs/4212504.txt                                    |   1 
fastlane/metadata/android/it-IT/changelogs/4212604.txt                                    |   2 
fastlane/metadata/android/it-IT/changelogs/4212704.txt                                    |   1 
fastlane/metadata/android/it-IT/changelogs/4212804.txt                                    |   3 
fastlane/metadata/android/it-IT/changelogs/4212904.txt                                    |   2 
fastlane/metadata/android/it-IT/changelogs/4213104.txt                                    |   2 
fastlane/metadata/android/it-IT/changelogs/4213204.txt                                    |   4 
fastlane/metadata/android/sv-SE/changelogs/42013.txt                                      |   1 
fastlane/metadata/android/sv-SE/changelogs/42050.txt                                      |   1 
fastlane/metadata/android/uk/changelogs/4213204.txt                                       |   4 
fastlane/metadata/android/uk/changelogs/4213304.txt                                       |   1 
fastlane/metadata/android/uk/changelogs/4213404.txt                                       |   1 
fastlane/metadata/android/zh-CN/changelogs/4213304.txt                                    |   1 
fastlane/metadata/android/zh-CN/changelogs/4213404.txt                                    |   1 
src/conversations/fastlane/metadata/android/sv-SE/full_description.txt                    |   2 
src/main/java/eu/siacs/conversations/AppSettings.java                                     |  10 
src/main/java/eu/siacs/conversations/crypto/sasl/Anonymous.java                           |  19 
src/main/java/eu/siacs/conversations/crypto/sasl/DigestMd5.java                           | 241 
src/main/java/eu/siacs/conversations/crypto/sasl/External.java                            |  17 
src/main/java/eu/siacs/conversations/crypto/sasl/Plain.java                               |  34 
src/main/java/eu/siacs/conversations/crypto/sasl/SaslMechanism.java                       |  26 
src/main/java/eu/siacs/conversations/crypto/sasl/ScramMechanism.java                      |   1 
src/main/java/eu/siacs/conversations/entities/Account.java                                |  11 
src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java                     |  79 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java                  |  62 
src/main/java/eu/siacs/conversations/ui/ContactDetailsActivity.java                       |   4 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java                         |  65 
src/main/java/eu/siacs/conversations/ui/OmemoActivity.java                                | 412 
src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java                       |   4 
src/main/java/eu/siacs/conversations/ui/fragment/settings/ConnectionSettingsFragment.java |  28 
src/main/java/eu/siacs/conversations/xmpp/Jid.java                                        |  17 
src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java                             | 110 
src/main/res/layout/item_account.xml                                                      |  20 
src/main/res/layout/item_device_fingerprint.xml                                           |   4 
src/main/res/layout/preference_material_switch.xml                                        |   8 
src/main/res/menu/message_context.xml                                                     |   4 
src/main/res/values-de/strings.xml                                                        |   1 
src/main/res/values-es/strings.xml                                                        |   1 
src/main/res/values-et/strings.xml                                                        |   4 
src/main/res/values-fi/strings.xml                                                        | 247 
src/main/res/values-gl/strings.xml                                                        |   4 
src/main/res/values-it/strings.xml                                                        |  22 
src/main/res/values-night/themes.xml                                                      |   1 
src/main/res/values-nl/strings.xml                                                        |   2 
src/main/res/values-pl/strings.xml                                                        |   1 
src/main/res/values-pt-rBR/strings.xml                                                    |   2 
src/main/res/values-ro-rRO/strings.xml                                                    |   2 
src/main/res/values-ru/strings.xml                                                        |   4 
src/main/res/values-sq-rAL/strings.xml                                                    |   1 
src/main/res/values-sr/strings.xml                                                        |   2 
src/main/res/values-uk/strings.xml                                                        |   2 
src/main/res/values-zh-rCN/strings.xml                                                    |  28 
src/main/res/values/dimens.xml                                                            |   2 
src/main/res/values/strings.xml                                                           |   2 
src/main/res/values/themes.xml                                                            |   9 
src/quicksy/fastlane/metadata/android/fi-FI/full_description.txt                          |  14 
src/quicksy/fastlane/metadata/android/fi-FI/short_description.txt                         |   1 
src/quicksy/res/values-fi/strings.xml                                                     |   4 
82 files changed, 1,119 insertions(+), 495 deletions(-)

Detailed changes

CHANGELOG.md 🔗

@@ -1,5 +1,13 @@
 # Changelog
 
+### Version 2.17.12
+
+* Fix crash on file transfer in fi translation
+
+### Version 2.17.11
+
+* minor bug fixes
+
 ### Version 2.17.10
 
 * Allow audio recording to be pause by tapping the timer

build.gradle 🔗

@@ -59,7 +59,7 @@ 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.3'
+    coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
 
     implementation project(':libs:annotation')
     annotationProcessor project(':libs:annotation-processor')
@@ -84,7 +84,7 @@ dependencies {
     implementation 'androidx.cardview:cardview:1.0.0'
     implementation "androidx.preference:preference:1.2.1"
     implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
-    implementation 'com.google.android.material:material:1.13.0-alpha09'
+    implementation 'com.google.android.material:material:1.13.0-alpha10'
     implementation 'androidx.work:work-runtime:2.9.1'
 
     implementation "androidx.emoji2:emoji2:1.5.0"

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

@@ -0,0 +1,4 @@
+* Lisasime seadistuse asjatundjatele, kes soovivad teha kanalite tuvastamist search.jabber.network asemel kohalikus serveris
+* Kohaletoimetamise märkeruudud on nüüd vaikimisi kasutusel ja eemaldasime vastava seadistuse
+* Lülitasime „Saatmisnupp näitab olekut“ vakimisi sisse ja eemaldasime vastava seadistuse
+* Tõstsime Varunduse ja Esiplaani teenuse seadistused põhivaatesse

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

@@ -0,0 +1,3 @@
+* parandused failide edastamisel Jingle IBB abil
+* parandused, kus korduvad sõnumite muutmised ummistasid andmebaasi
+* võtsime kasutusele „Last Message Correction“ versiooni 1.1

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

@@ -0,0 +1,4 @@
+* kasutajad saavad nüüs ise oma hüüdnime lisada
+* jälle on võimalik alla laadida faile, kasutades OMEMO krüptimist
+* kanalite tunnuspildid kasutavad nüüd # sümbolit
+* Quicksy kasutab vaikimisi „alati“ OMEMO krüptimist (sellega läheb peitu ka luku ikoon)

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

@@ -0,0 +1,5 @@
+* Ühenduste kiirema taastamise nimel võtsime kasutusele Extensible SASL Profile, Bind 2.0 ja Fast protokollid
+* Võtsime kasutusele ühenduskanalite sidumise
+* Lisasime võimaluse lülituda häälkõnel ümber videokõnele
+* Lisasime võimaluse oma tunnuspildi kustutamiseks
+* Lisasime märkamata jäänud kõnede teavituse

fastlane/metadata/android/it-IT/changelogs/4212604.txt 🔗

@@ -0,0 +1,2 @@
+* Spostati i messaggi più vicini tra di loro invece di unirli
+* Aggiunta possibilità di nascondere gli avatar nelle chat quando non strettamente necessario (Impostazioni -> Interfaccia -> Messaggi di chat -> Mostra avatar)

fastlane/metadata/android/it-IT/changelogs/4212804.txt 🔗

@@ -0,0 +1,3 @@
+* Accesso più facile a suoni di notifica personalizzati via Dettagli del contatto -> Menu -> Notifiche personalizzate
+* Corretto destinatari delle condivisioni dirette sulle nuove versioni di Android
+* Possibilità di limitare la visibilità degli avatar ai soli contatti

fastlane/metadata/android/it-IT/changelogs/4213204.txt 🔗

@@ -0,0 +1,4 @@
+* Consenti di mettere in pausa registrazioni audio toccando il timer
+* Corrette reazioni in messaggi privati dei gruppi
+* Non accettare più 'messaggi di ripiego' per le reazioni, ricevute e display markers
+* Aggiunte ulteriori icone di anteprime media

fastlane/metadata/android/uk/changelogs/4213204.txt 🔗

@@ -0,0 +1,4 @@
+* Можливість призупиняти аудіозапис, натискаючи на таймер
+* Виправлено реакції у приватних повідомлень MUC
+* Припинено прийом «резервних повідомлень» для реакцій, звітів та маркерів показу
+* Додано ще кілька значків попереднього перегляду медіафайлів

src/conversations/fastlane/metadata/android/sv-SE/full_description.txt 🔗

@@ -28,7 +28,7 @@ Conversations fungerar med alla XMPP-servrar. Men XMPP är ett utbyggbart protok
 
 De XEP-tillägg som stöds är:
 
-* XEP-0065: SOCKS5 Bytestreams (or mod_proxy65). Används för filöverföring om båda parter är bakom en brandvägg (NAT).
+* XEP-0065: SOCKS5 Bytestreams (or mod_proxy65). Används för filöverföring om båda parter är bakom en brandvägg eller NAT.
 * XEP-0163: Personal Eventing Protocol för avatarer
 * XEP-0191: Blocking command låter dig svartlista spammare eller blocka kontakter utan att ta bort dem
 * XEP-0198: Stream Management låter XMPP att klara av mindre nätverksavbrott och förändringar i den underliggande TCP-anslutningen

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

@@ -7,6 +7,7 @@ import androidx.annotation.BoolRes;
 import androidx.annotation.NonNull;
 import androidx.preference.PreferenceManager;
 import com.google.common.base.Strings;
+import eu.siacs.conversations.services.QuickConversationsService;
 import java.security.SecureRandom;
 
 public class AppSettings {
@@ -126,7 +127,14 @@ public class AppSettings {
     }
 
     public boolean isUseTor() {
-        return getBooleanPreference(USE_TOR, R.bool.use_tor);
+        return QuickConversationsService.isConversations()
+                && getBooleanPreference(USE_TOR, R.bool.use_tor);
+    }
+
+    public boolean isExtendedConnectionOptions() {
+        return QuickConversationsService.isConversations()
+                && getBooleanPreference(
+                        AppSettings.SHOW_CONNECTION_OPTIONS, R.bool.show_connection_options);
     }
 
     private boolean getBooleanPreference(@NonNull final String name, @BoolRes int res) {

src/main/java/eu/siacs/conversations/crypto/sasl/Anonymous.java 🔗

@@ -1,8 +1,9 @@
 package eu.siacs.conversations.crypto.sasl;
 
-import javax.net.ssl.SSLSocket;
-
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
 import eu.siacs.conversations.entities.Account;
+import javax.net.ssl.SSLSocket;
 
 public class Anonymous extends SaslMechanism {
 
@@ -24,6 +25,20 @@ public class Anonymous extends SaslMechanism {
 
     @Override
     public String getClientFirstMessage(final SSLSocket sslSocket) {
+        Preconditions.checkState(
+                this.state == State.INITIAL, "Calling getClientFirstMessage from invalid state");
+        this.state = State.AUTH_TEXT_SENT;
         return "";
     }
+
+    @Override
+    public String getResponse(final String challenge, final SSLSocket sslSocket)
+            throws AuthenticationException {
+        checkState(State.AUTH_TEXT_SENT);
+        if (Strings.isNullOrEmpty(challenge)) {
+            this.state = State.VALID_SERVER_RESPONSE;
+            return null;
+        }
+        throw new AuthenticationException("Unexpected server response");
+    }
 }

src/main/java/eu/siacs/conversations/crypto/sasl/DigestMd5.java 🔗

@@ -1,20 +1,25 @@
 package eu.siacs.conversations.crypto.sasl;
 
-import android.util.Base64;
-
-import java.nio.charset.Charset;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-
-import javax.net.ssl.SSLSocket;
-
+import android.util.Log;
+import androidx.annotation.NonNull;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.hash.Hashing;
+import com.google.common.io.BaseEncoding;
+import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.utils.CryptoHelper;
+import java.nio.charset.Charset;
+import java.util.Map;
+import javax.net.ssl.SSLSocket;
 
 public class DigestMd5 extends SaslMechanism {
 
     public static final String MECHANISM = "DIGEST-MD5";
     private State state = State.INITIAL;
+    private String precalculatedRSPAuth;
 
     public DigestMd5(final Account account) {
         super(account);
@@ -31,84 +36,150 @@ public class DigestMd5 extends SaslMechanism {
     }
 
     @Override
-    public String getResponse(final String challenge, final SSLSocket sslSocket)
+    public String getClientFirstMessage(final SSLSocket sslSocket) {
+        Preconditions.checkState(
+                this.state == State.INITIAL, "Calling getClientFirstMessage from invalid state");
+        this.state = State.AUTH_TEXT_SENT;
+        return "";
+    }
+
+    @Override
+    public String getResponse(final String challenge, final SSLSocket socket)
+            throws AuthenticationException {
+        return switch (state) {
+            case AUTH_TEXT_SENT -> processChallenge(challenge, socket);
+            case RESPONSE_SENT -> validateServerResponse(challenge);
+            case VALID_SERVER_RESPONSE -> validateUnnecessarySuccessMessage(challenge);
+            default -> throw new InvalidStateException(state);
+        };
+    }
+
+    // ejabberd sends the RSPAuth response as a challenge and then an empty success
+    // technically this is allowed as per https://datatracker.ietf.org/doc/html/rfc2222#section-5.2
+    // although it says to do that only if the profile of the protocol does not allow data to be put
+    // into success. which xmpp does allow. obviously
+    private String validateUnnecessarySuccessMessage(final String challenge)
+            throws AuthenticationException {
+        if (Strings.isNullOrEmpty(challenge)) {
+            return "";
+        }
+        throw new AuthenticationException("Success message must be empty");
+    }
+
+    private String validateServerResponse(final String challenge) throws AuthenticationException {
+        Log.d(Config.LOGTAG, "DigestMd5.validateServerResponse(" + challenge + ")");
+        final var attributes = messageToAttributes(challenge);
+        Log.d(Config.LOGTAG, "attributes: " + attributes);
+        final var rspauth = attributes.get("rspauth");
+        if (Strings.isNullOrEmpty(rspauth)) {
+            throw new AuthenticationException("no rspauth in server finish message");
+        }
+        final var expected = this.precalculatedRSPAuth;
+        if (Strings.isNullOrEmpty(expected) || !this.precalculatedRSPAuth.equals(rspauth)) {
+            throw new AuthenticationException("RSPAuth mismatch");
+        }
+        this.state = State.VALID_SERVER_RESPONSE;
+        return "";
+    }
+
+    private String processChallenge(final String challenge, final SSLSocket socket)
             throws AuthenticationException {
-        switch (state) {
-            case INITIAL:
-                state = State.RESPONSE_SENT;
-                final String encodedResponse;
-                try {
-                    final Tokenizer tokenizer =
-                            new Tokenizer(Base64.decode(challenge, Base64.DEFAULT));
-                    String nonce = "";
-                    for (final String token : tokenizer) {
-                        final String[] parts = token.split("=", 2);
-                        if (parts[0].equals("nonce")) {
-                            nonce = parts[1].replace("\"", "");
-                        } else if (parts[0].equals("rspauth")) {
-                            return "";
-                        }
-                    }
-                    final String digestUri = "xmpp/" + account.getServer();
-                    final String nonceCount = "00000001";
-                    final String x =
-                            account.getUsername()
-                                    + ":"
-                                    + account.getServer()
-                                    + ":"
-                                    + account.getPassword();
-                    final MessageDigest md = MessageDigest.getInstance("MD5");
-                    final byte[] y = md.digest(x.getBytes(Charset.defaultCharset()));
-                    final String cNonce = CryptoHelper.random(100);
-                    final byte[] a1 =
-                            CryptoHelper.concatenateByteArrays(
-                                    y,
-                                    (":" + nonce + ":" + cNonce)
-                                            .getBytes(Charset.defaultCharset()));
-                    final String a2 = "AUTHENTICATE:" + digestUri;
-                    final String ha1 = CryptoHelper.bytesToHex(md.digest(a1));
-                    final String ha2 =
-                            CryptoHelper.bytesToHex(
-                                    md.digest(a2.getBytes(Charset.defaultCharset())));
-                    final String kd =
-                            ha1 + ":" + nonce + ":" + nonceCount + ":" + cNonce + ":auth:" + ha2;
-                    final String response =
-                            CryptoHelper.bytesToHex(
-                                    md.digest(kd.getBytes(Charset.defaultCharset())));
-                    final String saslString =
-                            "username=\""
-                                    + account.getUsername()
-                                    + "\",realm=\""
-                                    + account.getServer()
-                                    + "\",nonce=\""
-                                    + nonce
-                                    + "\",cnonce=\""
-                                    + cNonce
-                                    + "\",nc="
-                                    + nonceCount
-                                    + ",qop=auth,digest-uri=\""
-                                    + digestUri
-                                    + "\",response="
-                                    + response
-                                    + ",charset=utf-8";
-                    encodedResponse =
-                            Base64.encodeToString(
-                                    saslString.getBytes(Charset.defaultCharset()), Base64.NO_WRAP);
-                } catch (final NoSuchAlgorithmException e) {
-                    throw new AuthenticationException(e);
-                }
-
-                return encodedResponse;
-            case RESPONSE_SENT:
-                state = State.VALID_SERVER_RESPONSE;
-                break;
-            case VALID_SERVER_RESPONSE:
-                if (challenge == null) {
-                    return null; // everything is fine
-                }
-            default:
-                throw new InvalidStateException(state);
+        Log.d(Config.LOGTAG, "DigestMd5.processChallenge()");
+        this.state = State.RESPONSE_SENT;
+        final var attributes = messageToAttributes(challenge);
+
+        final var nonce = attributes.get("nonce");
+
+        if (Strings.isNullOrEmpty(nonce)) {
+            throw new AuthenticationException("Server nonce missing");
+        }
+        final String digestUri = "xmpp/" + account.getServer();
+        final String nonceCount = "00000001";
+        final String x =
+                account.getUsername() + ":" + account.getServer() + ":" + account.getPassword();
+        final byte[] y = Hashing.md5().hashBytes(x.getBytes(Charset.defaultCharset())).asBytes();
+        final String cNonce = CryptoHelper.random(100);
+        final byte[] a1 =
+                CryptoHelper.concatenateByteArrays(
+                        y, (":" + nonce + ":" + cNonce).getBytes(Charset.defaultCharset()));
+        final String a2 = "AUTHENTICATE:" + digestUri;
+        final String ha1 = CryptoHelper.bytesToHex(Hashing.md5().hashBytes(a1).asBytes());
+        final String ha2 =
+                CryptoHelper.bytesToHex(
+                        Hashing.md5().hashBytes(a2.getBytes(Charset.defaultCharset())).asBytes());
+        final String kd = ha1 + ":" + nonce + ":" + nonceCount + ":" + cNonce + ":auth:" + ha2;
+
+        final String a2ForResponse = ":" + digestUri;
+        final String ha2ForResponse =
+                CryptoHelper.bytesToHex(
+                        Hashing.md5()
+                                .hashBytes(a2ForResponse.getBytes(Charset.defaultCharset()))
+                                .asBytes());
+        final String kdForResponseInput =
+                ha1 + ":" + nonce + ":" + nonceCount + ":" + cNonce + ":auth:" + ha2ForResponse;
+
+        this.precalculatedRSPAuth =
+                CryptoHelper.bytesToHex(
+                        Hashing.md5()
+                                .hashBytes(kdForResponseInput.getBytes(Charset.defaultCharset()))
+                                .asBytes());
+
+        final String response =
+                CryptoHelper.bytesToHex(
+                        Hashing.md5().hashBytes(kd.getBytes(Charset.defaultCharset())).asBytes());
+
+        final String saslString =
+                "username=\""
+                        + account.getUsername()
+                        + "\",realm=\""
+                        + account.getServer()
+                        + "\",nonce=\""
+                        + nonce
+                        + "\",cnonce=\""
+                        + cNonce
+                        + "\",nc="
+                        + nonceCount
+                        + ",qop=auth,digest-uri=\""
+                        + digestUri
+                        + "\",response="
+                        + response
+                        + ",charset=utf-8";
+        return BaseEncoding.base64().encode(saslString.getBytes());
+    }
+
+    private static Map<String, String> messageToAttributes(final String message)
+            throws AuthenticationException {
+        byte[] asBytes;
+        try {
+            asBytes = BaseEncoding.base64().decode(message);
+        } catch (final IllegalArgumentException e) {
+            throw new AuthenticationException("Unable to decode server challenge", e);
+        }
+        try {
+            return splitToAttributes(new String(asBytes));
+        } catch (final IllegalArgumentException e) {
+            throw new AuthenticationException("Duplicate attributes");
+        }
+    }
+
+    private static Map<String, String> splitToAttributes(final String message) {
+        final ImmutableMap.Builder<String, String> builder = new ImmutableMap.Builder<>();
+        for (final String token : Splitter.on(',').split(message)) {
+            final var tuple = Splitter.on('=').limit(2).splitToList(token);
+            if (tuple.size() == 2) {
+                final var value = tuple.get(1);
+                builder.put(tuple.get(0), trimQuotes(value));
+            }
+        }
+        return builder.buildOrThrow();
+    }
+
+    public static String trimQuotes(@NonNull final String input) {
+        if (input.length() >= 2
+                && input.charAt(0) == '"'
+                && input.charAt(input.length() - 1) == '"') {
+            return input.substring(1, input.length() - 1);
         }
-        return null;
+        return input;
     }
 }

src/main/java/eu/siacs/conversations/crypto/sasl/External.java 🔗

@@ -1,6 +1,7 @@
 package eu.siacs.conversations.crypto.sasl;
 
-import android.util.Base64;
+import com.google.common.base.Preconditions;
+import com.google.common.io.BaseEncoding;
 import eu.siacs.conversations.entities.Account;
 import javax.net.ssl.SSLSocket;
 
@@ -24,7 +25,17 @@ public class External extends SaslMechanism {
 
     @Override
     public String getClientFirstMessage(final SSLSocket sslSocket) {
-        return Base64.encodeToString(
-                account.getJid().asBareJid().toString().getBytes(), Base64.NO_WRAP);
+        Preconditions.checkState(
+                this.state == State.INITIAL, "Calling getClientFirstMessage from invalid state");
+        this.state = State.AUTH_TEXT_SENT;
+        final String message = account.getJid().asBareJid().toString();
+        return BaseEncoding.base64().encode(message.getBytes());
+    }
+
+    @Override
+    public String getResponse(String challenge, SSLSocket sslSocket)
+            throws AuthenticationException {
+        // TODO check that state is in auth text sent and move to finished
+        return "";
     }
 }

src/main/java/eu/siacs/conversations/crypto/sasl/Plain.java 🔗

@@ -1,12 +1,10 @@
 package eu.siacs.conversations.crypto.sasl;
 
-import android.util.Base64;
-
-import java.nio.charset.Charset;
-
-import javax.net.ssl.SSLSocket;
-
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.io.BaseEncoding;
 import eu.siacs.conversations.entities.Account;
+import javax.net.ssl.SSLSocket;
 
 public class Plain extends SaslMechanism {
 
@@ -16,11 +14,6 @@ public class Plain extends SaslMechanism {
         super(account);
     }
 
-    public static String getMessage(String username, String password) {
-        final String message = '\u0000' + username + '\u0000' + password;
-        return Base64.encodeToString(message.getBytes(Charset.defaultCharset()), Base64.NO_WRAP);
-    }
-
     @Override
     public int getPriority() {
         return 10;
@@ -33,6 +26,25 @@ public class Plain extends SaslMechanism {
 
     @Override
     public String getClientFirstMessage(final SSLSocket sslSocket) {
+        Preconditions.checkState(
+                this.state == State.INITIAL, "Calling getClientFirstMessage from invalid state");
+        this.state = State.AUTH_TEXT_SENT;
         return getMessage(account.getUsername(), account.getPassword());
     }
+
+    public static String getMessage(final String username, final String password) {
+        final String message = '\u0000' + username + '\u0000' + password;
+        return BaseEncoding.base64().encode(message.getBytes());
+    }
+
+    @Override
+    public String getResponse(final String challenge, final SSLSocket sslSocket)
+            throws AuthenticationException {
+        checkState(State.AUTH_TEXT_SENT);
+        if (Strings.isNullOrEmpty(challenge)) {
+            this.state = State.VALID_SERVER_RESPONSE;
+            return null;
+        }
+        throw new AuthenticationException("Unexpected server response");
+    }
 }

src/main/java/eu/siacs/conversations/crypto/sasl/SaslMechanism.java 🔗

@@ -16,6 +16,8 @@ public abstract class SaslMechanism {
 
     protected final Account account;
 
+    protected State state = State.INITIAL;
+
     protected SaslMechanism(final Account account) {
         this.account = account;
     }
@@ -39,14 +41,10 @@ public abstract class SaslMechanism {
 
     public abstract String getMechanism();
 
-    public String getClientFirstMessage(final SSLSocket sslSocket) {
-        return "";
-    }
+    public abstract String getClientFirstMessage(final SSLSocket sslSocket);
 
-    public String getResponse(final String challenge, final SSLSocket sslSocket)
-            throws AuthenticationException {
-        return "";
-    }
+    public abstract String getResponse(final String challenge, final SSLSocket sslSocket)
+            throws AuthenticationException;
 
     public enum State {
         INITIAL,
@@ -55,6 +53,17 @@ public abstract class SaslMechanism {
         VALID_SERVER_RESPONSE,
     }
 
+    protected void checkState(final State expected) throws InvalidStateException {
+        final var current = this.state;
+        if (current == null) {
+            throw new InvalidStateException("Current state is null. Implementation problem");
+        }
+        if (current != expected) {
+            throw new InvalidStateException(
+                    String.format("State was %s. Expected %s", current, expected));
+        }
+    }
+
     public enum Version {
         SASL,
         SASL_2;
@@ -120,8 +129,7 @@ public abstract class SaslMechanism {
                 return new ScramSha256(account);
             } else if (mechanisms.contains(ScramSha1.MECHANISM)) {
                 return new ScramSha1(account);
-            } else if (mechanisms.contains(Plain.MECHANISM)
-                    && !account.getServer().equals("nimbuzz.com")) {
+            } else if (mechanisms.contains(Plain.MECHANISM)) {
                 return new Plain(account);
             } else if (mechanisms.contains(DigestMd5.MECHANISM)) {
                 return new DigestMd5(account);

src/main/java/eu/siacs/conversations/crypto/sasl/ScramMechanism.java 🔗

@@ -48,7 +48,6 @@ public abstract class ScramMechanism extends SaslMechanism {
     protected final ChannelBinding channelBinding;
     private final String gs2Header;
     private final String clientNonce;
-    protected State state = State.INITIAL;
     private final String clientFirstMessageBare;
     private byte[] serverSignature = null;
     private DowngradeProtection downgradeProtection = null;

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

@@ -38,6 +38,7 @@ import eu.siacs.conversations.crypto.sasl.HashedTokenSha512;
 import eu.siacs.conversations.crypto.sasl.SaslMechanism;
 import eu.siacs.conversations.services.AvatarService;
 import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.utils.Resolver;
 import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.utils.XmppUri;
 import eu.siacs.conversations.xml.Element;
@@ -129,7 +130,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
                 null,
                 null,
                 null,
-                5222,
+                Resolver.XMPP_PORT_STARTTLS,
                 Presence.Status.ONLINE,
                 null,
                 null,
@@ -355,6 +356,11 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         return server != null && server.endsWith(".onion");
     }
 
+    public boolean isDirectToOnion() {
+        final var hostname = Strings.nullToEmpty(this.hostname).trim();
+        return isOnion() && (hostname.isEmpty() || hostname.endsWith(".onion"));
+    }
+
     public int getPort() {
         return this.port;
     }
@@ -863,6 +869,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         REGISTRATION_PASSWORD_TOO_WEAK(true, false),
         TLS_ERROR,
         TLS_ERROR_DOMAIN,
+        CHANNEL_BINDING,
         INCOMPATIBLE_SERVER,
         INCOMPATIBLE_CLIENT,
         TOR_NOT_AVAILABLE,
@@ -941,6 +948,8 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
                     return R.string.account_status_incompatible_server;
                 case INCOMPATIBLE_CLIENT:
                     return R.string.account_status_incompatible_client;
+                case CHANNEL_BINDING:
+                    return R.string.account_status_channel_binding;
                 case TOR_NOT_AVAILABLE:
                     return R.string.account_status_tor_unavailable;
                 case BIND_FAILURE:

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

@@ -88,6 +88,8 @@ import java.util.UUID;
 import java.util.concurrent.CopyOnWriteArrayList;
 import org.json.JSONException;
 import org.json.JSONObject;
+import org.jxmpp.jid.parts.Localpart;
+import org.jxmpp.stringprep.XmppStringprepException;
 import org.whispersystems.libsignal.IdentityKey;
 import org.whispersystems.libsignal.IdentityKeyPair;
 import org.whispersystems.libsignal.InvalidKeyException;
@@ -99,7 +101,7 @@ import org.whispersystems.libsignal.state.SignedPreKeyRecord;
 public class DatabaseBackend extends SQLiteOpenHelper {
 
     private static final String DATABASE_NAME = "history";
-    private static final int DATABASE_VERSION = 52;
+    private static final int DATABASE_VERSION = 53;
 
     private static boolean requiresMessageIndexRebuild = false;
     private static DatabaseBackend instance = null;
@@ -1251,6 +1253,37 @@ public class DatabaseBackend extends SQLiteOpenHelper {
                             + Message.REACTIONS
                             + " TEXT");
         }
+        if (oldVersion < 53 && newVersion >= 53) {
+            try (final Cursor cursor =
+                    db.query(
+                            Account.TABLENAME,
+                            new String[] {Account.UUID, Account.USERNAME},
+                            null,
+                            null,
+                            null,
+                            null,
+                            null)) {
+                while (cursor != null && cursor.moveToNext()) {
+                    final var uuid = cursor.getString(0);
+                    final var username = cursor.getString(1);
+                    final Localpart localpart;
+                    try {
+                        localpart = Localpart.fromUnescaped(username);
+                    } catch (final XmppStringprepException e) {
+                        Log.d(Config.LOGTAG, "unable to parse jid");
+                        continue;
+                    }
+                    final var contentValues = new ContentValues();
+                    contentValues.putNull(Account.ROSTERVERSION);
+                    contentValues.put(Account.USERNAME, localpart.toString());
+                    db.update(
+                            Account.TABLENAME,
+                            contentValues,
+                            Account.UUID + "=?",
+                            new String[] {uuid});
+                }
+            }
+        }
     }
 
     private void canonicalizeJids(SQLiteDatabase db) {
@@ -1262,21 +1295,24 @@ public class DatabaseBackend extends SQLiteOpenHelper {
             String newJid;
             try {
                 newJid =
-                        Jid.of(cursor.getString(cursor.getColumnIndex(Conversation.CONTACTJID)))
+                        Jid.of(
+                                        cursor.getString(
+                                                cursor.getColumnIndexOrThrow(
+                                                        Conversation.CONTACTJID)))
                                 .toString();
-            } catch (IllegalArgumentException ignored) {
+            } catch (final IllegalArgumentException e) {
                 Log.e(
                         Config.LOGTAG,
                         "Failed to migrate Conversation CONTACTJID "
-                                + cursor.getString(cursor.getColumnIndex(Conversation.CONTACTJID))
-                                + ": "
-                                + ignored
-                                + ". Skipping...");
+                                + cursor.getString(
+                                        cursor.getColumnIndexOrThrow(Conversation.CONTACTJID))
+                                + ". Skipping...",
+                        e);
                 continue;
             }
 
             final String[] updateArgs = {
-                newJid, cursor.getString(cursor.getColumnIndex(Conversation.UUID)),
+                newJid, cursor.getString(cursor.getColumnIndexOrThrow(Conversation.UUID)),
             };
             db.execSQL(
                     "update "
@@ -1296,12 +1332,14 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         while (cursor.moveToNext()) {
             String newJid;
             try {
-                newJid = Jid.of(cursor.getString(cursor.getColumnIndex(Contact.JID))).toString();
+                newJid =
+                        Jid.of(cursor.getString(cursor.getColumnIndexOrThrow(Contact.JID)))
+                                .toString();
             } catch (final IllegalArgumentException e) {
                 Log.e(
                         Config.LOGTAG,
                         "Failed to migrate Contact JID "
-                                + cursor.getString(cursor.getColumnIndex(Contact.JID))
+                                + cursor.getString(cursor.getColumnIndexOrThrow(Contact.JID))
                                 + ":  Skipping...",
                         e);
                 continue;
@@ -1309,8 +1347,8 @@ public class DatabaseBackend extends SQLiteOpenHelper {
 
             final String[] updateArgs = {
                 newJid,
-                cursor.getString(cursor.getColumnIndex(Contact.ACCOUNT)),
-                cursor.getString(cursor.getColumnIndex(Contact.JID)),
+                cursor.getString(cursor.getColumnIndexOrThrow(Contact.ACCOUNT)),
+                cursor.getString(cursor.getColumnIndexOrThrow(Contact.JID)),
             };
             db.execSQL(
                     "update "
@@ -1335,24 +1373,25 @@ public class DatabaseBackend extends SQLiteOpenHelper {
             try {
                 newServer =
                         Jid.of(
-                                        cursor.getString(cursor.getColumnIndex(Account.USERNAME)),
-                                        cursor.getString(cursor.getColumnIndex(Account.SERVER)),
+                                        cursor.getString(
+                                                cursor.getColumnIndexOrThrow(Account.USERNAME)),
+                                        cursor.getString(
+                                                cursor.getColumnIndexOrThrow(Account.SERVER)),
                                         null)
                                 .getDomain()
                                 .toString();
-            } catch (IllegalArgumentException ignored) {
+            } catch (final IllegalArgumentException e) {
                 Log.e(
                         Config.LOGTAG,
                         "Failed to migrate Account SERVER "
-                                + cursor.getString(cursor.getColumnIndex(Account.SERVER))
-                                + ": "
-                                + ignored
-                                + ". Skipping...");
+                                + cursor.getString(cursor.getColumnIndexOrThrow(Account.SERVER))
+                                + ". Skipping...",
+                        e);
                 continue;
             }
 
             String[] updateArgs = {
-                newServer, cursor.getString(cursor.getColumnIndex(Account.UUID)),
+                newServer, cursor.getString(cursor.getColumnIndexOrThrow(Account.UUID)),
             };
             db.execSQL(
                     "update "

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

@@ -2127,11 +2127,15 @@ public class XmppConnectionService extends Service {
         }
     }
 
-    private void sendFileMessage(final Message message, final boolean delay, final Runnable cb) {
-        Log.d(Config.LOGTAG, "send file message");
-        final Account account = message.getConversation().getAccount();
-        if (account.httpUploadAvailable(fileBackend.getFile(message, false).getSize())
-                || message.getConversation().getMode() == Conversation.MODE_MULTI) {
+    private void sendFileMessage(
+            final Message message, final boolean delay, final boolean forceP2P, final Runnable cb) {
+        final var account = message.getConversation().getAccount();
+        Log.d(
+                Config.LOGTAG,
+                account.getJid().asBareJid() + ": send file message. forceP2P=" + forceP2P);
+        if ((account.httpUploadAvailable(fileBackend.getFile(message, false).getSize())
+                        || message.getConversation().getMode() == Conversation.MODE_MULTI)
+                && !forceP2P) {
             mHttpConnectionManager.createNewUploadConnection(message, delay, cb);
         } else {
             mJingleConnectionManager.startJingleFileTransfer(message);
@@ -2140,14 +2144,24 @@ public class XmppConnectionService extends Service {
     }
 
     public void sendMessage(final Message message) {
-        sendMessage(message, false, false, false, null);
+        sendMessage(message, false, false, false, false, null);
     }
 
     public void sendMessage(final Message message, final Runnable cb) {
-        sendMessage(message, false, false, false, cb);
+        sendMessage(message, false, false, false, false, cb);
     }
 
     private void sendMessage(final Message message, final boolean resend, final boolean previewedLinks, final boolean delay, final Runnable cb) {
+        sendMessage(message, resend, previewedLinks, delay, false, cb);
+    }
+
+    private void sendMessage(
+            final Message message,
+            final boolean resend,
+            final boolean previewedLinks,
+            final boolean delay,
+            final boolean forceP2P,
+            final Runnable cb) {
         final Account account = message.getConversation().getAccount();
         if (account.setShowErrorNotification(true)) {
             databaseBackend.updateAccount(account);
@@ -2313,7 +2327,7 @@ public class XmppConnectionService extends Service {
                                         fileBackend.getFile(message, false).getSize())
                                 || conversation.getMode() == Conversation.MODE_MULTI
                                 || message.fixCounterpart()) {
-                            this.sendFileMessage(message, delay, cb);
+                            this.sendFileMessage(message, delay, forceP2P, cb);
                             passedCbOn = true;
                         } else {
                             break;
@@ -2329,7 +2343,7 @@ public class XmppConnectionService extends Service {
                                         fileBackend.getFile(message, false).getSize())
                                 || conversation.getMode() == Conversation.MODE_MULTI
                                 || message.fixCounterpart()) {
-                            this.sendFileMessage(message, delay, cb);
+                            this.sendFileMessage(message, delay, forceP2P, cb);
                             passedCbOn = true;
                         } else {
                             break;
@@ -2345,7 +2359,7 @@ public class XmppConnectionService extends Service {
                                         fileBackend.getFile(message, false).getSize())
                                 || conversation.getMode() == Conversation.MODE_MULTI
                                 || message.fixCounterpart()) {
-                            this.sendFileMessage(message, delay, cb);
+                            this.sendFileMessage(message, delay, forceP2P, cb);
                             passedCbOn = true;
                         } else {
                             break;
@@ -2484,15 +2498,15 @@ public class XmppConnectionService extends Service {
     }
 
     public void resendMessage(final Message message, final boolean delay) {
-        sendMessage(message, true, false, delay, null);
+        sendMessage(message, true, false, delay, false, null);
     }
 
     public void resendMessage(final Message message, final boolean delay, final Runnable cb) {
-        sendMessage(message, true, false, delay, cb);
+        sendMessage(message, true, false, delay, false, cb);
     }
 
     public void resendMessage(final Message message, final boolean delay, final boolean previewedLinks) {
-        sendMessage(message, true, previewedLinks, delay, null);
+        sendMessage(message, true, previewedLinks, delay, false, null);
     }
 
     public Pair<Account,Account> onboardingIncomplete() {
@@ -3401,15 +3415,15 @@ public class XmppConnectionService extends Service {
         if (existing == null) {
             return null;
         }
-        Log.d(
-                Config.LOGTAG,
-                existing.getJid().asBareJid()
-                        + ": restoring conversation with "
-                        + existing.getJid()
-                        + " from DB");
+        Log.d(Config.LOGTAG, "restoring conversation with " + existing.getJid() + " from DB");
         final Map<String, Account> accounts =
                 ImmutableMap.copyOf(Maps.uniqueIndex(this.accounts, Account::getUuid));
-        existing.setAccount(accounts.get(existing.getAccountUuid()));
+        final var account = accounts.get(existing.getAccountUuid());
+        if (account == null) {
+            Log.d(Config.LOGTAG, "could not find account " + existing.getAccountUuid());
+            return null;
+        }
+        existing.setAccount(account);
         final var loadMessagesFromDb = restoreFromArchive(existing);
         mDatabaseReaderExecutor.execute(
                 () ->
@@ -6077,10 +6091,6 @@ public class XmppConnectionService extends Service {
         return getBooleanPreference("use_tor", R.bool.use_tor);
     }
 
-    public boolean showExtendedConnectionOptions() {
-        return getBooleanPreference(AppSettings.SHOW_CONNECTION_OPTIONS, R.bool.show_connection_options);
-    }
-
     public boolean broadcastLastActivity() {
         return getBooleanPreference(AppSettings.BROADCAST_LAST_ACTIVITY, R.bool.last_activity);
     }
@@ -6708,10 +6718,10 @@ public class XmppConnectionService extends Service {
         return this.mHttpConnectionManager;
     }
 
-    public void resendFailedMessages(final Message message) {
+    public void resendFailedMessages(final Message message, final boolean forceP2P) {
         message.setTime(System.currentTimeMillis());
         markMessage(message, Message.STATUS_WAITING);
-        this.resendMessage(message, false);
+        this.sendMessage(message, true, false, false, forceP2P, null);
         if (message.getConversation() instanceof Conversation c) {
             c.sort();
         }

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

@@ -662,7 +662,9 @@ public class ContactDetailsActivity extends OmemoActivity
         }
         if (Config.supportOpenPgp() && contact.getPgpKeyId() != 0) {
             hasKeys = true;
-            View view = inflater.inflate(R.layout.contact_key, binding.detailsContactKeys, false);
+            View view =
+                    inflater.inflate(
+                            R.layout.item_device_fingerprint, binding.detailsContactKeys, false);
             TextView key = view.findViewById(R.id.key);
             TextView keyType = view.findViewById(R.id.key_type);
             keyType.setText(R.string.openpgp_key_id);

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

@@ -1858,24 +1858,25 @@ public class ConversationFragment extends XmppFragment
             activity.getMenuInflater().inflate(R.menu.message_context, menu);
             final MenuItem reportAndBlock = menu.findItem(R.id.action_report_and_block);
             final MenuItem addReaction = menu.findItem(R.id.action_add_reaction);
-            MenuItem openWith = menu.findItem(R.id.open_with);
-            MenuItem copyMessage = menu.findItem(R.id.copy_message);
-            MenuItem quoteMessage = menu.findItem(R.id.quote_message);
-            MenuItem retryDecryption = menu.findItem(R.id.retry_decryption);
-            MenuItem correctMessage = menu.findItem(R.id.correct_message);
-            MenuItem retractMessage = menu.findItem(R.id.retract_message);
-            MenuItem moderateMessage = menu.findItem(R.id.moderate_message);
-            MenuItem onlyThisThread = menu.findItem(R.id.only_this_thread);
-            MenuItem shareWith = menu.findItem(R.id.share_with);
-            MenuItem sendAgain = menu.findItem(R.id.send_again);
-            MenuItem copyUrl = menu.findItem(R.id.copy_url);
-            MenuItem copyLink = menu.findItem(R.id.copy_link);
-            MenuItem saveAsSticker = menu.findItem(R.id.save_as_sticker);
-            MenuItem downloadFile = menu.findItem(R.id.download_file);
-            MenuItem cancelTransmission = menu.findItem(R.id.cancel_transmission);
-            MenuItem blockMedia = menu.findItem(R.id.block_media);
-            MenuItem deleteFile = menu.findItem(R.id.delete_file);
-            MenuItem showErrorMessage = menu.findItem(R.id.show_error_message);
+            final MenuItem openWith = menu.findItem(R.id.open_with);
+            final MenuItem copyMessage = menu.findItem(R.id.copy_message);
+            final MenuItem quoteMessage = menu.findItem(R.id.quote_message);
+            final MenuItem retryDecryption = menu.findItem(R.id.retry_decryption);
+            final MenuItem correctMessage = menu.findItem(R.id.correct_message);
+            final MenuItem retractMessage = menu.findItem(R.id.retract_message);
+            final MenuItem moderateMessage = menu.findItem(R.id.moderate_message);
+            final MenuItem onlyThisThread = menu.findItem(R.id.only_this_thread);
+            final MenuItem shareWith = menu.findItem(R.id.share_with);
+            final MenuItem sendAgain = menu.findItem(R.id.send_again);
+            final MenuItem retryAsP2P = menu.findItem(R.id.send_again_as_p2p);
+            final MenuItem copyUrl = menu.findItem(R.id.copy_url);
+            final MenuItem copyLink = menu.findItem(R.id.copy_link);
+            final MenuItem saveAsSticker = menu.findItem(R.id.save_as_sticker);
+            final MenuItem downloadFile = menu.findItem(R.id.download_file);
+            final MenuItem cancelTransmission = menu.findItem(R.id.cancel_transmission);
+            final MenuItem blockMedia = menu.findItem(R.id.block_media);
+            final MenuItem deleteFile = menu.findItem(R.id.delete_file);
+            final MenuItem showErrorMessage = menu.findItem(R.id.show_error_message);
             onlyThisThread.setVisible(!conversation.getLockThread() && m.getThread() != null);
             final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(m);
             final boolean showError =
@@ -1949,6 +1950,12 @@ public class ConversationFragment extends XmppFragment
             }
             if (m.getStatus() == Message.STATUS_SEND_FAILED) {
                 sendAgain.setVisible(true);
+                final var fileNotUploaded = m.isFileOrImage() && !m.hasFileOnRemoteHost();
+                final var isPeerOnline =
+                        conversational.getMode() == Conversation.MODE_SINGLE
+                                && (conversational instanceof Conversation c)
+                                && !c.getContact().getPresences().isEmpty();
+                retryAsP2P.setVisible(fileNotUploaded && isPeerOnline);
             }
             if (m.hasFileOnRemoteHost()
                     || m.isGeoUri()
@@ -2061,7 +2068,10 @@ public class ConversationFragment extends XmppFragment
                 quoteMessage(selectedMessage);
                 return true;
             case R.id.send_again:
-                resendMessage(selectedMessage);
+                resendMessage(selectedMessage, false);
+                return true;
+            case R.id.send_again_as_p2p:
+                resendMessage(selectedMessage, true);
                 return true;
             case R.id.copy_url:
                 ShareUtil.copyUrlToClipboard(activity, selectedMessage);
@@ -3085,12 +3095,11 @@ public class ConversationFragment extends XmppFragment
         builder.create().show();
     }
 
-    private void resendMessage(final Message message) {
+    private void resendMessage(final Message message, final boolean forceP2P) {
         if (message.isFileOrImage()) {
-            if (!(message.getConversation() instanceof Conversation)) {
+            if (!(message.getConversation() instanceof Conversation conversation)) {
                 return;
             }
-            final Conversation conversation = (Conversation) message.getConversation();
             final DownloadableFile file =
                     activity.xmppConnectionService.getFileBackend().getFile(message);
             if ((file.exists() && file.canRead()) || message.hasFileOnRemoteHost()) {
@@ -3098,14 +3107,16 @@ public class ConversationFragment extends XmppFragment
                 if (!message.hasFileOnRemoteHost()
                         && xmppConnection != null
                         && conversation.getMode() == Conversational.MODE_SINGLE
-                        && !xmppConnection
-                                .getFeatures()
-                                .httpUpload(message.getFileParams().getSize())) {
+                        && (!xmppConnection
+                                        .getFeatures()
+                                        .httpUpload(message.getFileParams().getSize())
+                                || forceP2P)) {
                     activity.selectPresence(
                             conversation,
                             () -> {
                                 message.setCounterpart(conversation.getNextCounterpart());
-                                activity.xmppConnectionService.resendFailedMessages(message);
+                                activity.xmppConnectionService.resendFailedMessages(
+                                        message, forceP2P);
                                 new Handler()
                                         .post(
                                                 () -> {
@@ -3128,7 +3139,7 @@ public class ConversationFragment extends XmppFragment
                 return;
             }
         }
-        activity.xmppConnectionService.resendFailedMessages(message);
+        activity.xmppConnectionService.resendFailedMessages(message, false);
         new Handler()
                 .post(
                         () -> {

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

@@ -7,205 +7,241 @@ import android.view.View;
 import android.widget.CompoundButton;
 import android.widget.LinearLayout;
 import android.widget.Toast;
-
-import androidx.appcompat.app.AlertDialog;
 import androidx.databinding.DataBindingUtil;
-
 import com.google.android.material.color.MaterialColors;
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
-
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
 import eu.siacs.conversations.crypto.axolotl.XmppAxolotlSession;
-import eu.siacs.conversations.databinding.ContactKeyBinding;
+import eu.siacs.conversations.databinding.ItemDeviceFingerprintBinding;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.utils.CryptoHelper;
 import eu.siacs.conversations.utils.XmppUri;
 
 public abstract class OmemoActivity extends XmppActivity {
 
-	private Account mSelectedAccount;
-	private String mSelectedFingerprint;
-
-	protected XmppUri mPendingFingerprintVerificationUri = null;
-
-	@Override
-	public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) {
-		super.onCreateContextMenu(menu, v, menuInfo);
-		Object account = v.getTag(R.id.TAG_ACCOUNT);
-		Object fingerprint = v.getTag(R.id.TAG_FINGERPRINT);
-		Object fingerprintStatus = v.getTag(R.id.TAG_FINGERPRINT_STATUS);
-		if (account instanceof Account
-				&& fingerprint instanceof String
-				&& fingerprintStatus instanceof FingerprintStatus) {
-			getMenuInflater().inflate(R.menu.omemo_key_context, menu);
-			MenuItem distrust = menu.findItem(R.id.distrust_key);
-			MenuItem verifyScan = menu.findItem(R.id.verify_scan);
-			if (this instanceof TrustKeysActivity) {
-				distrust.setVisible(false);
-				verifyScan.setVisible(false);
-			} else {
-				FingerprintStatus status = (FingerprintStatus) fingerprintStatus;
-				if (!status.isActive() || status.isVerified()) {
-					verifyScan.setVisible(false);
-				}
-				distrust.setVisible(status.isVerified() || (!status.isActive() && status.isTrusted()));
-			}
-			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;
-		}
-		return true;
-	}
-
-	@Override
-	public void onActivityResult(final int requestCode, final int resultCode, final Intent intent) {
-		super.onActivityResult(requestCode, resultCode, intent);
-		if (requestCode == ScanActivity.REQUEST_SCAN_QR_CODE && resultCode == RESULT_OK) {
-			String result = intent.getStringExtra(ScanActivity.INTENT_EXTRA_RESULT);
-			XmppUri uri = new XmppUri(result == null ? "" : result);
-			if (xmppConnectionServiceBound) {
-				processFingerprintVerification(uri);
-			} else {
-				this.mPendingFingerprintVerificationUri = uri;
-			}
-		}
-	}
-
-	protected abstract void processFingerprintVerification(XmppUri uri);
-
-	protected void copyOmemoFingerprint(String fingerprint) {
-		if (copyTextToClipboard(CryptoHelper.prettifyFingerprint(fingerprint.substring(2)), R.string.omemo_fingerprint)) {
-			Toast.makeText(
-					this,
-					R.string.toast_message_omemo_fingerprint,
-					Toast.LENGTH_SHORT).show();
-		}
-	}
-
-	protected void addFingerprintRow(LinearLayout keys, final XmppAxolotlSession session, boolean highlight) {
-		final Account account = session.getAccount();
-		final String fingerprint = session.getFingerprint();
-		addFingerprintRowWithListeners(keys,
-				session.getAccount(),
-				fingerprint,
-				highlight,
-				session.getTrust(),
-				true,
-				true,
-				(buttonView, isChecked) -> account.getAxolotlService().setFingerprintTrust(fingerprint, FingerprintStatus.createActive(isChecked)));
-	}
-
-	protected void addFingerprintRowWithListeners(LinearLayout keys, final Account account,
-	                                              final String fingerprint,
-	                                              boolean highlight,
-	                                              FingerprintStatus status,
-	                                              boolean showTag,
-	                                              boolean undecidedNeedEnablement,
-	                                              CompoundButton.OnCheckedChangeListener
-			                                              onCheckedChangeListener) {
-		ContactKeyBinding binding = DataBindingUtil.inflate(getLayoutInflater(), R.layout.contact_key, keys, true);
-		binding.tglTrust.setVisibility(View.VISIBLE);
-		registerForContextMenu(binding.getRoot());
-		binding.getRoot().setTag(R.id.TAG_ACCOUNT, account);
-		binding.getRoot().setTag(R.id.TAG_FINGERPRINT, fingerprint);
-		binding.getRoot().setTag(R.id.TAG_FINGERPRINT_STATUS, status);
-		boolean x509 = Config.X509_VERIFICATION && status.getTrust() == FingerprintStatus.Trust.VERIFIED_X509;
-		final View.OnClickListener toast;
-		binding.tglTrust.setChecked(status.isTrusted());
-
-		if (status.isActive()) {
-			binding.key.setTextColor(MaterialColors.getColor(binding.key, com.google.android.material.R.attr.colorOnSurface));
-			binding.keyType.setTextColor(MaterialColors.getColor(binding.keyType, com.google.android.material.R.attr.colorOnSurface));
-			if (status.isVerified()) {
-				binding.verifiedFingerprint.setVisibility(View.VISIBLE);
-				binding.verifiedFingerprint.setAlpha(1.0f);
-				binding.tglTrust.setVisibility(View.GONE);
-				binding.verifiedFingerprint.setOnClickListener(v -> replaceToast(getString(R.string.this_device_has_been_verified), false));
-				toast = null;
-			} else {
-				binding.verifiedFingerprint.setVisibility(View.GONE);
-				binding.tglTrust.setVisibility(View.VISIBLE);
-				binding.tglTrust.setOnCheckedChangeListener(onCheckedChangeListener);
-				if (status.getTrust() == FingerprintStatus.Trust.UNDECIDED && undecidedNeedEnablement) {
-					binding.buttonEnableDevice.setVisibility(View.VISIBLE);
-					binding.buttonEnableDevice.setOnClickListener(v -> {
-						account.getAxolotlService().setFingerprintTrust(fingerprint, FingerprintStatus.createActive(false));
-						binding.buttonEnableDevice.setVisibility(View.GONE);
-						binding.tglTrust.setVisibility(View.VISIBLE);
-					});
-					binding.tglTrust.setVisibility(View.GONE);
-				} else {
-					binding.tglTrust.setOnClickListener(null);
-					binding.tglTrust.setEnabled(true);
-				}
-				toast = v -> hideToast();
-			}
-		} else {
-			binding.key.setTextColor(MaterialColors.getColor(binding.key, com.google.android.material.R.attr.colorOnSurfaceVariant));
-			binding.keyType.setTextColor(MaterialColors.getColor(binding.keyType, com.google.android.material.R.attr.colorOnSurfaceVariant));
-			toast = v -> replaceToast(getString(R.string.this_device_is_no_longer_in_use), false);
-			if (status.isVerified()) {
-				binding.tglTrust.setVisibility(View.GONE);
-				binding.verifiedFingerprint.setVisibility(View.VISIBLE);
-				binding.verifiedFingerprint.setAlpha(0.4368f);
-				binding.verifiedFingerprint.setOnClickListener(toast);
-			} else {
-				binding.tglTrust.setVisibility(View.VISIBLE);
-				binding.verifiedFingerprint.setVisibility(View.GONE);
-				binding.tglTrust.setEnabled(false);
-			}
-		}
-
-		binding.getRoot().setOnClickListener(toast);
-		binding.key.setOnClickListener(toast);
-		binding.keyType.setOnClickListener(toast);
-		if (showTag) {
-			binding.keyType.setText(getString(x509 ? R.string.omemo_fingerprint_x509 : R.string.omemo_fingerprint));
-		} else {
-			binding.keyType.setVisibility(View.GONE);
-		}
-		if (highlight) {
-			binding.keyType.setTextColor(MaterialColors.getColor(binding.keyType, com.google.android.material.R.attr.colorPrimaryVariant));
-			binding.keyType.setText(getString(x509 ? R.string.omemo_fingerprint_x509_selected_message : R.string.omemo_fingerprint_selected_message));
-		} else {
-			binding.keyType.setText(getString(x509 ? R.string.omemo_fingerprint_x509 : R.string.omemo_fingerprint));
-		}
-
-		binding.key.setText(CryptoHelper.prettifyFingerprint(fingerprint.substring(2)));
-	}
-
-	public void showPurgeKeyDialog(final Account account, final String fingerprint) {
-		final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
-		builder.setTitle(R.string.distrust_omemo_key);
-		builder.setMessage(R.string.distrust_omemo_key_text);
-		builder.setNegativeButton(getString(R.string.cancel), null);
-		builder.setPositiveButton(R.string.confirm,
-				(dialog, which) -> {
-					account.getAxolotlService().distrustFingerprint(fingerprint);
-					refreshUi();
-				});
-		builder.create().show();
-	}
-
-	@Override
-	public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
-		super.onRequestPermissionsResult(requestCode, permissions, grantResults);
-		ScanActivity.onRequestPermissionResult(this, requestCode, grantResults);
-	}
+    private Account mSelectedAccount;
+    private String mSelectedFingerprint;
+
+    protected XmppUri mPendingFingerprintVerificationUri = null;
+
+    @Override
+    public void onCreateContextMenu(
+            ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) {
+        super.onCreateContextMenu(menu, v, menuInfo);
+        Object account = v.getTag(R.id.TAG_ACCOUNT);
+        Object fingerprint = v.getTag(R.id.TAG_FINGERPRINT);
+        Object fingerprintStatus = v.getTag(R.id.TAG_FINGERPRINT_STATUS);
+        if (account instanceof Account
+                && fingerprint instanceof String
+                && fingerprintStatus instanceof FingerprintStatus) {
+            getMenuInflater().inflate(R.menu.omemo_key_context, menu);
+            MenuItem distrust = menu.findItem(R.id.distrust_key);
+            MenuItem verifyScan = menu.findItem(R.id.verify_scan);
+            if (this instanceof TrustKeysActivity) {
+                distrust.setVisible(false);
+                verifyScan.setVisible(false);
+            } else {
+                FingerprintStatus status = (FingerprintStatus) fingerprintStatus;
+                if (!status.isActive() || status.isVerified()) {
+                    verifyScan.setVisible(false);
+                }
+                distrust.setVisible(
+                        status.isVerified() || (!status.isActive() && status.isTrusted()));
+            }
+            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;
+        }
+        return true;
+    }
+
+    @Override
+    public void onActivityResult(final int requestCode, final int resultCode, final Intent intent) {
+        super.onActivityResult(requestCode, resultCode, intent);
+        if (requestCode == ScanActivity.REQUEST_SCAN_QR_CODE && resultCode == RESULT_OK) {
+            String result = intent.getStringExtra(ScanActivity.INTENT_EXTRA_RESULT);
+            XmppUri uri = new XmppUri(result == null ? "" : result);
+            if (xmppConnectionServiceBound) {
+                processFingerprintVerification(uri);
+            } else {
+                this.mPendingFingerprintVerificationUri = uri;
+            }
+        }
+    }
+
+    protected abstract void processFingerprintVerification(XmppUri uri);
+
+    protected void copyOmemoFingerprint(String fingerprint) {
+        if (copyTextToClipboard(
+                CryptoHelper.prettifyFingerprint(fingerprint.substring(2)),
+                R.string.omemo_fingerprint)) {
+            Toast.makeText(this, R.string.toast_message_omemo_fingerprint, Toast.LENGTH_SHORT)
+                    .show();
+        }
+    }
+
+    protected void addFingerprintRow(
+            LinearLayout keys, final XmppAxolotlSession session, boolean highlight) {
+        final Account account = session.getAccount();
+        final String fingerprint = session.getFingerprint();
+        addFingerprintRowWithListeners(
+                keys,
+                session.getAccount(),
+                fingerprint,
+                highlight,
+                session.getTrust(),
+                true,
+                true,
+                (buttonView, isChecked) ->
+                        account.getAxolotlService()
+                                .setFingerprintTrust(
+                                        fingerprint, FingerprintStatus.createActive(isChecked)));
+    }
+
+    protected void addFingerprintRowWithListeners(
+            LinearLayout keys,
+            final Account account,
+            final String fingerprint,
+            boolean highlight,
+            FingerprintStatus status,
+            boolean showTag,
+            boolean undecidedNeedEnablement,
+            CompoundButton.OnCheckedChangeListener onCheckedChangeListener) {
+        ItemDeviceFingerprintBinding binding =
+                DataBindingUtil.inflate(
+                        getLayoutInflater(), R.layout.item_device_fingerprint, keys, true);
+        binding.tglTrust.setVisibility(View.VISIBLE);
+        registerForContextMenu(binding.getRoot());
+        binding.getRoot().setTag(R.id.TAG_ACCOUNT, account);
+        binding.getRoot().setTag(R.id.TAG_FINGERPRINT, fingerprint);
+        binding.getRoot().setTag(R.id.TAG_FINGERPRINT_STATUS, status);
+        boolean x509 =
+                Config.X509_VERIFICATION
+                        && status.getTrust() == FingerprintStatus.Trust.VERIFIED_X509;
+        final View.OnClickListener toast;
+        binding.tglTrust.setChecked(status.isTrusted());
+        binding.tglTrust.jumpDrawablesToCurrentState();
+
+        if (status.isActive()) {
+            binding.key.setTextColor(
+                    MaterialColors.getColor(
+                            binding.key, com.google.android.material.R.attr.colorOnSurface));
+            binding.keyType.setTextColor(
+                    MaterialColors.getColor(
+                            binding.keyType, com.google.android.material.R.attr.colorOnSurface));
+            if (status.isVerified()) {
+                binding.verifiedFingerprint.setVisibility(View.VISIBLE);
+                binding.verifiedFingerprint.setAlpha(1.0f);
+                binding.tglTrust.setVisibility(View.GONE);
+                binding.verifiedFingerprint.setOnClickListener(
+                        v ->
+                                replaceToast(
+                                        getString(R.string.this_device_has_been_verified), false));
+                toast = null;
+            } else {
+                binding.verifiedFingerprint.setVisibility(View.GONE);
+                binding.tglTrust.setVisibility(View.VISIBLE);
+                binding.tglTrust.setOnCheckedChangeListener(onCheckedChangeListener);
+                if (status.getTrust() == FingerprintStatus.Trust.UNDECIDED
+                        && undecidedNeedEnablement) {
+                    binding.buttonEnableDevice.setVisibility(View.VISIBLE);
+                    binding.buttonEnableDevice.setOnClickListener(
+                            v -> {
+                                account.getAxolotlService()
+                                        .setFingerprintTrust(
+                                                fingerprint, FingerprintStatus.createActive(false));
+                                binding.buttonEnableDevice.setVisibility(View.GONE);
+                                binding.tglTrust.setVisibility(View.VISIBLE);
+                            });
+                    binding.tglTrust.setVisibility(View.GONE);
+                } else {
+                    binding.tglTrust.setOnClickListener(null);
+                    binding.tglTrust.setEnabled(true);
+                }
+                toast = v -> hideToast();
+            }
+        } else {
+            binding.key.setTextColor(
+                    MaterialColors.getColor(
+                            binding.key, com.google.android.material.R.attr.colorOnSurfaceVariant));
+            binding.keyType.setTextColor(
+                    MaterialColors.getColor(
+                            binding.keyType,
+                            com.google.android.material.R.attr.colorOnSurfaceVariant));
+            toast = v -> replaceToast(getString(R.string.this_device_is_no_longer_in_use), false);
+            if (status.isVerified()) {
+                binding.tglTrust.setVisibility(View.GONE);
+                binding.verifiedFingerprint.setVisibility(View.VISIBLE);
+                binding.verifiedFingerprint.setAlpha(0.4368f);
+                binding.verifiedFingerprint.setOnClickListener(toast);
+            } else {
+                binding.tglTrust.setVisibility(View.VISIBLE);
+                binding.verifiedFingerprint.setVisibility(View.GONE);
+                binding.tglTrust.setEnabled(false);
+            }
+        }
+
+        binding.getRoot().setOnClickListener(toast);
+        binding.key.setOnClickListener(toast);
+        binding.keyType.setOnClickListener(toast);
+        if (showTag) {
+            binding.keyType.setText(
+                    getString(x509 ? R.string.omemo_fingerprint_x509 : R.string.omemo_fingerprint));
+        } else {
+            binding.keyType.setVisibility(View.GONE);
+        }
+        if (highlight) {
+            binding.keyType.setTextColor(
+                    MaterialColors.getColor(
+                            binding.keyType,
+                            com.google.android.material.R.attr.colorPrimaryVariant));
+            binding.keyType.setText(
+                    getString(
+                            x509
+                                    ? R.string.omemo_fingerprint_x509_selected_message
+                                    : R.string.omemo_fingerprint_selected_message));
+        } else {
+            binding.keyType.setText(
+                    getString(x509 ? R.string.omemo_fingerprint_x509 : R.string.omemo_fingerprint));
+        }
+
+        binding.key.setText(CryptoHelper.prettifyFingerprint(fingerprint.substring(2)));
+    }
+
+    public void showPurgeKeyDialog(final Account account, final String fingerprint) {
+        final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+        builder.setTitle(R.string.distrust_omemo_key);
+        builder.setMessage(R.string.distrust_omemo_key_text);
+        builder.setNegativeButton(getString(R.string.cancel), null);
+        builder.setPositiveButton(
+                R.string.confirm,
+                (dialog, which) -> {
+                    account.getAxolotlService().distrustFingerprint(fingerprint);
+                    refreshUi();
+                });
+        builder.create().show();
+    }
+
+    @Override
+    public void onRequestPermissionsResult(
+            int requestCode, String[] permissions, int[] grantResults) {
+        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+        ScanActivity.onRequestPermissionResult(this, requestCode, grantResults);
+    }
 }

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

@@ -102,8 +102,8 @@ public class AccountAdapter extends ArrayAdapter<Account> {
         }
         viewHolder.binding.tglAccountStatus.setOnCheckedChangeListener(
                 (compoundButton, b) -> {
-                    if (b == isDisabled && activity instanceof OnTglAccountState) {
-                        ((OnTglAccountState) activity).onClickTglAccountState(account, b);
+                    if (b == isDisabled && activity instanceof OnTglAccountState tglAccountState) {
+                        tglAccountState.onClickTglAccountState(account, b);
                     }
                 });
         if (activity.xmppConnectionService != null && activity.xmppConnectionService.getAccounts().size() > 1) {

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

@@ -1,17 +1,18 @@
 package eu.siacs.conversations.ui.fragment.settings;
 
 import android.os.Bundle;
+import android.util.Log;
 import android.widget.Toast;
-
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
-
 import com.google.common.base.Strings;
-
 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.services.QuickConversationsService;
+import eu.siacs.conversations.utils.Resolver;
+import java.util.Arrays;
 
 public class ConnectionSettingsFragment extends XmppPreferenceFragment {
 
@@ -59,9 +60,26 @@ public class ConnectionSettingsFragment extends XmppPreferenceFragment {
                 reconnectAccounts();
                 requireService().reinitializeMuclumbusService();
             }
-            case AppSettings.SHOW_CONNECTION_OPTIONS -> {
-                reconnectAccounts();
+            case AppSettings.SHOW_CONNECTION_OPTIONS -> reconnectAccounts();
+        }
+        if (Arrays.asList(AppSettings.USE_TOR, AppSettings.SHOW_CONNECTION_OPTIONS).contains(key)) {
+            final var appSettings = new AppSettings(requireContext());
+            if (appSettings.isUseTor() || appSettings.isExtendedConnectionOptions()) {
+                return;
             }
+            resetUserDefinedHostname();
+        }
+    }
+
+    private void resetUserDefinedHostname() {
+        final var service = requireService();
+        for (final Account account : service.getAccounts()) {
+            Log.d(
+                    Config.LOGTAG,
+                    account.getJid().asBareJid() + ": resetting hostname and port to defaults");
+            account.setHostname(null);
+            account.setPort(Resolver.XMPP_PORT_STARTTLS);
+            service.databaseBackend.updateAccount(account);
         }
     }
 

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

@@ -1,9 +1,10 @@
 package eu.siacs.conversations.xmpp;
 
 import androidx.annotation.NonNull;
-import com.google.common.base.CharMatcher;
+import eu.siacs.conversations.utils.IP;
 import im.conversations.android.xmpp.model.stanza.Stanza;
 import java.io.Serializable;
+import java.util.regex.Pattern;
 import org.jxmpp.jid.impl.JidCreate;
 import org.jxmpp.jid.parts.Domainpart;
 import org.jxmpp.jid.parts.Localpart;
@@ -12,6 +13,10 @@ import org.jxmpp.stringprep.XmppStringprepException;
 
 public abstract class Jid implements Comparable<Jid>, Serializable, CharSequence {
 
+    private static final Pattern HOSTNAME_PATTERN =
+            Pattern.compile(
+                    "^(?=.{1,253}$)(?=.{1,253}$)(?!-)(?!.*--)(?!.*-$)[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*$");
+
     public static Jid of(
             final CharSequence local, final CharSequence domain, final CharSequence resource) {
         if (local == null) {
@@ -77,10 +82,14 @@ public abstract class Jid implements Comparable<Jid>, Serializable, CharSequence
 
     public static Jid ofUserInput(final CharSequence input) {
         final var jid = of(input);
-        if (CharMatcher.is('@').matchesAnyOf(jid.getDomain())) {
-            throw new IllegalArgumentException("Domain should not contain @");
+        final var domain = jid.getDomain().toString();
+        if (domain.isEmpty()) {
+            throw new IllegalArgumentException("Domain can not be empty");
+        }
+        if (HOSTNAME_PATTERN.matcher(domain).matches() || IP.matches(domain)) {
+            return jid;
         }
-        return jid;
+        throw new IllegalArgumentException("Invalid hostname");
     }
 
     public static Jid ofOrInvalid(final String input) {

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

@@ -146,6 +146,7 @@ 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;
@@ -309,6 +310,7 @@ public class XmppConnection implements Runnable {
             mXmppConnectionService.resetSendingToWaiting(account);
         }
         Log.d(Config.LOGTAG, account.getJid().asBareJid().toString() + ": connecting");
+        this.streamFeatures = null;
         this.pendingResumeId.clear();
         this.loginInfo = null;
         this.features.encryptionEnabled = false;
@@ -324,8 +326,9 @@ public class XmppConnection implements Runnable {
             Socket localSocket;
             shouldAuthenticate = !account.isOptionSet(Account.OPTION_REGISTER);
             this.changeStatus(Account.State.CONNECTING);
-            final boolean useTor = mXmppConnectionService.useTorToConnect() || account.isOnion();
-            final boolean extended = mXmppConnectionService.showExtendedConnectionOptions();
+            final boolean useTorSetting = appSettings.isUseTor();
+            final boolean extended = appSettings.isExtendedConnectionOptions();
+            final boolean useTor = useTorSetting || account.isOnion();
             // TODO collapse Tor usage into normal connection code path
             if (useTor) {
                 final var seeOtherHost = this.seeOtherHostResolverResult;
@@ -343,7 +346,15 @@ public class XmppConnection implements Runnable {
                                     Resolver.fromHardCoded(
                                             account.getServer(), Resolver.XMPP_PORT_STARTTLS));
                 } else {
-                    viaTor = Iterables.getOnlyElement(Resolver.fromHardCoded(hostname, port));
+                    if (useTorSetting || extended) {
+                        // if the hostname configuration is showing we can take it
+                        viaTor = Iterables.getOnlyElement(Resolver.fromHardCoded(hostname, port));
+                    } else {
+                        viaTor =
+                                Iterables.getOnlyElement(
+                                        Resolver.fromHardCoded(
+                                                account.getServer(), Resolver.XMPP_PORT_STARTTLS));
+                    }
                     this.verifiedHostname = hostname;
                 }
 
@@ -637,6 +648,9 @@ public class XmppConnection implements Runnable {
             } else if (nextTag.isStart("features", Namespace.STREAMS)) {
                 processStreamFeatures(nextTag);
             } else if (nextTag.isStart("proceed", Namespace.TLS)) {
+                if (this.socket instanceof SSLSocket) {
+                    throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+                }
                 switchOverToTls(nextTag);
             } else if (nextTag.isStart("failure", Namespace.TLS)) {
                 throw new StateChangingException(Account.State.TLS_ERROR);
@@ -797,7 +811,9 @@ public class XmppConnection implements Runnable {
             throws IOException, XmlPullParserException {
         final LoginInfo currentLoginInfo = this.loginInfo;
         final SaslMechanism currentSaslMechanism = LoginInfo.mechanism(currentLoginInfo);
-        if (currentLoginInfo == null || currentSaslMechanism == null) {
+        if (currentLoginInfo == null
+                || LoginInfo.isSuccess(currentLoginInfo)
+                || currentSaslMechanism == null) {
             throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
         }
         final SaslMechanism.Version version;
@@ -1009,9 +1025,15 @@ public class XmppConnection implements Runnable {
         } catch (final IllegalArgumentException e) {
             throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
         }
+
+        final LoginInfo currentLoginInfo = this.loginInfo;
+        if (currentLoginInfo == null || LoginInfo.isSuccess(currentLoginInfo)) {
+            throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+        }
+
         Log.d(Config.LOGTAG, failure.toString());
         Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": login failure " + version);
-        if (SaslMechanism.hashedToken(LoginInfo.mechanism(this.loginInfo))) {
+        if (SaslMechanism.hashedToken(LoginInfo.mechanism(currentLoginInfo))) {
             Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": resetting token");
             account.resetFastToken();
             mXmppConnectionService.databaseBackend.updateAccount(account);
@@ -1048,12 +1070,13 @@ public class XmppConnection implements Runnable {
                 }
             }
         }
-        if (SaslMechanism.hashedToken(LoginInfo.mechanism(this.loginInfo))) {
+        if (SaslMechanism.hashedToken(LoginInfo.mechanism(currentLoginInfo))) {
             Log.d(
                     Config.LOGTAG,
                     account.getJid().asBareJid()
                             + ": fast authentication failed. falling back to regular"
                             + " authentication");
+            this.loginInfo = null;
             authenticate();
         } else {
             throw new StateChangingException(Account.State.UNAUTHORIZED);
@@ -1282,7 +1305,10 @@ public class XmppConnection implements Runnable {
                     account.getJid().asBareJid() + "Not processing iq. Thread was interrupted");
             return;
         }
-        if (packet.hasExtension(Jingle.class) && packet.getType() == Iq.Type.SET && isBound) {
+        if (packet.hasExtension(Jingle.class)
+                && packet.getType() == Iq.Type.SET
+                && isBound
+                && LoginInfo.isSuccess(this.loginInfo)) {
             if (this.jingleListener != null) {
                 this.jingleListener.onJinglePacketReceived(account, packet);
             }
@@ -1312,7 +1338,7 @@ public class XmppConnection implements Runnable {
         final boolean isRequest =
                 stanza.getType() == Iq.Type.GET || stanza.getType() == Iq.Type.SET;
         if (isRequest) {
-            if (isBound) {
+            if (isBound && LoginInfo.isSuccess(this.loginInfo)) {
                 return new Pair<>(this.unregisteredIqListener, null);
             } else {
                 throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
@@ -1471,13 +1497,33 @@ public class XmppConnection implements Runnable {
     }
 
     private void processStreamFeatures(final Tag currentTag) throws IOException {
-        this.streamFeatures =
+        final var streamFeatures =
                 tagReader.readElement(
                         currentTag, im.conversations.android.xmpp.model.streams.Features.class);
         final boolean isSecure = isSecure();
+        if (streamFeatures.hasExtension(StartTls.class) && !features.encryptionEnabled) {
+            sendStartTLS();
+            return;
+        }
+        if (isSecure) {
+            processSecureStreamFeatures(streamFeatures);
+        } else {
+            Log.d(
+                    Config.LOGTAG,
+                    account.getJid().asBareJid()
+                            + ": STARTTLS not available "
+                            + XmlHelper.printElementNames(streamFeatures));
+            throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+        }
+    }
+
+    private void processSecureStreamFeatures(
+            final im.conversations.android.xmpp.model.streams.Features streamFeatures)
+            throws IOException {
+        this.streamFeatures = streamFeatures;
         final boolean needsBinding = !isBound && !account.isOptionSet(Account.OPTION_REGISTER);
         if (this.quickStartInProgress) {
-            if (this.streamFeatures.hasStreamFeature(Authentication.class)) {
+            if (streamFeatures.hasStreamFeature(Authentication.class)) {
                 Log.d(
                         Config.LOGTAG,
                         account.getJid().asBareJid()
@@ -1504,33 +1550,21 @@ public class XmppConnection implements Runnable {
             mXmppConnectionService.databaseBackend.updateAccount(account);
             throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
         }
-        if (this.streamFeatures.hasExtension(StartTls.class) && !features.encryptionEnabled) {
-            sendStartTLS();
-        } else if (this.streamFeatures.hasChild("register", Namespace.REGISTER_STREAM_FEATURE)
+        if (streamFeatures.hasChild("register", Namespace.REGISTER_STREAM_FEATURE)
                 && account.isOptionSet(Account.OPTION_REGISTER)) {
-            if (isSecure) {
-                register();
-            } else {
-                Log.d(
-                        Config.LOGTAG,
-                        account.getJid().asBareJid()
-                                + ": unable to find STARTTLS for registration process "
-                                + XmlHelper.printElementNames(this.streamFeatures));
-                throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
-            }
-        } else if (!this.streamFeatures.hasChild("register", Namespace.REGISTER_STREAM_FEATURE)
+            register();
+        } else if (!streamFeatures.hasChild("register", Namespace.REGISTER_STREAM_FEATURE)
                 && account.isOptionSet(Account.OPTION_REGISTER)) {
             throw new StateChangingException(Account.State.REGISTRATION_NOT_SUPPORTED);
-        } else if (this.streamFeatures.hasStreamFeature(Authentication.class)
+        } else if (streamFeatures.hasStreamFeature(Authentication.class)
                 && shouldAuthenticate
-                && isSecure) {
+                && this.loginInfo == null) {
             authenticate(SaslMechanism.Version.SASL_2);
-        } else if (this.streamFeatures.hasStreamFeature(Mechanisms.class)
+        } else if (streamFeatures.hasStreamFeature(Mechanisms.class)
                 && shouldAuthenticate
-                && isSecure) {
+                && this.loginInfo == null) {
             authenticate(SaslMechanism.Version.SASL);
-        } else if (this.streamFeatures.streamManagement()
-                && isSecure
+        } else if (streamFeatures.streamManagement()
                 && LoginInfo.isSuccess(loginInfo)
                 && streamId != null
                 && !inSmacksSession) {
@@ -1547,7 +1581,6 @@ public class XmppConnection implements Runnable {
             this.tagWriter.writeStanzaAsync(resume);
         } else if (needsBinding) {
             if (this.streamFeatures.hasChild("bind", Namespace.BIND)
-                    && isSecure
                     && LoginInfo.isSuccess(loginInfo)) {
                 sendBindRequest();
             } else {
@@ -1579,10 +1612,15 @@ public class XmppConnection implements Runnable {
     }
 
     private boolean isSecure() {
-        return features.encryptionEnabled || Config.ALLOW_NON_TLS_CONNECTIONS || account.isOnion();
+        return (features.encryptionEnabled && this.socket instanceof SSLSocket)
+                || Config.ALLOW_NON_TLS_CONNECTIONS
+                || account.isDirectToOnion();
     }
 
     private void authenticate(final SaslMechanism.Version version) throws IOException {
+        if (this.loginInfo != null) {
+            throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+        }
         final AuthenticationStreamFeature authElement;
         if (version == SaslMechanism.Version.SASL) {
             authElement = this.streamFeatures.getExtension(Mechanisms.class);
@@ -1724,7 +1762,7 @@ public class XmppConnection implements Runnable {
                 return;
             }
             Log.d(Config.LOGTAG, account.getJid() + ": server did not offer channel binding");
-            throw new StateChangingException(Account.State.INCOMPATIBLE_SERVER);
+            throw new StateChangingException(Account.State.CHANNEL_BINDING);
         }
     }
 
@@ -1883,8 +1921,7 @@ public class XmppConnection implements Runnable {
                                 is = null;
                             }
                         } else {
-                            final boolean useTor =
-                                    mXmppConnectionService.useTorToConnect() || account.isOnion();
+                            final boolean useTor = this.appSettings.isUseTor() || account.isOnion();
                             try {
                                 final String url = data.getValue("url");
                                 final String fallbackUrl = data.getValue("captcha-fallback-url");
@@ -2969,6 +3006,9 @@ public class XmppConnection implements Runnable {
 
         public void success(final String challenge, final SSLSocket sslSocket)
                 throws SaslMechanism.AuthenticationException {
+            if (Thread.currentThread().isInterrupted()) {
+                throw new SaslMechanism.AuthenticationException("Race condition during auth");
+            }
             final var response = this.saslMechanism.getResponse(challenge, sslSocket);
             if (!Strings.isNullOrEmpty(response)) {
                 throw new SaslMechanism.AuthenticationException(

src/main/res/layout/item_account.xml 🔗

@@ -14,8 +14,8 @@
         android:layout_height="wrap_content"
         android:background="?selectableItemBackground"
         android:paddingStart="8dp"
-        android:paddingBottom="8dp"
-        android:paddingTop="8dp">
+        android:paddingTop="8dp"
+        android:paddingBottom="8dp">
 
         <com.google.android.material.imageview.ShapeableImageView
             android:id="@+id/account_image"
@@ -29,19 +29,19 @@
             android:layout_width="fill_parent"
             android:layout_height="wrap_content"
             android:layout_centerVertical="true"
-            android:layout_toEndOf="@+id/account_image"
-            android:orientation="vertical"
             android:layout_marginStart="@dimen/avatar_item_distance"
-            android:layout_toStartOf="@+id/tgl_account_status">
+            android:layout_toStartOf="@+id/tgl_account_status"
+            android:layout_toEndOf="@+id/account_image"
+            android:orientation="vertical">
 
             <TextView
-                tools:text="juliet@example.com"
                 android:id="@+id/account_jid"
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
                 android:scrollHorizontally="false"
                 android:singleLine="true"
-                android:textAppearance="?textAppearanceBodyLarge" />
+                android:textAppearance="?textAppearanceBodyLarge"
+                tools:text="juliet@example.com" />
 
             <LinearLayout
                 android:layout_width="fill_parent"
@@ -68,14 +68,14 @@
             </LinearLayout>
         </LinearLayout>
 
-        <androidx.appcompat.widget.SwitchCompat
+        <com.google.android.material.materialswitch.MaterialSwitch
             android:id="@+id/tgl_account_status"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_alignParentEnd="true"
             android:layout_centerVertical="true"
-            android:padding="16dp"
-            android:focusable="false" />
+            android:focusable="false"
+            android:padding="16dp" />
 
     </RelativeLayout>
     </FrameLayout>

src/main/res/layout/contact_key.xml → src/main/res/layout/item_device_fingerprint.xml 🔗

@@ -36,7 +36,7 @@
 
         <LinearLayout
             android:id="@+id/action_container"
-            android:layout_width="@dimen/key_action_width"
+            android:layout_width="56dp"
             android:layout_height="48dp"
             android:layout_alignParentEnd="true"
             android:layout_centerVertical="true"
@@ -69,7 +69,7 @@
                 android:visibility="gone"
                 app:tint="@color/light_green_600" />
 
-            <androidx.appcompat.widget.SwitchCompat
+            <com.google.android.material.materialswitch.MaterialSwitch
                 android:id="@+id/tgl_trust"
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"

src/main/res/layout/preference_material_switch.xml 🔗

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.google.android.material.materialswitch.MaterialSwitch xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/switchWidget"
+    android:layout_width="wrap_content"
+    android:layout_height="wrap_content"
+    android:background="@null"
+    android:clickable="false"
+    android:focusable="false" />

src/main/res/menu/message_context.xml 🔗

@@ -73,6 +73,10 @@
         android:id="@+id/send_again"
         android:title="@string/send_again"
         android:visible="false" />
+    <item
+        android:id="@+id/send_again_as_p2p"
+        android:title="@string/retry_with_p2p"
+        android:visible="false" />
     <item
         android:id="@+id/download_file"
         android:title="@string/download_x_file"

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

@@ -1109,4 +1109,5 @@
     <string name="delete_avatar_message">Möchtest du deinen Profilbild löschen? Einige Clients zeigen möglicherweise weiterhin eine zwischengespeicherte Kopie deines Profilbildes an.</string>
     <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>

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

@@ -1123,4 +1123,5 @@
     <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>

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

@@ -1083,7 +1083,7 @@
     <string name="pref_automatic_download">Automaatne allalaadimine</string>
     <string name="appearance">Välimus</string>
     <string name="pref_light_dark_mode">Hele või tume kujundus</string>
-    <string name="detect_mim">Nõua kanaliga sidumist</string>
+    <string name="detect_mim">Nõua edastuskanaliga sidumist</string>
     <string name="detect_mim_summary">Edastuskanaliga sidumine võib aidata vahendusrünnete tuvastamisel</string>
     <string name="pref_category_server_connection">Ühendus serveriga</string>
     <string name="pref_category_operating_system">Operatsioonisüsteem</string>
@@ -1129,4 +1129,6 @@
     <string name="delete_avatar_message">Kas sa sooviksid oma tunnuspildi kustutada? Palun arvesta, et mitmed klientrakendused võivad jätkata vana puhverdatud pildi kasutamist.</string>
     <string name="show_to_contacts_only">Näita vaid kontaktidele</string>
     <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>

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

@@ -26,8 +26,8 @@
     <string name="minute_ago">minuutti sitten</string>
     <string name="minutes_ago">%d minuuttia sitten</string>
     <plurals name="x_unread_conversations">
-        <item quantity="one">%d lukematon keskustelu</item>
-        <item quantity="other">%d lukematonta keskustelua</item>
+        <item quantity="one">%d lukematon pikakeskustelu</item>
+        <item quantity="other">%d lukematonta pikakeskustelua</item>
     </plurals>
     <string name="sending">lähettää…</string>
     <string name="message_decrypting">Puretaan viestin salausta. Odota hetki…</string>
@@ -39,7 +39,7 @@
     <string name="moderator">Moderaattori</string>
     <string name="participant">Osallistuja</string>
     <string name="visitor">Vierailija</string>
-    <string name="remove_contact_text">Poistetaanko %s yhteystiedoistasi? Keskustelujasi hänen kanssaan ei poisteta.</string>
+    <string name="remove_contact_text">Poistetaanko %s yhteystiedoistasi? Pikakeskustelujasi hänen kanssaan ei poisteta.</string>
     <string name="block_contact_text">Estetäänkö %s lähettämästä viestejä sinulle?</string>
     <string name="unblock_contact_text">Perutaanko %s:n esto lähettää viestejä sinulle?</string>
     <string name="block_domain_text">Estetäänkö kaikki yhteydet verkkotunnuksesta %s?</string>
@@ -76,7 +76,7 @@
     <string name="preparing_images">Valmistaudutaan lähettämään kuvat</string>
     <string name="sharing_files_please_wait">Jaetaan tiedostoja. Odota hetki…</string>
     <string name="action_clear_history">Pyyhi historia</string>
-    <string name="clear_conversation_history">Pyyhi keskusteluhistoria</string>
+    <string name="clear_conversation_history">Pyyhi pikakeskusteluhistoria</string>
     <string name="clear_histor_msg">Poistetaanko kaikki keskustelun viestit?
 \n
 \n<b>Varoitus:</b> Muilla laitteilla tai palvelimilla säilytettyjä kopioita ei poisteta.</string>
@@ -85,12 +85,12 @@
 \n
 \n<b>Varoitus:</b> Muilla laitteilla tai palvelimilla olevia kopioita ei poisteta. </string>
     <string name="choose_presence">Valitse laite</string>
-    <string name="send_unencrypted_message">Lähetä salaamaton viesti</string>
+    <string name="send_unencrypted_message">Lähetä selkeä tekstiviesti</string>
     <string name="send_message">Lähetä viesti</string>
     <string name="send_message_to_x">Lähetä viesti henkilölle %s</string>
     <string name="send_omemo_x509_message">Lähetä v\\OMEMO-salattu viesti</string>
     <string name="your_nick_has_been_changed">Uusi nimimerkki on jo varattu</string>
-    <string name="send_unencrypted">Lähetä salaamaton</string>
+    <string name="send_unencrypted">Lähetä selkeää tekstiä</string>
     <string name="decryption_failed">Salauksen purku epäonnistui. Sinulle ei varmaan ole oikeaa salaista avainta.</string>
     <string name="openkeychain_required">OpenKeychain</string>
     <string name="openkeychain_required_long"><![CDATA[%1$s käyttää <b>OpenKeychain</b>ia viestien salaamiseeen ja salauksen purkamiseen, sekä julkisten avaintesi hallinointiin.<br><br>Se on GPLv3+-lisensoitu ja saatavilla F-Droidista sekä Google Playsta.<br><br><small>(Käynnistä %1$s uudelleen asennettuasi sovelluksen.)</small>]]></string>
@@ -119,13 +119,13 @@
     <string name="pref_notification_grace_period">Rauhanaika</string>
     <string name="pref_notification_grace_period_summary">Kuinka pitkäksi aikaa ilmoitukset hiljennetään kun jollain toisella laitteillasi tehdään jotain.</string>
     <string name="pref_advanced_options">Edistyneet</string>
-    <string name="pref_never_send_crash_summary">Vikailmoituksia lähettämällä autat kehitystyötä</string>
-    <string name="pref_confirm_messages">Lukukuittaus</string>
+    <string name="pref_never_send_crash_summary">Lähettämällä pinojälkiä autat Quicksyn jatkuvaa kehitystä</string>
+    <string name="pref_confirm_messages">Vahvista viestit</string>
     <string name="pref_confirm_messages_summary">Ilmoita lähettäjälle kun olet vastaanottanut ja lukenut viestin</string>
     <string name="pref_prevent_screenshots">Estä kuvankaappaukset</string>
     <string name="pref_prevent_screenshots_summary">Piilota sovelluksen sisältö sovellusvaihtajassa ja estä ruutukaappaukset</string>
     <string name="pref_ui_options">Käyttöliittymä</string>
-    <string name="openpgp_error">OpenKeychain-virhe</string>
+    <string name="openpgp_error">OpenKeychain tuotti virheen.</string>
     <string name="bad_key_for_encryption">Avain ei kelpaa salaamiseen.</string>
     <string name="accept">Hyväksy</string>
     <string name="error">Virhe tapahtui</string>
@@ -171,14 +171,14 @@
     <string name="unpublish_pgp_message">Haluatko varmasti poistaa OpenPGP-avaimesi tilamainostuksistasi?\nYhteystietosi eivät voi enää lähettää sinulle OpenPGP-salattuja viestejä.</string>
     <string name="openpgp_has_been_published">OpenPGP julkinen avain julkaistu.</string>
     <string name="mgmt_account_enable">Ota tunnus käyttöön</string>
-    <string name="mgmt_account_delete_confirm_text">Haluatko varmasti poistaa tilisi? Tilin poistaminen pyyhkii koko keskusteluhistoriasi</string>
+    <string name="mgmt_account_delete_confirm_text">Oletko varma, että haluat poistaa tilisi? Tilin poistaminen poistaa koko pikakeskusteluhistoriasi</string>
     <string name="attach_record_voice">Nauhoita ääntä</string>
     <string name="account_settings_jabber_id">XMPP-osoite</string>
     <string name="block_jabber_id">Estä XMPP-osoite</string>
     <string name="account_settings_example_jabber_id">käyttäjä@esimerkki.fi</string>
     <string name="password">Salasana</string>
     <string name="invalid_jid">Tämä ei ole kunnollinen XMPP-osoite</string>
-    <string name="error_out_of_memory">Muisti loppui. Kuva on liian suuri.</string>
+    <string name="error_out_of_memory">Muisti loppu. Kuva liian iso</string>
     <string name="add_phone_book_text">Lisätäänkö %s osoitekirjaan?</string>
     <string name="server_info_show_more">Tietoa palvelimesta</string>
     <string name="server_info_mam">XEP-0313: MAM</string>
@@ -265,10 +265,10 @@
     <string name="enter_password">Kirjoita salasana</string>
     <string name="request_presence_updates">Pyydä yhteystietoa ensin lähettämään tilapäivityksiä.\n\n<small>Tätä käytetään sen tunnistamiseen mitä sovellusta tämä käyttää</small>.</string>
     <string name="request_now">Pyydä nyt</string>
-    <string name="ignore">Ohita</string>
+    <string name="ignore">Jätä huomioimatta</string>
     <string name="without_mutual_presence_updates"><b>Varoitus:</b> Tämän lähettäminen ilman molemminpuolisia tilapäivityksiä voi aiheuttaa odottamattomia ongelmia.\n\n<small>Mene \"Yhteystiedon tietoihin\" tarkistaaksesi tilapäivitysten tilauksesi.</small></string>
     <string name="pref_security_settings">Turvallisuus</string>
-    <string name="pref_allow_message_correction">Salli viestien korjaaminen</string>
+    <string name="pref_allow_message_correction">Viestin korjaus</string>
     <string name="pref_allow_message_correction_summary">Mahdollistaa muiden muokata sinulle lähettämiään viestejä jälkikäteen</string>
     <string name="pref_expert_options">Edistyneet asetukset</string>
     <string name="pref_expert_options_summary">Ole varovainen näiden kanssa</string>
@@ -302,8 +302,8 @@
     <string name="jabber_id_copied_to_clipboard">XMPP-osoite kopioitu leikepöydälle</string>
     <string name="error_message_copied_to_clipboard">Vikailmoitus kopioitu leikepöydälle</string>
     <string name="web_address">web-osoite</string>
-    <string name="scan_qr_code">Lue 2D-viivakoodi</string>
-    <string name="show_qr_code">Näytä 2D-viivakoodi</string>
+    <string name="scan_qr_code">Skannaa QR-koodi</string>
+    <string name="show_qr_code">Näytä QR-koodi</string>
     <string name="show_block_list">Näytä estolista</string>
     <string name="account_details">Tilitiedot</string>
     <string name="confirm">Vahvista</string>
@@ -438,7 +438,7 @@
     <string name="download_failed_could_not_connect">Lataus epöonnistui: Isäntään ei saatu yhteyttä</string>
     <string name="download_failed_could_not_write_file">Lataus epäonnistui: Tiedoston tallennus epäonnistui</string>
     <string name="account_status_tor_unavailable">Tor-verkkoa ei saavutettu</string>
-    <string name="account_status_host_unknown">Palvelin ei vastaa tästä verkkotunnuksesta</string>
+    <string name="account_status_host_unknown">Ei vastuussa toimialueesta</string>
     <string name="server_info_broken">Rikki</string>
     <string name="pref_presence_settings">Saatavuus</string>
     <string name="pref_away_when_screen_off">Poissa kun laite on lukittu</string>
@@ -447,8 +447,8 @@
     <string name="pref_dnd_on_silent_mode_summary">Näytä minut kiireisenä kun laite on äänettömänä</string>
     <string name="pref_treat_vibrate_as_silent">Kohtele vain värinä -tilaa äänettömän lailla</string>
     <string name="pref_treat_vibrate_as_dnd_summary">Näytä minut kiireisenä kun laite on vain värinä -tilassa</string>
-    <string name="pref_show_connection_options">Laajemmat yhteysasetukset</string>
-    <string name="pref_show_connection_options_summary">Näytä isäntänimen ja portin valinta tiliä lisätessä</string>
+    <string name="pref_show_connection_options">Isäntänimi ja portti</string>
+    <string name="pref_show_connection_options_summary">Näytä laajennetut yhteysasetukset tilin määrittämisen yhteydessä</string>
     <string name="hostname_example">xmpp.esimerkki.fi</string>
     <string name="action_add_account_with_certificate">Kirjaudu varmenteella</string>
     <string name="unable_to_parse_certificate">Varmenteen jäsennys epäonnistui</string>
@@ -483,11 +483,8 @@
     <string name="shared_images_with_x">Kuvat jaettu %s:n kanssa</string>
     <string name="no_storage_permission">Salli %1$s:n käyttää ulkoista tallennustilaa</string>
     <string name="no_camera_permission">Salli %1$s:n käyttää kameraa </string>
-    <string name="sync_with_contacts">Synkronoi yhteystietojen kanssa</string>
-    <string name="sync_with_contacts_long">%1$s haluaa pääsyn osoitekirjaasi yhdistääkseen sen XMPP-yhteystietojesi kanssa.
-\nTämä näyttää yhteystietojesi koko nimen ja kuvan.
-\n
-\n%1$s pelkästään lukee osoitekirjaasi ja vertailee niitä paikallisesti, lähettämättä mitään palvelimelle.</string>
+    <string name="sync_with_contacts">Yhteystietolistan integrointi</string>
+    <string name="sync_with_contacts_long">%1$s käsittelee yhteystietoluettelosi paikallisesti laitteellasi näyttääkseen sinulle nimet ja profiilikuvat vastaavista XMPP-yhteystiedoista.\n\nMikään yhteystietoluettelo ei koskaan poistu laitteestasi!</string>
     <string name="notify_on_all_messages">Ilmoita kaikista uusista viesteistä</string>
     <string name="notify_only_when_highlighted">Ilmoita vain kun minut mainitaan</string>
     <string name="notify_never">Ilmoitukset pois käytöstä</string>
@@ -531,7 +528,7 @@
     <string name="gp_short">Lyhyt</string>
     <string name="gp_medium">Keskipitkä</string>
     <string name="gp_long">Pitkä</string>
-    <string name="pref_broadcast_last_activity_summary">Kertoo yhteystiedoillesi milloin käytät Conversationsia</string>
+    <string name="pref_broadcast_last_activity_summary">Anna yhteyshenkilöillesi nähdä, milloin viimeksi käytit sovellusta</string>
     <string name="pref_privacy">Yksityisyys</string>
     <string name="pref_theme_options">Teema</string>
     <string name="pref_theme_options_summary">Valitse väripaletti</string>
@@ -578,7 +575,7 @@
     <string name="pref_blind_trust_before_verification_summary">Luota uusiin laitteisiin varmistamattomilta yhteystiedoilta, mutta vaadi varmistettujen yhteystietojen uusien laitteiden manuaalinen hyväksyminen.</string>
     <string name="blindly_trusted_omemo_keys">OMEMO-avaimiin luotetaan sokeasti, eli ne voivat olla jonkun muun tai joku voi salakuunnella.</string>
     <string name="not_trusted">Ei luotettu</string>
-    <string name="invalid_barcode">Viallinen 2D-viivakoodi</string>
+    <string name="invalid_barcode">Virheellinen QR-koodi</string>
     <string name="pref_clean_cache_summary">Tyhjennä välimuisti (kamerasovelluksen käyttämä)</string>
     <string name="pref_clean_cache">Tyhjennä välimuisti</string>
     <string name="pref_clean_private_storage">Siivoa yksityinen tallennustila</string>
@@ -660,7 +657,7 @@
     <string name="disable_now">Poista käytöstä nyt</string>
     <string name="draft">Luonnos:</string>
     <string name="pref_omemo_setting">OMEMO-salaus</string>
-    <string name="pref_omemo_setting_summary_default_on">Uusissa keskusteluissa OMEMO otetaan oletuksena käyttöön.</string>
+    <string name="pref_omemo_setting_summary_default_on">Uusissa pikakeskusteluissa OMEMO otetaan oletuksena käyttöön.</string>
     <string name="create_shortcut">Luo pikakuvake</string>
     <string name="default_on">Käytössä oletuksena</string>
     <string name="default_off">Oletuksena pois käytöstä</string>
@@ -677,13 +674,13 @@
     <string name="please_wait">Odota hetki…</string>
     <string name="no_microphone_permission">Salli %1$s:n käyttää mikrofonia</string>
     <string name="gif">GIF</string>
-    <string name="view_conversation">Näytä keskustelu</string>
+    <string name="view_conversation">Näytä pikakeskustelu</string>
     <string name="pref_use_share_location_plugin">Sijainnin jako -lisäosa</string>
     <string name="pref_use_share_location_plugin_summary">Käytä lisäosaa sisäänrakennetun kartan sijaan</string>
     <string name="copy_link">Kopioi web-osoite</string>
     <string name="copy_jabber_id">Kopioi XMPP-osoite</string>
     <string name="p1_s3_filetransfer">Tiedostonjako HTTP:llä S3:een</string>
-    <string name="pref_start_search_summary">\'Aloita keskustelu\' -näytöllä avaa näppäimistö ja siirrä kursori hakukenttään</string>
+    <string name="pref_start_search_summary">\'Uusi pikakeskustelu\' -näytön esiintymisen yhteydessä, avaa näppäimistö ja aseta kohdistin hakukenttään</string>
     <string name="group_chat_avatar">Ryhmän kuvake</string>
     <string name="host_does_not_support_group_chat_avatars">Isäntäpalvelin ei tue ryhmäkeskustelun kuvakkeita</string>
     <string name="only_the_owner_can_change_group_chat_avatar">Vain omistaja voi vaihtaa kuvakkeen</string>
@@ -725,20 +722,20 @@
     <string name="verify_x">Varmista %s</string>
     <string name="we_have_sent_you_an_sms_to_x"><![CDATA[Lähetimme sinulle tekstiviestin numeroon <b>%s</b>.]]></string>
     <string name="we_have_sent_you_another_sms">Lähetimme sinulle uuden viestin 6-numeroisella koodilla.</string>
-    <string name="please_enter_pin_below">Kirjoita 6-numeroinen PIN-koodi alapuolelle.</string>
+    <string name="please_enter_pin_below">Syötä kuusinumeroinen PIN-koodi alle.</string>
     <string name="resend_sms">Lähetä uusi tekstiviesti</string>
     <string name="resend_sms_in">Lähetä uusi tekstivieti (%s)</string>
     <string name="wait_x">Odota (%s)</string>
-    <string name="back">takaisin</string>
+    <string name="back">Takaisin</string>
     <string name="possible_pin">Mahdollinen PIN-koodi liitettiin leikepöydältä automaattisesti.</string>
-    <string name="please_enter_pin">Syötä 6-numeroinen PIN-koodisi.</string>
+    <string name="please_enter_pin">Syötä kuusinumeroinen PIN-koodisi.</string>
     <string name="abort_registration_procedure">Haluatko varmasti perua rekisteröintiprosessin?</string>
     <string name="yes">Kyllä</string>
     <string name="no">Ei</string>
     <string name="verifying">Varmistetaan…</string>
     <string name="requesting_sms">Pyydetään tekstiviestiä…</string>
     <string name="incorrect_pin">Syöttämäsi PIN-koodi on väärä.</string>
-    <string name="pin_expired">Lähettämämme PIN-koodi on vanhentunut.</string>
+    <string name="pin_expired">Sinulle lähettämämme PIN-koodi on vanhentunut.</string>
     <string name="unknown_api_error_network">Tuntematon verkkovirhe.</string>
     <string name="unknown_api_error_response">Tuntematon vastaus palvelimelta.</string>
     <string name="unable_to_connect_to_server">Palvelimeen ei saatu yhteyttä.</string>
@@ -762,7 +759,7 @@
     <string name="ebook">e-kirja</string>
     <string name="video_original">Alkuperäinen (pakkaamaton)</string>
     <string name="open_with">Avaa sovelluksella…</string>
-    <string name="set_profile_picture">Conversations-profiilikuva</string>
+    <string name="set_profile_picture">Keskustelun profiilikuva</string>
     <string name="choose_account">Valitse tili</string>
     <string name="restore_backup">Palauta varmuuskopiosta</string>
     <string name="restore">Palauta</string>
@@ -861,8 +858,8 @@
     <string name="remove_from_favorites">Irrota</string>
     <string name="gpx_track">GPX-reitti</string>
     <string name="could_not_correct_message">Viestin korjaaminen epäonnistui</string>
-    <string name="search_all_conversations">Kaikki keskustelut</string>
-    <string name="search_this_conversation">Tämä keskustelu</string>
+    <string name="search_all_conversations">Kaikki pikakeskustelut</string>
+    <string name="search_this_conversation">Tämä pikakeskustelu</string>
     <string name="your_avatar">Profiilikuvasi</string>
     <string name="avatar_for_x">%s:n profiilikuva</string>
     <string name="encrypted_with_omemo">OMEMO-salattu</string>
@@ -893,7 +890,7 @@
     <string name="plain_text_document">Perustekstiasiakirja</string>
     <string name="pref_omemo_setting_summary_always">OMEMO:a käytetään aina kaikissa yksityisissä keskusteluissa.</string>
     <string name="search_contacts">Etsi yhteystiedoista</string>
-    <string name="pref_omemo_setting_summary_default_off">OMEMO täytyy ottaa käyttöön käsin uusissa keskusteluissa.</string>
+    <string name="pref_omemo_setting_summary_default_off">OMEMO on otettava nimenomaisesti käyttöön uusille pikakeskusteluille.</string>
     <string name="group_chats">Ryhmäkeskustelut</string>
     <string name="search_group_chats">Etsi ryhmäkeskusteluista</string>
     <string name="pref_start_search">Suoraan hakuun</string>
@@ -908,7 +905,7 @@
     <string name="pref_autojoin">Synkronoi kirjanmerkit</string>
     <string name="outgoing_call_duration_timestamp">Lähtevä puhelu (%s) · %s</string>
     <string name="download_failed_invalid_file">Lataus epäonnistui: Kelvoton tiedosto</string>
-    <string name="pref_broadcast_last_activity">Julkaise käyttö</string>
+    <string name="pref_broadcast_last_activity">Viimeksi nähty</string>
     <string name="continue_btn">Jatka</string>
     <string name="account_state_logged_out">Kirjautunut ulos</string>
     <plurals name="n_missed_calls">
@@ -929,4 +926,180 @@
     <string name="audiobook">Äänikirja</string>
     <string name="silent_messages_channel_name">Hiljaiset viestit</string>
     <string name="rtp_state_content_add_video">Vaihdetaanko videopuheluun\?</string>
+    <string name="channel_discover_opt_in_message">Kanavahaku käyttää kolmannen osapuolen palvelua nimeltä &lt;a href=https://search.jabber.network&gt;search.jabber.network&lt;/a&gt;.&lt;br&gt;&lt;br&gt;Tämän ominaisuuden käyttäminen lähettää IP-osoitteesi ja hakutermit kyseiseen palveluun. Katso lisätietoja niiden &lt;a href=https://search.jabber.network/privacy&gt;tietosuojakäytännöstään&lt;/a&gt;.</string>
+    <string name="magic_create_text">Opas on laadittu tilin luomista varten conversations.imissa.\nKun valitset palveluntarjoajaksi conversations.im, voit kommunikoida muiden palveluntarjoajien käyttäjien kanssa antamalla heille koko XMPP-osoitteesi.</string>
+    <string name="pref_send_crash_reports">Lähetä kaatumisraportit</string>
+    <string name="contact_asks_for_presence_subscription">Yhteyshenkilö pyytää läsnäolotilausta</string>
+    <string name="barcode_does_not_contain_fingerprints_for_this_chat">Viivakoodi ei sisällä sormenjälkiä tälle pikakeskustelulle.</string>
+    <string name="corresponding_chats_closed">Vastaavat pikakeskustelut arkistoitu.</string>
+    <string name="pref_message_notification_settings">Viestien ilmoitusasetukset</string>
+    <string name="rate_limited">Sinuun sovelletaan käyttörajoitus</string>
+    <string name="rtp_state_content_add">Lisätäänkö lisää säikeitä?</string>
+    <string name="appearance">Ulkoasu</string>
+    <string name="pref_light_dark_mode">Vaalea/tumma tila</string>
+    <string name="account_status_connection_timeout">Yhteyden aikakatkaisu</string>
+    <string name="account_status_policy_violation">Käytännön rikkomus</string>
+    <string name="account_status_incompatible_client">Yhteensopimaton asiakas</string>
+    <string name="account_status_stream_error">Virtavirhe</string>
+    <string name="account_status_stream_opening_error">Virran avaamisvirhe</string>
+    <string name="pref_autojoin_summary">Aseta \"autojoin\" -lippu, kun tulet MUC:hen tai poistut siitä ja reagoi muiden asiakkaiden tekemiin muutoksiin.</string>
+    <string name="conference_technical_problems">Poistuit tästä ryhmäpikakeskustelusta teknisistä syistä</string>
+    <string name="could_not_change_affiliation">%s:n sidossuhdetta ei voitu muuttaa</string>
+    <string name="multimedia_file">multimediatiedosto</string>
+    <string name="account_status_bind_failure">Sidonnan epäonnistuminen</string>
+    <string name="battery_optimizations_enabled_dialog">Laitteesi käyttää raskasta akun optimointia kohteelle %1$s, mikä voi johtaa viivästyneisiin ilmoituksiin tai jopa viestien katoamiseen.\n\nSinua pyydetään nyt poistamaan ne käytöstä.</string>
+    <string name="welcome_header">Liity Conversationiin</string>
+    <string name="pref_use_colorful_bubbles_summary">Erilliset taustavärit lähetetyille ja vastaanotetuille viesteille</string>
+    <string name="distrust_omemo_key">Epäluotettava laite</string>
+    <string name="not_fetching_history_retention_period">Viestejä ei noudeta paikallisen säilytysajan vuoksi.</string>
+    <string name="pref_dynamic_colors_summary">Järjestelmän värit (Material You)</string>
+    <string name="action_unfix_from_location">Ei kiinteä asento</string>
+    <string name="foreground_service_channel_description">Tätä ilmoitusluokkaa käytetään näyttämään pysyvä ilmoitus, joka ilmaisee, että %1$s on käynnissä.</string>
+    <string name="invalid_user_input">Virheellinen käyttäjän syöte</string>
+    <string name="restore_warning_continued">Älä yritä palauttaa varmuuskopioita, joita et ole itse luonut!</string>
+    <string name="search_participants">Etsi osallistujat</string>
+    <string name="rtp_state_contact_offline">Yhteyshenkilö ei ole saatavilla</string>
+    <string name="switch_to_chat">Vaihda pikakeskusteluun</string>
+    <string name="account_registrations_are_not_supported">Tilin rekisteröintiä ei tueta</string>
+    <string name="delete_avatar">Poista avatar</string>
+    <string name="reject_switch_to_video">Hylkää siirtyminen videopyyntöön</string>
+    <string name="switch_to_video">Siirry videoon</string>
+    <string name="pref_up_push_server_title">Työntö-palvelin</string>
+    <string name="no_account_deactivated">Ei mitään (poistettu käytöstä)</string>
+    <string name="decline">Hylkää</string>
+    <string name="delete_from_server">Poista tili palvelimelta</string>
+    <string name="could_not_delete_account_from_server">Tiliä ei voitu poistaa palvelimelta</string>
+    <string name="hide_notification">Piilota ilmoitus</string>
+    <string name="log_out">Kirjaudu ulos</string>
+    <string name="contact_uses_unverified_keys">Yhteystietosi käyttää vahvistamattomia laitteita. Skannaa heidän QR-koodinsa suorittaaksesi vahvistuksen ja estääksesi aktiiviset MITM-hyökkäykset.</string>
+    <string name="unverified_devices">Käytät vahvistamattomia laitteita. Skannaa QR-koodi muilla laitteillasi vahvistaaksesi ja estääksesi aktiiviset MITM-hyökkäykset.</string>
+    <string name="report_spam">Ilmoita roskapostista</string>
+    <string name="report_spam_and_block">Ilmoita roskapostista ja estä roskapostittaja</string>
+    <string name="call_integration_not_available">Puheluintegraatio ei ole saatavilla!</string>
+    <string name="delete_and_close">Poista ja arkistoi pikakeskustelu</string>
+    <string name="start_chat">Aloita pikakeskustelu</string>
+    <string name="pref_title_interface">Käyttöliittymä</string>
+    <string name="pref_summary_appearance">Teema, värit, kuvakaappaukset, syöttö</string>
+    <string name="pref_title_security">Turvallisuus</string>
+    <string name="pref_summary_security">E2E-salaus, sokea luottamus ennen vahvistusta, MITM-tunnistus</string>
+    <string name="unified_push_summary">Ilmoitusvälitys UnifiedPush-yhteensopiville kolmannen osapuolen sovelluksille</string>
+    <string name="notifications">Ilmoitukset</string>
+    <string name="pref_allow_screenshots">Salli kuvakaappaukset</string>
+    <string name="pref_category_e2ee">Päästä päähän -salaus</string>
+    <string name="pref_title_trust_system_ca_store">Varmenneviranomaiset</string>
+    <string name="pref_title_trust_system_ca_store_summary">Luota järjestelmän CA-varmenteisiin</string>
+    <string name="detect_mim">Edellyttää kanavan sidontaa</string>
+    <string name="detect_mim_summary">Kanavan sidonta voi havaita joitain koneen välissä olevia hyökkäyksiä</string>
+    <string name="pref_category_server_connection">Palvelinyhteys</string>
+    <string name="pref_privacy_summary">Kirjoitusilmoitukset, viimeksi nähty, saatavuus</string>
+    <string name="pref_connection_summary">Isäntänimi ja portti, Tor</string>
+    <string name="pref_category_engagement_notifications">Sitoutumisilmoitukset</string>
+    <string name="remove_bookmark">Haluatko poistaa kirjanmerkin kohteesta %s?</string>
+    <string name="remove_bookmark_and_close">Haluatko poistaa %s:n kirjanmerkin ja arkistoida pikakeskustelun?</string>
+    <string name="conversations_backup">Keskustelujen varmuuskopio</string>
+    <string name="outdated_backup_file_format">Yrität tuoda vanhentunutta varmuuskopiotiedostomuotoa</string>
+    <string name="hide_inactive_devices">Piilota ei-aktiivinen</string>
+    <string name="battery_optimizations_enabled_explained">Laitteesi käyttää raskasta akun optimointia kohteelle %1$s, mikä voi johtaa viivästyneisiin ilmoituksiin tai jopa viestien katoamiseen.\nOn suositeltavaa poistaa ne käytöstä.</string>
+    <string name="no_permission_to_place_call">Ei lupaa puhelun soittamiseen</string>
+    <string name="silent_messages_channel_description">Tätä ilmoitusryhmää käytetään näyttämään ilmoituksia, joiden ei pitäisi laukaista ääntä. Esimerkiksi ollessasi aktiivinen toisella laitteella (armoaika).</string>
+    <string name="enter_your_name_instructions">Syötä nimesi, jotta ihmiset, joiden osoitekirjassa ei ole sinua, tietävät kuka olet.</string>
+    <string name="could_not_disable_video">Videota ei voitu poistaa käytöstä.</string>
+    <string name="quicksy_wants_your_consent">Quicksy pyytää suostumustasi tietojesi käyttöön</string>
+    <string name="title_activity_new_chat">Uusi pikakeskustelu</string>
+    <string name="error_no_keys_to_trust_presence">Tälle yhteyshenkilölle ei ole käytettävissä avaimia.\nVarmista, että teillä molemmilla on läsnäolotilaus.</string>
+    <string name="invalid_username">Tämä ei ole kelvollinen käyttäjänimi</string>
+    <string name="show_inactive_devices">Näytä ei-aktiivinen</string>
+    <string name="security_error_invalid_file_access">Turvavirhe: Virheellinen tiedoston käyttöoikeus!</string>
+    <string name="welcome_header_quicksy">Tervetuloa Quicksyyn!</string>
+    <string name="pref_dynamic_colors">Dynaamiset värit</string>
+    <string name="pref_connection_summary_w_cd">Isäntänimi ja portti, Tor, kanavan löytö</string>
+    <string name="pref_allow_screenshots_summary">Näytä sovelluksen sisältö sovelluksen vaihtajassa ja salli kuvakaappausten ottaminen</string>
+    <string name="distrust_omemo_key_text">Oletko varma, että haluat poistaa tämän laitteen vahvistuksen?\nTämä laite ja sen viestit merkitään \"Epäluotettava\":ksi.</string>
+    <string name="audio_video_disabled_tor">Puhelut on poistettu käytöstä Toria käytettäessä</string>
+    <string name="no_certificate_selected">Asiakasvarmennetta ei ole valittu!</string>
+    <string name="privacy_policy">Tietosuojakäytäntö</string>
+    <string name="search_countries">Etsi maita</string>
+    <string name="contact_list_integration_not_available">Yhteystietolistan integrointi ei ole saatavilla</string>
+    <string name="no_affiliation">Ei sidossuhdetta</string>
+    <string name="shared_text_with_x">Teksti jaettu %s:n kanssa</string>
+    <string name="pref_headsup_notifications">Huomioi ilmoitukset</string>
+    <string name="pref_headsup_notifications_summary">Näytä huomioidut ilmoitukset</string>
+    <string name="action_fix_to_location">Kiinteä asento</string>
+    <string name="unable_to_start_recording">Tallentamista ei voitu aloittaa</string>
+    <string name="error_channel_description">Tätä ilmoitusluokkaa käytetään näyttämään ilmoitus, jos tiliin yhdistämisessä on ongelma.</string>
+    <string name="ongoing_calls_channel_name">Käynnissä olevat puhelut</string>
+    <string name="security_violation_not_attaching_file">Tiedosto jätetty pois tietoturvarikkomuksen vuoksi.</string>
+    <string name="log_in">Kirjaudu sisään</string>
+    <string name="title_activity_share_with">Jaa kanssa…</string>
+    <string name="pref_use_colorful_bubbles">Värikkäät pikakeskustelukuplat</string>
+    <string name="action_archive_chat">Arkistoi pikakeskustelu</string>
+    <string name="share_with">Jaa kanssa…</string>
+    <string name="archive_this_chat">Poista pikakeskustelu jälkeenpäin</string>
+    <string name="send_encrypted_message">Lähetä salattu viesti</string>
+    <string name="perform_action_with">Suorita toiminto kanssa</string>
+    <string name="pref_category_receiving">Vastaanotetaan</string>
+    <string name="pref_automatic_download">Automaattinen lataus</string>
+    <string name="pref_category_operating_system">Käyttöjärjestelmä</string>
+    <string name="pref_keyboard_options">Näppäimistö</string>
+    <string name="pref_attachments_summary">Tiedoston koko, kuvan pakkaus, videon laatu</string>
+    <string name="pref_notifications_summary">Armonaika, soittoääni, värinä, muukalaiset</string>
+    <string name="pref_category_sending">Lähetetään</string>
+    <string name="no_xmpp_adddress_found">XMPP-osoitetta ei löytynyt</string>
+    <string name="account_status_temporary_auth_failure">Väliaikainen todennuksen epäonnistuminen</string>
+    <string name="pref_up_push_account_title">XMPP-tili</string>
+    <string name="pref_up_push_account_summary">Tili, jonka kautta työntöviestit vastaanotetaan.</string>
+    <string name="title_undo_swipe_out_chat">Pikakeskustelu arkistoitu</string>
+    <string name="your_avatar_tap_to_select_new_avatar">Sinun avatarisi. Napauta valitaksesi uuden avatarin galleriasta.</string>
+    <string name="server_info_sasl2">XEP-0388: laajennettava SASL-profiili</string>
+    <string name="error_no_keys_to_trust_server_error">Tälle yhteyshenkilölle ei ole käytettävissä avaimia.\nUusia avaimia ei voitu noutaa palvelimelta. Ehkä yhteystietosi palvelimessa on jotain vikaa?</string>
+    <string name="reconnect_on_other_host">Yhdistä uudelleen toiseen isäntään</string>
+    <string name="verifying_omemo_keys_trusted_source_account">Olet vahvistamassa oman tilisi OMEMO-avaimet. Tämä on turvallista vain, jos seurasit tätä linkkiä luotettavasta lähteestä, jossa vain sinä olisit voinut julkaista tämän linkin.</string>
+    <string name="pref_category_application">Sovellus</string>
+    <string name="pref_category_interaction">Vuorovaikutus</string>
+    <string name="pref_category_on_this_device">Laitteella</string>
+    <string name="pref_backup_summary">Luo yksikertainen, ajoita toistuva</string>
+    <string name="pref_create_backup_one_off_summary">Luo yksikertainen varmuuskopio</string>
+    <string name="call_is_using_wired_headset">Puhelussa käytetään langallisia kuulokkeita</string>
+    <string name="call_is_using_speaker_tap_to_switch_to_earpiece">Puhelussa käytetään kaiutinta. Napauta vaihtaaksesi kuulokkeisiin.</string>
+    <string name="call_is_using_speaker">Puhelussa käytetään kaiutinta.</string>
+    <string name="call_is_using_bluetooth">Puhelussa käytetään bluetoothia.</string>
+    <string name="flip_camera">Käännä kamera</string>
+    <string name="video_is_enabled_tap_to_disable">Video on kytketty päälle. Poista käytöstä napauttamalla.</string>
+    <string name="video_is_disabled_tap_to_enable">Video on poistettu käytöstä. Ota käyttöön napauttamalla.</string>
+    <string name="server_info_login_mechanism">Sisäänkirjautumismekanismi</string>
+    <string name="pref_chat_bubbles_summary">Taustaväri, kirjasinkoko, avatarit</string>
+    <string name="delete_avatar_message">Haluatko poistaa avatarisi? Jotkut asiakkaat saattavat edelleen näyttää avatarisi välimuistissa olevan kopion.</string>
+    <string name="pref_large_font_summary">Suurenna viestikuplien kirjasinkokoa</string>
+    <string name="custom_notifications_enable">Otetaanko mukautetut ilmoitusasetukset (tärkeys, ääni, värinä) käyttöön tälle keskustelulle?</string>
+    <string name="could_not_modify_call">Puhelua ei voitu muokata</string>
+    <string name="pref_accept_invites_from_strangers_summary">Hyväksy kutsut ryhmäpikakeskusteluihin tuntemattomilta</string>
+    <string name="pref_accept_invites_from_strangers">Kutsut tuntemattomilta</string>
+    <string name="allow_private_messages">Salli yksityisviestit</string>
+    <string name="pref_large_font">Suuri fontti</string>
+    <string name="unsupported_operation">Toimintoa ei tueta</string>
+    <string name="edit_nick">Muokkaa lempinimeä</string>
+    <string name="delete_pgp_key">Poista OpenPGP-avain</string>
+    <string name="edit_name_and_topic">Muokkaa nimeä ja aihetta</string>
+    <string name="edit_configuration">Muuta kokoonpanoa</string>
+    <string name="change_notification_settings">Muuta ilmoitusasetuksia</string>
+    <string name="call_is_using_earpiece_tap_to_switch_to_speaker">Puhelussa käytetään kuuloketta. Napauta vaihtaaksesi kaiuttimeen.</string>
+    <string name="pref_backup_recurring">Toistuva varmuuskopio</string>
+    <string name="pref_fullscreen_notification">Koko näyttö -ilmoitukset</string>
+    <string name="pref_fullscreen_notification_summary">Salli tämän sovelluksen näyttää saapuvien puhelujen ilmoitukset, jotka täyttävät koko näytön, kun laite on lukittuna.</string>
+    <string name="call_is_using_earpiece">Puhelussa käytetään kuulokkeita.</string>
+    <string name="clients_may_not_support_av">Yhteyshenkilösi XMPP-asiakasohjelma ei ehkä tue audio-/videopuheluita.</string>
+    <string name="could_not_add_reaction">Reaktiota ei voitu lisätä</string>
+    <string name="add_reaction">Lisää reaktio…</string>
+    <string name="pref_call_integration">Puheluintegraatio</string>
+    <string name="pref_call_integration_summary">Tämän sovelluksen puhelut ovat vuorovaikutuksessa tavallisten puheluiden kanssa, kuten puhelun lopettaminen toisen alkaessa.</string>
+    <string name="pref_align_start">Vasemmalle tasatut viestit</string>
+    <string name="pref_align_start_summary">Näytä kaikki viestit, mukaan lukien lähetetyt, vasemmalla puolella yhtenäisen pikakeskustelu-asettelun saavuttamiseksi.</string>
+    <string name="custom_notifications">Mukautetut ilmoitukset</string>
+    <string name="show_to_contacts_only">Näytä vain yhteyshenkilöille</string>
+    <string name="add_reaction_title">Lisää reaktio</string>
+    <string name="more_reactions">Lisää reaktioita</string>
+    <string name="pref_show_avatars_summary">Näytä avatarit viestillesi ja kahdenkeskisissä pikakeskusteluissa ryhmäpikakeskustelujen lisäksi.</string>
+    <string name="pref_chat_bubbles">Pikakeskustelukuplat</string>
+    <string name="pref_show_avatars">Näytä avatarit</string>
+    <string name="pref_title_bubbles">Pikakeskustelukuplat</string>
 </resources>

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

@@ -334,7 +334,7 @@
     <string name="delete_x_file">Eliminar %s</string>
     <string name="file">ficheiro</string>
     <string name="open_x_file">Abrir %s</string>
-    <string name="sending_file">enviando (%1$d %% completado)</string>
+    <string name="sending_file">enviando (%1$d%% completado)</string>
     <string name="preparing_file">Preparándose para compartir o ficheiro</string>
     <string name="x_file_offered_for_download">Ofreceuse %s para descargar</string>
     <string name="cancel_transmission">Cancelar a transmisión</string>
@@ -1107,4 +1107,6 @@
     <string name="delete_avatar_message">Queres eliminar o teu avatar? Algúns clientes poderían continuar mostrando unha copia almacenada do teu avatar.</string>
     <string name="show_to_contacts_only">Mostrar só aos contactos</string>
     <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>
 </resources>

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

@@ -460,8 +460,8 @@
     <string name="download_failed_could_not_write_file">Scaricamento fallito: scrittura del file impossibile</string>
     <string name="download_failed_invalid_file">Scaricamento fallito: file non valido</string>
     <string name="account_status_tor_unavailable">Rete Tor non disponibile</string>
-    <string name="account_status_bind_failure">Bind fallito</string>
-    <string name="account_status_host_unknown">Il server non è responsabile per questo dominio</string>
+    <string name="account_status_bind_failure">Associazione fallita</string>
+    <string name="account_status_host_unknown">Non responsabile per il dominio</string>
     <string name="server_info_broken">Rotto</string>
     <string name="pref_presence_settings">Disponibilità</string>
     <string name="pref_away_when_screen_off">\"Non disponibile\" a dispositivo bloccato</string>
@@ -1107,4 +1107,22 @@
     <string name="add_reaction">Aggiungi reazione…</string>
     <string name="add_reaction_title">Aggiungi reazione</string>
     <string name="more_reactions">Altre reazioni</string>
+    <string name="account_status_connection_timeout">Connessione scaduta</string>
+    <string name="show_to_contacts_only">Mostra solo ai contatti</string>
+    <string name="retry_with_p2p">Riprova con P2P</string>
+    <string name="could_not_modify_call">Impossibile modificare la chiamata</string>
+    <string name="clients_may_not_support_av">Il client XMPP del tuo contatto potrebbe non supportare le chiamate audio/video.</string>
+    <string name="pref_call_integration">Integrazione di chiamate</string>
+    <string name="pref_call_integration_summary">Le chiamate da questa app interagiscono con le normali chiamate telefoniche, come terminare una chiamata quando un\'altra inizia.</string>
+    <string name="pref_align_start">Messaggi allineati a sinistra</string>
+    <string name="pref_show_avatars">Mostra gli avatar</string>
+    <string name="pref_show_avatars_summary">Mostra gli avatar per i tuoi messaggi e nelle chat 1:1, in aggiunta alle chat di gruppo.</string>
+    <string name="custom_notifications_enable">Attivare le impostazioni di notifica personalizzate (importanza, suono, vibrazione) per questa conversazione?</string>
+    <string name="pref_align_start_summary">Mostra tutti i messaggi, inclusi quelli inviati, sul lato sinistro per una disposizione uniforme della chat.</string>
+    <string name="custom_notifications">Notifiche personalizzate</string>
+    <string name="delete_avatar_message">Vuoi eliminare il tuo avatar? Alcuni client potrebbero continuare a mostrare una copia in cache del tuo avatar.</string>
+    <string name="pref_chat_bubbles">Messaggi di chat</string>
+    <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>
 </resources>

src/main/res/values-night/themes.xml 🔗

@@ -28,6 +28,7 @@
         <item name="colorOnSurfaceInverse">@color/md_theme_dark_inverseOnSurface</item>
         <item name="colorSurfaceInverse">@color/md_theme_dark_inverseSurface</item>
         <item name="colorPrimaryInverse">@color/md_theme_dark_inversePrimary</item>
+        <item name="preferenceTheme">@style/MaterialPreferenceThemeOverlay</item>
     </style>
 
     <style name="Theme.Conversations3.SplashScreen" parent="@style/Theme.Conversations3">

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

@@ -1125,4 +1125,6 @@
     <string name="show_to_contacts_only">Alleen weergeven aan contacten</string>
     <string name="delete_avatar_message">Wil je je avatar verwijderen? Sommige clients kunnen nog steeds een kopie van jouw avatar in de cache weergeven.</string>
     <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>

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

@@ -1139,4 +1139,5 @@
     <string name="delete_avatar_message">Czy chcesz usunąć swój awatar? Niektórzy klienci mogą nadal pokazywać zachowaną kopię.</string>
     <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>
 </resources>

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

@@ -1127,4 +1127,6 @@
     <string name="show_to_contacts_only">Mostrar somente para contatos</string>
     <string name="delete_avatar_message">Você gostaria de excluir seu avatar? Alguns clientes podem continuar mostrando uma cópia em cache do seu avatar.</string>
     <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>
 </resources>

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

@@ -1126,4 +1126,6 @@
     <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="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>
 </resources>

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

@@ -676,7 +676,7 @@
     <string name="pref_automatically_delete_messages_description">Автоматически удалять сообщения с этого устройства по прошествии заданного времени.</string>
     <string name="encrypting_message">Зашифровать сообщение</string>
     <string name="not_fetching_history_retention_period">Сообщения не загружаются в соответствии с локальным сроком хранения.</string>
-    <string name="transcoding_video">Сжимание видео</string>
+    <string name="transcoding_video">Сжатие видео</string>
     <string name="contact_blocked_past_tense">Контакт заблокирован.</string>
     <string name="pref_notifications_from_strangers">Уведомления от неизвестных контактов</string>
     <string name="pref_notifications_from_strangers_summary">Уведомлять о сообщениях и звонках от неизвестных контактов</string>
@@ -1156,4 +1156,6 @@
     <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>
 </resources>

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

@@ -1120,4 +1120,5 @@
     <string name="show_to_contacts_only">Shfaqja vetëm kontakteve</string>
     <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>

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

@@ -1143,4 +1143,6 @@
     <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>
 </resources>

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

@@ -1155,4 +1155,6 @@
     <string name="show_to_contacts_only">Показувати лише контактам</string>
     <string name="delete_avatar_message">Бажаєте видалити свій аватар? Деякі клієнти можуть продовжувати відображати копію Вашого аватара з кешу.</string>
     <string name="account_status_connection_timeout">Час очікування з\'єднання вичерпано</string>
+    <string name="retry_with_p2p">Повторити спробу з P2P</string>
+    <string name="account_status_channel_binding">Прив\'язка каналу недоступна</string>
 </resources>

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

@@ -150,7 +150,7 @@
 \n<small>请使用其他文件管理器选择图片</small>。</string>
     <string name="error_security_exception">用来分享此文件的应用未提供足够的权限。</string>
     <string name="account_status_unknown">未知</string>
-    <string name="account_status_disabled">暂时禁用</string>
+    <string name="account_status_disabled">已暂时禁用</string>
     <string name="account_status_online">在线</string>
     <string name="account_status_connecting">正在连接…</string>
     <string name="account_status_offline">离线</string>
@@ -414,7 +414,7 @@
     <string name="reply">回复</string>
     <string name="mark_as_read">标记为已读</string>
     <string name="pref_input_options">输入</string>
-    <string name="pref_enter_is_send">Enter 即发送</string>
+    <string name="pref_enter_is_send">Enter 键发送</string>
     <string name="pref_enter_is_send_summary">使用 Enter 键发送消息。即使禁用此选项,始终可以使用 Ctrl+Enter 发送消息。</string>
     <string name="pref_display_enter_key">显示 Enter 键</string>
     <string name="pref_display_enter_key_summary">将表情符号键替换为 Enter 键</string>
@@ -455,7 +455,7 @@
     <string name="pref_quick_action_summary">以快捷操作替代“发送”按钮</string>
     <string name="pref_quick_action">快捷操作</string>
     <string name="none">无</string>
-    <string name="recently_used">最近使用</string>
+    <string name="recently_used">最近用过</string>
     <string name="choose_quick_action">选择快捷操作</string>
     <string name="search_contacts">搜索联系人</string>
     <string name="send_private_message">发送私信</string>
@@ -617,9 +617,9 @@
     <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>
@@ -662,8 +662,8 @@
     <string name="account_status_regis_web">服务器要求在网站上注册</string>
     <string name="open_website">打开网站</string>
     <string name="application_found_to_open_website">未找到可以打开网站的应用</string>
-    <string name="pref_headsup_notifications">顶部通知</string>
-    <string name="pref_headsup_notifications_summary">显示顶部通知</string>
+    <string name="pref_headsup_notifications">提醒式通知</string>
+    <string name="pref_headsup_notifications_summary">显示提醒式通知</string>
     <string name="today">今天</string>
     <string name="yesterday">昨天</string>
     <string name="pref_validate_hostname">用 DNSSEC 验证主机名</string>
@@ -772,7 +772,7 @@
     <string name="phone_number">电话号码</string>
     <string name="verify_your_phone_number">验证您的电话号码</string>
     <string name="enter_country_code_and_phone_number">Quicksy 将发送短信(运营商可能收费)来验证电话号码。请输入您的国家/地区代码和电话号码:</string>
-    <string name="we_will_be_verifying"><![CDATA[我们将验证这个电话号码 <br/><br/><b>%s</b><br/><br/> 可以吗?是否编辑号码?]]></string>
+    <string name="we_will_be_verifying"><![CDATA[我们将验证电话号码 <br/><br/><b>%s</b><br/><br/> 是否正确,或者编辑号码?]]></string>
     <string name="not_a_valid_phone_number">%s 不是有效的电话号码。</string>
     <string name="please_enter_your_phone_number">请输入您的电话号码。</string>
     <string name="search_countries">搜索国家/地区</string>
@@ -785,7 +785,7 @@
     <string name="wait_x">请稍候 (%s)</string>
     <string name="back">返回</string>
     <string name="possible_pin">已自动从剪贴板粘贴可能的 PIN 码。</string>
-    <string name="please_enter_pin">请输入您的 6 位 PIN 码。</string>
+    <string name="please_enter_pin">请输入六位数的 PIN 码。</string>
     <string name="abort_registration_procedure">是否确定要中止注册过程?</string>
     <string name="yes">是</string>
     <string name="no">否</string>
@@ -1006,7 +1006,7 @@
     <string name="remove_bookmark_and_close">是否移除 %s 的书签并归档对话?</string>
     <string name="remove_bookmark">是否移除 %s 的书签?</string>
     <string name="delete_and_close">删除并归档对话</string>
-    <string name="channel_discover_opt_in_message">频道发现使用称为 &lt;a href=https://search.jabber.network&gt;search.jabber.network&lt;/a&gt; 的第三方服务。&lt;br&gt;&lt;br&gt;使用此功能会将您的 IP 地址和搜索词传输到此服务。请参阅其 &lt;a href=https://search.jabber.network/privacy&gt;隐私政策&lt;/a&gt; 以获取更多信息。</string>
+    <string name="channel_discover_opt_in_message">频道发现使用称为 &lt;a href=https://search.jabber.network&gt;search.jabber.network&lt;/a&gt; 的第三方服务。&lt;br&gt;&lt;br&gt;使用此功能会将您的 IP 地址和搜索词传输到此服务。如需更多信息,请参阅其&lt;a href=https://search.jabber.network/privacy&gt;隐私政策&lt;/a&gt;。</string>
     <string name="start_chat">开始对话</string>
     <string name="title_activity_share_with">分享至…</string>
     <string name="pref_use_colorful_bubbles_summary">为发送和接收的消息使用不同的背景颜色</string>
@@ -1056,7 +1056,7 @@
     <string name="unified_push_summary">兼容 UnifiedPush 的第三方应用的通知转发器</string>
     <string name="send_encrypted_message">发送加密消息</string>
     <string name="pref_large_font">大字体</string>
-    <string name="pref_large_font_summary">增加消息气泡中的字体大小</string>
+    <string name="pref_large_font_summary">增大消息气泡中的字体大小</string>
     <string name="pref_accept_invites_from_strangers">陌生人的邀请</string>
     <string name="pref_accept_invites_from_strangers_summary">接受来自陌生人的群聊邀请</string>
     <string name="pref_create_backup_one_off_summary">创建一次性备份</string>
@@ -1095,7 +1095,7 @@
     <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>
@@ -1105,4 +1105,6 @@
     <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>
 </resources>

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

@@ -8,8 +8,6 @@
     <dimen name="card_padding_list">8dp</dimen> <!-- card_padding_regular minus list_padding -->
     <dimen name="list_padding">8dp</dimen>
     <dimen name="image_button_padding">12dp</dimen>
-    <dimen name="key_action_width">48dp
-    </dimen>  <!-- icon width (24dp) + 2 * image button padding -->
     <dimen name="fineprint_size">11sp</dimen>
     <dimen name="audio_player_width">224dp</dimen>
     <dimen name="image_preview_width">224dp</dimen>

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

@@ -174,6 +174,7 @@
     <string name="account_status_stream_error">Stream error</string>
     <string name="account_status_stream_opening_error">Stream opening error</string>
     <string name="encryption_choice_unencrypted">TLS</string>
+    <string name="account_status_channel_binding">Channel binding unavailable</string>
     <string name="encryption_choice_otr">OTR</string>
     <string name="encryption_choice_pgp">OpenPGP</string>
     <string name="encryption_choice_omemo">OMEMO</string>
@@ -317,6 +318,7 @@
     <string name="paste_as_quote">Paste as quote</string>
     <string name="copy_original_url">Copy original URL</string>
     <string name="send_again">Send again</string>
+    <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="jabber_id_copied_to_clipboard">Copied Jabber ID to clipboard</string>

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

@@ -52,6 +52,15 @@
         <item name="colorPrimaryInverse">@color/md_theme_light_inversePrimary</item>
         <item name="materialDrawerStyle">@style/Widget.MaterialDrawerStyle</item>
         <item name="materialDrawerHeaderStyle">@style/Widget.MaterialDrawerHeaderStyle</item>
+        <item name="preferenceTheme">@style/MaterialPreferenceThemeOverlay</item>
+    </style>
+
+    <style name="MaterialPreferenceThemeOverlay" parent="@style/PreferenceThemeOverlay">
+        <item name="switchPreferenceCompatStyle">@style/MaterialSwitchPreference</item>
+    </style>
+
+    <style name="MaterialSwitchPreference" parent="@style/Preference.SwitchPreferenceCompat.Material">
+        <item name="widgetLayout">@layout/preference_material_switch</item>
     </style>
 
 </resources>

src/quicksy/fastlane/metadata/android/fi-FI/full_description.txt 🔗

@@ -0,0 +1,14 @@
+Quicksy on suositun Jabber/XMPP-asiakaskeskustelujen spin off, jossa on automaattinen yhteystietojen etsintä.
+
+Rekisteröidyt puhelinnumerollasi ja Quicksy ehdottaa sinulle automaattisesti mahdollisia yhteystietoja osoitekirjassasi olevien puhelinnumeroiden perusteella.
+
+Kotelon alla Quicksy on täysimittainen Jabber-asiakasohjelma, jonka avulla voit kommunikoida minkä tahansa käyttäjän kanssa millä tahansa julkisesti liitetyllä palvelimella. Samoin Quicksyn käyttäjiin voidaan ottaa yhteyttä ulkopuolelta yksinkertaisesti lisäämällä +puhelinnumero@quicksy.im yhteystietoluetteloosi.
+
+Yhteystietojen synkronoinnin lisäksi käyttöliittymä on tarkoituksella mahdollisimman lähellä keskusteluja. Tämän ansiosta käyttäjät voivat lopulta siirtyä Quicksystä Conversationsiin ilman, että heidän tarvitsee opetella uudelleen sovelluksen toimintaa.
+
+Ehdotetut yhteystiedot koostuvat muista Quicksy-käyttäjistä ja tavallisista Jabber/XMPP-käyttäjistä, jotka ovat syöttäneet Jabber-tunnuksensa Quicksy-hakemistoon (https://quicksy.im/#get-listed).
+
+Huomautus: Voit kirjoittaa (https://quicksy.im/enter/) Jabber-tunnuksesi Quicksyyn
+Hakemistoon vaaditaan kertaluonteinen rekisteröintimaksu.
+
+Lue lisää tietosuojakäytännöstä (https://quicksy.im/#privacy).

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

@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
     <string name="pref_notification_grace_period_summary">Kuinka kauan Quicksy pysyy hiljaa nähtyään toisella laitteellasi toimintaa</string>
-    <string name="pref_never_send_crash_summary">Lähettämällä virheenkorjaustietoja autat Quicksyn kehittäjiä</string>
+    <string name="pref_never_send_crash_summary">Lähettämällä pinojälkiä autat Quicksyn jatkuvaa kehitystä</string>
     <string name="pref_broadcast_last_activity_summary">Kerro kaikille yhteystiedoillesi kun käytät Quicksya</string>
     <string name="huawei_protected_apps_summary">Saadaksesi ilmoituksia silloinkin kun näyttö on sammutettu, Quicksy pitää lisätä suojattujen sovellusten luetteloon.</string>
     <string name="set_profile_picture">Quicksy-profiilikuva</string>
@@ -9,4 +9,4 @@
     <string name="unable_to_verify_server_identity">Palvelimen identiteetin varmennus epäonnistui.</string>
     <string name="unknown_security_error">Tuntematon turvallisuusvirhe.</string>
     <string name="timeout_while_connecting_to_server">Palvelimeen yhdistäminen aikakatkaistiin.</string>
-</resources>
+</resources>