Merge commit '9dbfa784533b19e6f868586a3ea7f32235b57119'

Stephen Paul Weber created

* commit '9dbfa784533b19e6f868586a3ea7f32235b57119':
  target SDK 35 (Android 15)
  add compile time config to merge bodies instead of ignoring
  show links in SOS message
  implement Service Outage Status and show message in account screen
  refactor pep event parsing to new api
  extract occupant-id and stanza-id only when they are unique
  use new api for accessing pgp encrypted and oob
  refactor LocalizedContent code
  Translated using Weblate (Italian)
  Translated using Weblate (Chinese (Simplified Han script))
  Translated using Weblate (Ukrainian)
  Translated using Weblate (Spanish)
  version bump to 2.18.1
  Translated using Weblate (Albanian)
  Translated using Weblate (Spanish)
  Translated using Weblate (Italian)
  use message id as session id in jingle file transfer
  the holger fix 🤷
  logging and code clean up in XmppDomainVerifier

Change summary

CHANGELOG.md                                                                                |   7 
build.gradle                                                                                |  10 
fastlane/metadata/android/en-US/changelogs/4214004.txt                                      |   2 
fastlane/metadata/android/it-IT/changelogs/4214004.txt                                      |   2 
fastlane/metadata/android/sq/changelogs/4213104.txt                                         |   2 
fastlane/metadata/android/uk/changelogs/4214004.txt                                         |   2 
fastlane/metadata/android/zh-CN/changelogs/4214004.txt                                      |   2 
gradle/wrapper/gradle-wrapper.properties                                                    |   4 
src/conversations/res/layout/activity_easy_invite.xml                                       |   1 
src/conversations/res/layout/activity_magic_create.xml                                      |   2 
src/conversations/res/layout/activity_pick_server.xml                                       |   2 
src/conversations/res/layout/activity_welcome.xml                                           |   1 
src/main/java/de/gultsch/common/Linkify.java                                                |   3 
src/main/java/de/gultsch/common/MiniUri.java                                                |   2 
src/main/java/de/gultsch/common/Patterns.java                                               |   2 
src/main/java/eu/siacs/conversations/Config.java                                            |   2 
src/main/java/eu/siacs/conversations/crypto/XmppDomainVerifier.java                         |  38 
src/main/java/eu/siacs/conversations/crypto/axolotl/AxolotlService.java                     | 805 
src/main/java/eu/siacs/conversations/entities/Account.java                                  | 130 
src/main/java/eu/siacs/conversations/entities/Bookmark.java                                 |  84 
src/main/java/eu/siacs/conversations/entities/ServiceDiscoveryResult.java                   | 670 
src/main/java/eu/siacs/conversations/generator/MessageGenerator.java                        |  14 
src/main/java/eu/siacs/conversations/http/ServiceOutageStatus.java                          | 162 
src/main/java/eu/siacs/conversations/parser/MessageParser.java                              | 208 
src/main/java/eu/siacs/conversations/parser/PresenceParser.java                             |   4 
src/main/java/eu/siacs/conversations/services/AbstractConnectionManager.java                |  59 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java                    | 165 
src/main/java/eu/siacs/conversations/ui/AboutActivity.java                                  |  15 
src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java                      |   5 
src/main/java/eu/siacs/conversations/ui/ConversationActivity.java                           |  15 
src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java                            |  50 
src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java                         |  20 
src/main/java/eu/siacs/conversations/ui/text/FixedURLSpan.java                              |  28 
src/main/java/eu/siacs/conversations/utils/StylingHelper.java                               |  13 
src/main/java/eu/siacs/conversations/xml/Element.java                                       |   4 
src/main/java/eu/siacs/conversations/xml/LocalizedContent.java                              |  26 
src/main/java/eu/siacs/conversations/xml/Namespace.java                                     |   2 
src/main/java/eu/siacs/conversations/xml/XmlReader.java                                     | 113 
src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java                               |  14 
src/main/java/eu/siacs/conversations/xmpp/jingle/AbstractJingleConnection.java              |  26 
src/main/java/eu/siacs/conversations/xmpp/jingle/JingleFileTransferConnection.java          |   1 
src/main/java/eu/siacs/conversations/xmpp/jingle/Media.java                                 |  11 
src/main/java/eu/siacs/conversations/xmpp/jingle/RtpContentMap.java                         |   4 
src/main/java/eu/siacs/conversations/xmpp/jingle/TrackWrapper.java                          |  13 
src/main/java/eu/siacs/conversations/xmpp/jingle/VideoSourceWrapper.java                    |  17 
src/main/java/eu/siacs/conversations/xmpp/jingle/WebRTCWrapper.java                         |  12 
src/main/java/eu/siacs/conversations/xmpp/jingle/transports/WebRTCDataChannelTransport.java |   6 
src/main/java/eu/siacs/conversations/xmpp/pep/Avatar.java                                   | 136 
src/main/java/eu/siacs/conversations/xmpp/pep/PublishOptions.java                           |   2 
src/main/java/im/conversations/android/xmpp/model/bookmark/Conference.java                  |  22 
src/main/java/im/conversations/android/xmpp/model/bookmark/Storage.java                     |  12 
src/main/java/im/conversations/android/xmpp/model/bookmark/package-info.java                |   4 
src/main/java/im/conversations/android/xmpp/model/bookmark2/Conference.java                 |  32 
src/main/java/im/conversations/android/xmpp/model/bookmark2/Extensions.java                 |   2 
src/main/java/im/conversations/android/xmpp/model/bookmark2/Nick.java                       |   2 
src/main/java/im/conversations/android/xmpp/model/bookmark2/package-info.java               |   5 
src/main/java/im/conversations/android/xmpp/model/mds/Displayed.java                        |   5 
src/main/java/im/conversations/android/xmpp/model/pubsub/Items.java                         |   5 
src/main/java/im/conversations/android/xmpp/model/pubsub/event/Action.java                  |  14 
src/main/java/im/conversations/android/xmpp/model/pubsub/event/Delete.java                  |  11 
src/main/java/im/conversations/android/xmpp/model/pubsub/event/Event.java                   |  14 
src/main/java/im/conversations/android/xmpp/model/pubsub/event/Purge.java                   |   3 
src/main/java/im/conversations/android/xmpp/model/pubsub/event/Retract.java                 |   3 
src/main/java/im/conversations/android/xmpp/model/receipts/Received.java                    |   5 
src/main/java/im/conversations/android/xmpp/model/stanza/Message.java                       |  43 
src/main/java/im/conversations/android/xmpp/model/storage/PrivateStorage.java               |  13 
src/main/java/im/conversations/android/xmpp/model/unique/StanzaId.java                      |  21 
src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java                    |  24 
src/main/res/layout-w945dp/activity_conversations.xml                                       |   1 
src/main/res/layout/activity_about.xml                                                      |   7 
src/main/res/layout/activity_change_password.xml                                            |   3 
src/main/res/layout/activity_channel_discovery.xml                                          |   1 
src/main/res/layout/activity_choose_contact.xml                                             |   1 
src/main/res/layout/activity_contact_details.xml                                            |   1 
src/main/res/layout/activity_conversations.xml                                              |   1 
src/main/res/layout/activity_edit_account.xml                                               |  53 
src/main/res/layout/activity_import_backup.xml                                              |   1 
src/main/res/layout/activity_manage_accounts.xml                                            |   1 
src/main/res/layout/activity_media_browser.xml                                              |   1 
src/main/res/layout/activity_muc_details.xml                                                |   2 
src/main/res/layout/activity_muc_users.xml                                                  |   1 
src/main/res/layout/activity_publish_profile_picture.xml                                    |   3 
src/main/res/layout/activity_rtp_session.xml                                                |   3 
src/main/res/layout/activity_search.xml                                                     |   3 
src/main/res/layout/activity_settings.xml                                                   |   3 
src/main/res/layout/activity_share_location.xml                                             |   1 
src/main/res/layout/activity_share_with.xml                                                 |   1 
src/main/res/layout/activity_show_location.xml                                              |   3 
src/main/res/layout/activity_start_conversation.xml                                         |   5 
src/main/res/layout/activity_trust_keys.xml                                                 |   3 
src/main/res/values-es/strings.xml                                                          |  22 
src/main/res/values-it/strings.xml                                                          |   8 
src/main/res/values/strings.xml                                                             |   3 
src/main/res/values/themes.xml                                                              |   1 
src/quicksy/res/layout/activity_choose_country.xml                                          |   1 
src/quicksy/res/layout/activity_enter_name.xml                                              |   1 
src/quicksy/res/layout/activity_enter_number.xml                                            |   1 
src/quicksy/res/layout/activity_tos.xml                                                     |   1 
src/quicksy/res/layout/activity_verify.xml                                                  |   1 
99 files changed, 2,033 insertions(+), 1,263 deletions(-)

Detailed changes

CHANGELOG.md 🔗

@@ -1,9 +1,14 @@
 # Changelog
 
+### Version 2.18.1
+
+* Fix reactions on files received via P2P
+* Improve URI matching
+
 ### Version 2.18.0
 
 * Add ability to pick backup location
-* More more URIs (tel:, mailto:) clickable
+* Make more URIs (tel:, mailto:) clickable
 
 ### Version 2.17.12
 

build.gradle 🔗

@@ -6,7 +6,7 @@ buildscript {
         mavenCentral()
     }
     dependencies {
-        classpath 'com.android.tools.build:gradle:8.5.2'
+        classpath 'com.android.tools.build:gradle:8.9.1'
         classpath "com.diffplug.spotless:spotless-plugin-gradle:7.0.2"
     }
 }
@@ -85,7 +85,7 @@ dependencies {
     implementation "androidx.preference:preference:1.2.1"
     implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
     implementation 'com.google.android.material:material:1.13.0-alpha10'
-    implementation 'androidx.work:work-runtime:2.9.1'
+    implementation 'androidx.work:work-runtime:2.10.0'
 
     implementation "androidx.emoji2:emoji2:1.5.0"
     freeImplementation "androidx.emoji2:emoji2-bundled:1.5.0"
@@ -113,7 +113,7 @@ dependencies {
     implementation "com.squareup.retrofit2:converter-gson:2.11.0"
     implementation "com.squareup.okhttp3:okhttp:4.12.0"
 
-    implementation 'com.google.guava:guava:33.4.0-android'
+    implementation 'com.google.guava:guava:33.4.6-android'
     implementation 'io.michaelrocks:libphonenumber-android:8.13.52'
     implementation 'im.conversations.webrtc:webrtc-android:129.0.0'
     implementation 'io.github.nishkarsh:android-permissions:2.1.6'
@@ -147,11 +147,11 @@ ext {
 
 android {
     namespace 'eu.siacs.conversations'
-    compileSdk 34
+    compileSdk 35
 
     defaultConfig {
         minSdkVersion 23
-        targetSdkVersion 34
+        targetSdkVersion 35
         versionCode 42025 + tags.size()
         versionName grgit.describe(always: true)
         applicationId "eu.siacs.conversations"

gradle/wrapper/gradle-wrapper.properties 🔗

@@ -1,8 +1,8 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
 networkTimeout=10000
 validateDistributionUrl=true
-distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d
+distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists

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

@@ -2,9 +2,9 @@
 <layout xmlns:android="http://schemas.android.com/apk/res/android">
 
     <LinearLayout
-
         android:layout_width="match_parent"
         android:layout_height="match_parent"
+        android:fitsSystemWindows="true"
         android:orientation="vertical">
 
         <com.google.android.material.appbar.AppBarLayout

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

@@ -2,9 +2,9 @@
 <layout xmlns:android="http://schemas.android.com/apk/res/android">
 
     <LinearLayout
-
         android:layout_width="match_parent"
         android:layout_height="match_parent"
+        android:fitsSystemWindows="true"
         android:orientation="vertical">
 
         <com.google.android.material.appbar.AppBarLayout

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

@@ -4,6 +4,7 @@
     <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="match_parent"
+        android:fitsSystemWindows="true"
         android:orientation="vertical">
 
         <com.google.android.material.appbar.AppBarLayout

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

@@ -34,6 +34,7 @@ import android.text.Editable;
 import android.text.Spanned;
 import android.text.style.TypefaceSpan;
 import android.text.style.URLSpan;
+import android.text.Spannable;
 import com.google.common.base.Splitter;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.ImmutableList;
@@ -76,7 +77,7 @@ public class Linkify {
         };
     }
 
-    public static void addLinks(final Editable body) {
+    public static void addLinks(final Spannable body) {
         android.text.util.Linkify.addLinks(body, Patterns.URI_GENERIC, null, MATCH_FILTER, null);
     }
 

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

@@ -1,6 +1,7 @@
 package de.gultsch.common;
 
 import android.net.Uri;
+import androidx.annotation.NonNull;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
@@ -11,7 +12,6 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
-import org.checkerframework.checker.nullness.qual.NonNull;
 
 public class MiniUri {
 

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

@@ -6,7 +6,7 @@ public class Patterns {
 
     public static final Pattern URI_GENERIC =
             Pattern.compile(
-                    "(?<=^|\\p{Z}|\\s|\\p{P})(tel|xmpp|http|https|geo|mailto|web\\+ap|gemini|bitcoin|bitcoincash|ethereum|monero|wownero):[\\p{L}\\p{M}\\p{N}\\-._~:/?#\\[\\]@!$&'*+,;=%]+(\\([\\p{L}\\p{M}\\p{N}\\-._~:/?#\\[\\]@!$&'*+,;=%]+\\))*[\\p{L}\\p{M}\\p{N}\\-._~:/?#\\[\\]@!$&'*+,;=%]*");
+                    "(?<=^|\\p{Z}|\\s|\\p{P}|<)(tel|xmpp|http|https|geo|mailto|web\\+ap|gemini|bitcoin|bitcoincash|ethereum|monero|wownero):[\\p{L}\\p{M}\\p{N}\\-._~:/?#\\[\\]@!$&'*+,;=%]+(\\([\\p{L}\\p{M}\\p{N}\\-._~:/?#\\[\\]@!$&'*+,;=%]+\\))*[\\p{L}\\p{M}\\p{N}\\-._~:/?#\\[\\]@!$&'*+,;=%]*");
 
     public static final Pattern URI_TEL =
             Pattern.compile("^tel:\\+?(\\d{1,4}[-./()\\s]?)*\\d{1,4}(;.*)?$");

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

@@ -141,6 +141,8 @@ public final class Config {
 
     public static final boolean IGNORE_ID_REWRITE_IN_MUC = true;
     public static final boolean MUC_LEAVE_BEFORE_JOIN = false;
+    // if this is set to true messages that contain multiple bodies (per language) will be ignored
+    public static final boolean TREAT_MULTI_CONTENT_AS_INVALID = false;
 
     public static final boolean USE_LMC_VERSION_1_1 = true;
 

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

@@ -2,6 +2,8 @@ package eu.siacs.conversations.crypto;
 
 import android.util.Log;
 import android.util.Pair;
+import androidx.annotation.NonNull;
+import com.google.common.base.CharMatcher;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import java.io.IOException;
@@ -12,6 +14,7 @@ import java.security.cert.CertificateParsingException;
 import java.security.cert.X509Certificate;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
 import javax.net.ssl.SSLPeerUnverifiedException;
@@ -23,7 +26,6 @@ import org.bouncycastle.asn1.DERIA5String;
 import org.bouncycastle.asn1.DERUTF8String;
 import org.bouncycastle.asn1.DLSequence;
 import org.bouncycastle.asn1.x500.RDN;
-import org.bouncycastle.asn1.x500.X500Name;
 import org.bouncycastle.asn1.x500.style.BCStyle;
 import org.bouncycastle.asn1.x500.style.IETFUtils;
 import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
@@ -36,18 +38,18 @@ public class XmppDomainVerifier {
     private static final String XMPP_ADDR = "1.3.6.1.5.5.7.8.5";
 
     private static List<String> getCommonNames(final X509Certificate certificate) {
-        List<String> domains = new ArrayList<>();
+        final var domains = new ImmutableList.Builder<String>();
         try {
-            X500Name x500name = new JcaX509CertificateHolder(certificate).getSubject();
-            RDN[] rdns = x500name.getRDNs(BCStyle.CN);
-            for (int i = 0; i < rdns.length; ++i) {
+            final var x500name = new JcaX509CertificateHolder(certificate).getSubject();
+            final RDN[] nameRDNs = x500name.getRDNs(BCStyle.CN);
+            for (int i = 0; i < nameRDNs.length; ++i) {
                 domains.add(
                         IETFUtils.valueToString(
                                 x500name.getRDNs(BCStyle.CN)[i].getFirst().getValue()));
             }
-            return domains;
-        } catch (CertificateEncodingException e) {
-            return domains;
+            return domains.build();
+        } catch (final CertificateEncodingException e) {
+            return Collections.emptyList();
         }
     }
 
@@ -75,17 +77,24 @@ public class XmppDomainVerifier {
         }
     }
 
-    public static boolean matchDomain(final String needle, final List<String> haystack) {
-        for (final String entry : haystack) {
-            if (entry.startsWith("*.")) {
+    public static boolean matchDomain(final String domain, final List<String> certificateDomains) {
+        for (final String certificateDomain : certificateDomains) {
+            if (certificateDomain.startsWith("*.")) {
                 // https://www.rfc-editor.org/rfc/rfc6125#section-6.4.3
                 // wild cards can only be in the left most label and don’t match '.'
-                final int i = needle.indexOf('.');
-                if (i != -1 && needle.substring(i).equalsIgnoreCase(entry.substring(1))) {
+                final var wildcardEntry = certificateDomain.substring(1);
+                if (CharMatcher.is('.').countIn(wildcardEntry) < 2) {
+                    Log.w(LOGTAG, "not enough labels in wildcard certificate");
+                    break;
+                }
+                final int position = domain.indexOf('.');
+                if (position != -1 && domain.substring(position).equalsIgnoreCase(wildcardEntry)) {
+                    Log.d(LOGTAG, "domain " + domain + " matched " + certificateDomain);
                     return true;
                 }
             } else {
-                if (entry.equalsIgnoreCase(needle)) {
+                if (certificateDomain.equalsIgnoreCase(domain)) {
+                    Log.d(LOGTAG, "domain " + domain + " matched " + certificateDomain);
                     return true;
                 }
             }
@@ -184,6 +193,7 @@ public class XmppDomainVerifier {
             return all.build();
         }
 
+        @NonNull
         @Override
         public String toString() {
             return MoreObjects.toStringHelper(this)

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

@@ -6,10 +6,8 @@ import android.os.Bundle;
 import android.security.KeyChain;
 import android.util.Log;
 import android.util.Pair;
-
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
-
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -17,38 +15,6 @@ import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.common.util.concurrent.SettableFuture;
-
-import org.bouncycastle.jce.provider.BouncyCastleProvider;
-import org.whispersystems.libsignal.IdentityKey;
-import org.whispersystems.libsignal.IdentityKeyPair;
-import org.whispersystems.libsignal.InvalidKeyException;
-import org.whispersystems.libsignal.InvalidKeyIdException;
-import org.whispersystems.libsignal.SessionBuilder;
-import org.whispersystems.libsignal.SignalProtocolAddress;
-import org.whispersystems.libsignal.UntrustedIdentityException;
-import org.whispersystems.libsignal.ecc.ECPublicKey;
-import org.whispersystems.libsignal.state.PreKeyBundle;
-import org.whispersystems.libsignal.state.PreKeyRecord;
-import org.whispersystems.libsignal.state.SignedPreKeyRecord;
-import org.whispersystems.libsignal.util.KeyHelper;
-
-import java.security.PrivateKey;
-import java.security.Security;
-import java.security.Signature;
-import java.security.cert.X509Certificate;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.Random;
-import java.util.Set;
-import java.util.concurrent.atomic.AtomicBoolean;
-
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Contact;
@@ -71,6 +37,35 @@ import eu.siacs.conversations.xmpp.jingle.stanzas.OmemoVerifiedIceUdpTransportIn
 import eu.siacs.conversations.xmpp.jingle.stanzas.RtpDescription;
 import eu.siacs.conversations.xmpp.pep.PublishOptions;
 import im.conversations.android.xmpp.model.stanza.Iq;
+import java.security.PrivateKey;
+import java.security.Security;
+import java.security.Signature;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.whispersystems.libsignal.IdentityKey;
+import org.whispersystems.libsignal.IdentityKeyPair;
+import org.whispersystems.libsignal.InvalidKeyException;
+import org.whispersystems.libsignal.InvalidKeyIdException;
+import org.whispersystems.libsignal.SessionBuilder;
+import org.whispersystems.libsignal.SignalProtocolAddress;
+import org.whispersystems.libsignal.UntrustedIdentityException;
+import org.whispersystems.libsignal.ecc.ECPublicKey;
+import org.whispersystems.libsignal.state.PreKeyBundle;
+import org.whispersystems.libsignal.state.PreKeyRecord;
+import org.whispersystems.libsignal.state.SignedPreKeyRecord;
+import org.whispersystems.libsignal.util.KeyHelper;
 
 public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
 
@@ -102,8 +97,11 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
     private int numPublishTriesOnEmptyPep = 0;
     private boolean pepBroken = false;
     private int lastDeviceListNotificationHash = 0;
-    private final Set<XmppAxolotlSession> postponedSessions = new HashSet<>(); //sessions stored here will receive after mam catchup treatment
-    private final Set<SignalProtocolAddress> postponedHealing = new HashSet<>(); //addresses stored here will need a healing notification after mam catchup
+    private final Set<XmppAxolotlSession> postponedSessions =
+            new HashSet<>(); // sessions stored here will receive after mam catchup treatment
+    private final Set<SignalProtocolAddress> postponedHealing =
+            new HashSet<>(); // addresses stored here will need a healing notification after mam
+    // catchup
     private final AtomicBoolean changeAccessMode = new AtomicBoolean(false);
 
     public AxolotlService(Account account, XmppConnectionService connectionService) {
@@ -156,7 +154,8 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
         for (Jid jid : jids) {
             if (deviceIds.get(jid) != null) {
                 for (Integer foreignId : this.deviceIds.get(jid)) {
-                    SignalProtocolAddress address = new SignalProtocolAddress(jid.toString(), foreignId);
+                    SignalProtocolAddress address =
+                            new SignalProtocolAddress(jid.toString(), foreignId);
                     if (fetchStatusMap.getAll(address.getName()).containsValue(FetchStatus.ERROR)) {
                         return true;
                     }
@@ -167,11 +166,13 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
     }
 
     public void preVerifyFingerprint(Contact contact, String fingerprint) {
-        axolotlStore.preVerifyFingerprint(contact.getAccount(), contact.getJid().asBareJid().toString(), fingerprint);
+        axolotlStore.preVerifyFingerprint(
+                contact.getAccount(), contact.getJid().asBareJid().toString(), fingerprint);
     }
 
     public void preVerifyFingerprint(Account account, String fingerprint) {
-        axolotlStore.preVerifyFingerprint(account, account.getJid().asBareJid().toString(), fingerprint);
+        axolotlStore.preVerifyFingerprint(
+                account, account.getJid().asBareJid().toString(), fingerprint);
     }
 
     public boolean hasVerifiedKeys(String name) {
@@ -184,11 +185,13 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
     }
 
     public String getOwnFingerprint() {
-        return CryptoHelper.bytesToHex(axolotlStore.getIdentityKeyPair().getPublicKey().serialize());
+        return CryptoHelper.bytesToHex(
+                axolotlStore.getIdentityKeyPair().getPublicKey().serialize());
     }
 
     public Set<IdentityKey> getKeysWithTrust(FingerprintStatus status) {
-        return axolotlStore.getContactKeysWithTrust(account.getJid().asBareJid().toString(), status);
+        return axolotlStore.getContactKeysWithTrust(
+                account.getJid().asBareJid().toString(), status);
     }
 
     public Set<IdentityKey> getKeysWithTrust(FingerprintStatus status, Jid jid) {
@@ -226,21 +229,23 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
 
     public Collection<XmppAxolotlSession> findOwnSessions() {
         SignalProtocolAddress ownAddress = getAddressForJid(account.getJid().asBareJid());
-        ArrayList<XmppAxolotlSession> s = new ArrayList<>(this.sessions.getAll(ownAddress.getName()).values());
+        ArrayList<XmppAxolotlSession> s =
+                new ArrayList<>(this.sessions.getAll(ownAddress.getName()).values());
         Collections.sort(s);
         return s;
     }
 
     public Collection<XmppAxolotlSession> findSessionsForContact(Contact contact) {
         SignalProtocolAddress contactAddress = getAddressForJid(contact.getJid());
-        ArrayList<XmppAxolotlSession> s = new ArrayList<>(this.sessions.getAll(contactAddress.getName()).values());
+        ArrayList<XmppAxolotlSession> s =
+                new ArrayList<>(this.sessions.getAll(contactAddress.getName()).values());
         Collections.sort(s);
         return s;
     }
 
     private Set<XmppAxolotlSession> findSessionsForConversation(Conversation conversation) {
         if (conversation.getContact().isSelf()) {
-            //will be added in findOwnSessions()
+            // will be added in findOwnSessions()
             return Collections.emptySet();
         }
         HashSet<XmppAxolotlSession> sessions = new HashSet<>();
@@ -280,7 +285,10 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
     }
 
     public void destroy() {
-        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": destroying old axolotl service. no longer in use");
+        Log.d(
+                Config.LOGTAG,
+                account.getJid().asBareJid()
+                        + ": destroying old axolotl service. no longer in use");
         mXmppConnectionService.databaseBackend.wipeAxolotlDb(account);
     }
 
@@ -306,7 +314,9 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
         final boolean me = jid.asBareJid().equals(account.getJid().asBareJid());
         if (me) {
             if (hash != 0 && hash == this.lastDeviceListNotificationHash) {
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignoring duplicate own device id list");
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid() + ": ignoring duplicate own device id list");
                 return;
             }
             this.lastDeviceListNotificationHash = hash;
@@ -315,10 +325,12 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
         if (me) {
             deviceIds.remove(getOwnDeviceId());
         }
-        final Set<Integer> expiredDevices = new HashSet<>(axolotlStore.getSubDeviceSessions(jid.asBareJid().toString()));
+        final Set<Integer> expiredDevices =
+                new HashSet<>(axolotlStore.getSubDeviceSessions(jid.asBareJid().toString()));
         expiredDevices.removeAll(deviceIds);
         for (Integer deviceId : expiredDevices) {
-            SignalProtocolAddress address = new SignalProtocolAddress(jid.asBareJid().toString(), deviceId);
+            SignalProtocolAddress address =
+                    new SignalProtocolAddress(jid.asBareJid().toString(), deviceId);
             XmppAxolotlSession session = sessions.get(address);
             if (session != null && session.getFingerprint() != null) {
                 if (session.getTrust().isActive()) {
@@ -328,11 +340,14 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
         }
         final Set<Integer> newDevices = ImmutableSet.copyOf(deviceIds);
         for (final Integer deviceId : newDevices) {
-            SignalProtocolAddress address = new SignalProtocolAddress(jid.asBareJid().toString(), deviceId);
+            SignalProtocolAddress address =
+                    new SignalProtocolAddress(jid.asBareJid().toString(), deviceId);
             XmppAxolotlSession session = sessions.get(address);
             if (session != null && session.getFingerprint() != null) {
                 if (!session.getTrust().isActive()) {
-                    Log.d(Config.LOGTAG, "reactivating device with fingerprint " + session.getFingerprint());
+                    Log.d(
+                            Config.LOGTAG,
+                            "reactivating device with fingerprint " + session.getFingerprint());
                     session.setTrust(session.getTrust().toActive());
                 }
             }
@@ -343,7 +358,8 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
             }
             needsPublishing |= this.changeAccessMode.get();
             for (final Integer deviceId : deviceIds) {
-                SignalProtocolAddress ownDeviceAddress = new SignalProtocolAddress(jid.asBareJid().toString(), deviceId);
+                SignalProtocolAddress ownDeviceAddress =
+                        new SignalProtocolAddress(jid.asBareJid().toString(), deviceId);
                 if (sessions.get(ownDeviceAddress) == null) {
                     FetchStatus status = fetchStatusMap.get(ownDeviceAddress);
                     if (status == null || status == FetchStatus.TIMEOUT) {
@@ -363,7 +379,7 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
         final boolean changed = oldSet == null || oldSet.hashCode() != hash;
         this.deviceIds.put(jid, deviceIds);
         if (changed) {
-            mXmppConnectionService.updateConversationUi(); //update the lock icon
+            mXmppConnectionService.updateConversationUi(); // update the lock icon
             mXmppConnectionService.keyStatusUpdated(null);
             if (me) {
                 mXmppConnectionService.updateAccountUi();
@@ -375,7 +391,10 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
 
     public void wipeOtherPepDevices() {
         if (pepBroken) {
-            Log.d(Config.LOGTAG, getLogprefix(account) + "wipeOtherPepDevices called, but PEP is broken. Ignoring... ");
+            Log.d(
+                    Config.LOGTAG,
+                    getLogprefix(account)
+                            + "wipeOtherPepDevices called, but PEP is broken. Ignoring... ");
             return;
         }
         Set<Integer> deviceIds = new HashSet<>();
@@ -391,22 +410,39 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
 
     private void publishOwnDeviceIdIfNeeded() {
         if (pepBroken) {
-            Log.d(Config.LOGTAG, getLogprefix(account) + "publishOwnDeviceIdIfNeeded called, but PEP is broken. Ignoring... ");
+            Log.d(
+                    Config.LOGTAG,
+                    getLogprefix(account)
+                            + "publishOwnDeviceIdIfNeeded called, but PEP is broken. Ignoring... ");
             return;
         }
-        Iq packet = mXmppConnectionService.getIqGenerator().retrieveDeviceIds(account.getJid().asBareJid());
-        mXmppConnectionService.sendIqPacket(account, packet, response -> {
-            if (response.getType() == Iq.Type.TIMEOUT) {
-                Log.d(Config.LOGTAG, getLogprefix(account) + "Timeout received while retrieving own Device Ids.");
-            } else {
-                //TODO consider calling registerDevices only after item-not-found to account for broken PEPs
-                final Element item = IqParser.getItem(response);
-                final Set<Integer> deviceIds = IqParser.deviceIds(item);
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": retrieved own device list: " + deviceIds);
-                registerDevices(account.getJid().asBareJid(), deviceIds);
-            }
-
-        });
+        Iq packet =
+                mXmppConnectionService
+                        .getIqGenerator()
+                        .retrieveDeviceIds(account.getJid().asBareJid());
+        mXmppConnectionService.sendIqPacket(
+                account,
+                packet,
+                response -> {
+                    if (response.getType() == Iq.Type.TIMEOUT) {
+                        Log.d(
+                                Config.LOGTAG,
+                                getLogprefix(account)
+                                        + "Timeout received while retrieving own Device Ids.");
+                    } else {
+                        // TODO consider calling registerDevices only after item-not-found to
+                        // account for broken PEPs
+                        // TODO use new API
+                        final Element item = IqParser.getItem(response);
+                        final Set<Integer> deviceIds = IqParser.deviceIds(item);
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid()
+                                        + ": retrieved own device list: "
+                                        + deviceIds);
+                        registerDevices(account.getJid().asBareJid(), deviceIds);
+                    }
+                });
     }
 
     private Set<Integer> getExpiredDevices() {
@@ -415,16 +451,34 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
             if (session.getTrust().isActive()) {
                 long diff = System.currentTimeMillis() - session.getTrust().getLastActivation();
                 if (diff > Config.OMEMO_AUTO_EXPIRY) {
-                    long lastMessageDiff = System.currentTimeMillis() - mXmppConnectionService.databaseBackend.getLastTimeFingerprintUsed(account, session.getFingerprint());
+                    long lastMessageDiff =
+                            System.currentTimeMillis()
+                                    - mXmppConnectionService.databaseBackend
+                                            .getLastTimeFingerprintUsed(
+                                                    account, session.getFingerprint());
                     long hours = Math.round(lastMessageDiff / (1000 * 60.0 * 60.0));
                     if (lastMessageDiff > Config.OMEMO_AUTO_EXPIRY) {
                         devices.add(session.getRemoteAddress().getDeviceId());
                         session.setTrust(session.getTrust().toInactive());
-                        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": added own device " + session.getFingerprint() + " to list of expired devices. Last message received " + hours + " hours ago");
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid()
+                                        + ": added own device "
+                                        + session.getFingerprint()
+                                        + " to list of expired devices. Last message received "
+                                        + hours
+                                        + " hours ago");
                     } else {
-                        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": own device " + session.getFingerprint() + " was active " + hours + " hours ago");
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid()
+                                        + ": own device "
+                                        + session.getFingerprint()
+                                        + " was active "
+                                        + hours
+                                        + " hours ago");
                     }
-                } //TODO print last activation diff
+                } // TODO print last activation diff
             }
         }
         return devices;
@@ -435,12 +489,20 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
         Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "publishing own device ids");
         if (deviceIdsCopy.isEmpty()) {
             if (numPublishTriesOnEmptyPep >= publishTriesThreshold) {
-                Log.w(Config.LOGTAG, getLogprefix(account) + "Own device publish attempt threshold exceeded, aborting...");
+                Log.w(
+                        Config.LOGTAG,
+                        getLogprefix(account)
+                                + "Own device publish attempt threshold exceeded, aborting...");
                 pepBroken = true;
                 return;
             } else {
                 numPublishTriesOnEmptyPep++;
-                Log.w(Config.LOGTAG, getLogprefix(account) + "Own device list empty, attempting to publish (try " + numPublishTriesOnEmptyPep + ")");
+                Log.w(
+                        Config.LOGTAG,
+                        getLogprefix(account)
+                                + "Own device list empty, attempting to publish (try "
+                                + numPublishTriesOnEmptyPep
+                                + ")");
             }
         } else {
             numPublishTriesOnEmptyPep = 0;
@@ -453,74 +515,136 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
         publishDeviceIdsAndRefineAccessModel(ids, true);
     }
 
-    private void publishDeviceIdsAndRefineAccessModel(final Set<Integer> ids, final boolean firstAttempt) {
-        final Bundle publishOptions = account.getXmppConnection().getFeatures().pepPublishOptions() ? PublishOptions.openAccess() : null;
-        final var publish = mXmppConnectionService.getIqGenerator().publishDeviceIds(ids, publishOptions);
-        mXmppConnectionService.sendIqPacket(account, publish, response -> {
-            final Element error = response.getType() == Iq.Type.ERROR ? response.findChild("error") : null;
-            final boolean preConditionNotMet = PublishOptions.preconditionNotMet(response);
-            if (firstAttempt && preConditionNotMet) {
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": precondition wasn't met for device list. pushing node configuration");
-                mXmppConnectionService.pushNodeConfiguration(account, AxolotlService.PEP_DEVICE_LIST, publishOptions, new XmppConnectionService.OnConfigurationPushed() {
-                    @Override
-                    public void onPushSucceeded() {
-                        publishDeviceIdsAndRefineAccessModel(ids, false);
-                    }
-
-                    @Override
-                    public void onPushFailed() {
-                        publishDeviceIdsAndRefineAccessModel(ids, false);
+    private void publishDeviceIdsAndRefineAccessModel(
+            final Set<Integer> ids, final boolean firstAttempt) {
+        final Bundle publishOptions =
+                account.getXmppConnection().getFeatures().pepPublishOptions()
+                        ? PublishOptions.openAccess()
+                        : null;
+        final var publish =
+                mXmppConnectionService.getIqGenerator().publishDeviceIds(ids, publishOptions);
+        mXmppConnectionService.sendIqPacket(
+                account,
+                publish,
+                response -> {
+                    final Element error =
+                            response.getType() == Iq.Type.ERROR
+                                    ? response.findChild("error")
+                                    : null;
+                    final boolean preConditionNotMet = PublishOptions.preconditionNotMet(response);
+                    if (firstAttempt && preConditionNotMet) {
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid()
+                                        + ": precondition wasn't met for device list. pushing node"
+                                        + " configuration");
+                        mXmppConnectionService.pushNodeConfiguration(
+                                account,
+                                AxolotlService.PEP_DEVICE_LIST,
+                                publishOptions,
+                                new XmppConnectionService.OnConfigurationPushed() {
+                                    @Override
+                                    public void onPushSucceeded() {
+                                        publishDeviceIdsAndRefineAccessModel(ids, false);
+                                    }
+
+                                    @Override
+                                    public void onPushFailed() {
+                                        publishDeviceIdsAndRefineAccessModel(ids, false);
+                                    }
+                                });
+                    } else {
+                        if (AxolotlService.this.changeAccessMode.compareAndSet(true, false)) {
+                            Log.d(
+                                    Config.LOGTAG,
+                                    account.getJid().asBareJid() + ": done changing access mode");
+                            account.setOption(Account.OPTION_REQUIRES_ACCESS_MODE_CHANGE, false);
+                            mXmppConnectionService.databaseBackend.updateAccount(account);
+                        }
+                        if (response.getType() == Iq.Type.ERROR) {
+                            if (preConditionNotMet) {
+                                Log.d(
+                                        Config.LOGTAG,
+                                        account.getJid().asBareJid()
+                                                + ": device list pre condition still not met on"
+                                                + " second attempt");
+                            } else if (error != null) {
+                                pepBroken = true;
+                                Log.d(
+                                        Config.LOGTAG,
+                                        getLogprefix(account)
+                                                + "Error received while publishing own device id"
+                                                + response.findChild("error"));
+                            }
+                        }
                     }
                 });
-            } else {
-                if (AxolotlService.this.changeAccessMode.compareAndSet(true, false)) {
-                    Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": done changing access mode");
-                    account.setOption(Account.OPTION_REQUIRES_ACCESS_MODE_CHANGE, false);
-                    mXmppConnectionService.databaseBackend.updateAccount(account);
-                }
-                if (response.getType() == Iq.Type.ERROR) {
-                    if (preConditionNotMet) {
-                        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": device list pre condition still not met on second attempt");
-                    } else if (error != null) {
-                        pepBroken = true;
-                        Log.d(Config.LOGTAG, getLogprefix(account) + "Error received while publishing own device id" + response.findChild("error"));
-                    }
-
-                }
-            }
-        });
     }
 
-    public void publishDeviceVerificationAndBundle(final SignedPreKeyRecord signedPreKeyRecord,
-                                                   final Set<PreKeyRecord> preKeyRecords,
-                                                   final boolean announceAfter,
-                                                   final boolean wipe) {
+    public void publishDeviceVerificationAndBundle(
+            final SignedPreKeyRecord signedPreKeyRecord,
+            final Set<PreKeyRecord> preKeyRecords,
+            final boolean announceAfter,
+            final boolean wipe) {
         try {
             IdentityKey axolotlPublicKey = axolotlStore.getIdentityKeyPair().getPublicKey();
-            PrivateKey x509PrivateKey = KeyChain.getPrivateKey(mXmppConnectionService, account.getPrivateKeyAlias());
-            X509Certificate[] chain = KeyChain.getCertificateChain(mXmppConnectionService, account.getPrivateKeyAlias());
+            PrivateKey x509PrivateKey =
+                    KeyChain.getPrivateKey(mXmppConnectionService, account.getPrivateKeyAlias());
+            X509Certificate[] chain =
+                    KeyChain.getCertificateChain(
+                            mXmppConnectionService, account.getPrivateKeyAlias());
             Signature verifier = Signature.getInstance("sha256WithRSA");
             verifier.initSign(x509PrivateKey, SECURE_RANDOM);
             verifier.update(axolotlPublicKey.serialize());
             byte[] signature = verifier.sign();
-            final Iq packet = mXmppConnectionService.getIqGenerator().publishVerification(signature, chain, getOwnDeviceId());
-            Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + ": publish verification for device " + getOwnDeviceId());
-            mXmppConnectionService.sendIqPacket(account, packet, response -> {
-                String node = AxolotlService.PEP_VERIFICATION + ":" + getOwnDeviceId();
-                mXmppConnectionService.pushNodeConfiguration(account, node, PublishOptions.openAccess(), new XmppConnectionService.OnConfigurationPushed() {
-                    @Override
-                    public void onPushSucceeded() {
-                        Log.d(Config.LOGTAG, getLogprefix(account) + "configured verification node to be world readable");
-                        publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announceAfter, wipe);
-                    }
-
-                    @Override
-                    public void onPushFailed() {
-                        Log.d(Config.LOGTAG, getLogprefix(account) + "unable to set access model on verification node");
-                        publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announceAfter, wipe);
-                    }
-                });
-            });
+            final Iq packet =
+                    mXmppConnectionService
+                            .getIqGenerator()
+                            .publishVerification(signature, chain, getOwnDeviceId());
+            Log.d(
+                    Config.LOGTAG,
+                    AxolotlService.getLogprefix(account)
+                            + ": publish verification for device "
+                            + getOwnDeviceId());
+            mXmppConnectionService.sendIqPacket(
+                    account,
+                    packet,
+                    response -> {
+                        String node = AxolotlService.PEP_VERIFICATION + ":" + getOwnDeviceId();
+                        mXmppConnectionService.pushNodeConfiguration(
+                                account,
+                                node,
+                                PublishOptions.openAccess(),
+                                new XmppConnectionService.OnConfigurationPushed() {
+                                    @Override
+                                    public void onPushSucceeded() {
+                                        Log.d(
+                                                Config.LOGTAG,
+                                                getLogprefix(account)
+                                                        + "configured verification node to be world"
+                                                        + " readable");
+                                        publishDeviceBundle(
+                                                signedPreKeyRecord,
+                                                preKeyRecords,
+                                                announceAfter,
+                                                wipe);
+                                    }
+
+                                    @Override
+                                    public void onPushFailed() {
+                                        Log.d(
+                                                Config.LOGTAG,
+                                                getLogprefix(account)
+                                                        + "unable to set access model on"
+                                                        + " verification node");
+                                        publishDeviceBundle(
+                                                signedPreKeyRecord,
+                                                preKeyRecords,
+                                                announceAfter,
+                                                wipe);
+                                    }
+                                });
+                    });
         } catch (Exception e) {
             e.printStackTrace();
         }
@@ -528,175 +652,310 @@ public class AxolotlService implements OnAdvancedStreamFeaturesLoaded {
 
     public void publishBundlesIfNeeded(final boolean announce, final boolean wipe) {
         if (pepBroken) {
-            Log.d(Config.LOGTAG, getLogprefix(account) + "publishBundlesIfNeeded called, but PEP is broken. Ignoring... ");
+            Log.d(
+                    Config.LOGTAG,
+                    getLogprefix(account)
+                            + "publishBundlesIfNeeded called, but PEP is broken. Ignoring... ");
             return;
         }
 
         if (account.getXmppConnection().getFeatures().pepPublishOptions()) {
-            this.changeAccessMode.set(account.isOptionSet(Account.OPTION_REQUIRES_ACCESS_MODE_CHANGE));
+            this.changeAccessMode.set(
+                    account.isOptionSet(Account.OPTION_REQUIRES_ACCESS_MODE_CHANGE));
         } else {
             if (account.setOption(Account.OPTION_REQUIRES_ACCESS_MODE_CHANGE, true)) {
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": server doesn’t support publish-options. setting for later access mode change");
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid()
+                                + ": server doesn’t support publish-options. setting for later"
+                                + " access mode change");
                 mXmppConnectionService.databaseBackend.updateAccount(account);
             }
         }
         if (this.changeAccessMode.get()) {
-            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": server gained publish-options capabilities. changing access model");
-        }
-        final Iq packet = mXmppConnectionService.getIqGenerator().retrieveBundlesForDevice(account.getJid().asBareJid(), getOwnDeviceId());
-        mXmppConnectionService.sendIqPacket(account, packet, response -> {
-
-            if (response.getType() == Iq.Type.TIMEOUT) {
-                return; //ignore timeout. do nothing
-            }
-
-            if (response.getType() == Iq.Type.ERROR) {
-                Element error = response.findChild("error");
-                if (error == null || !error.hasChild("item-not-found")) {
-                    pepBroken = true;
-                    Log.w(Config.LOGTAG, AxolotlService.getLogprefix(account) + "request for device bundles came back with something other than item-not-found" + response);
-                    return;
-                }
-            }
+            Log.d(
+                    Config.LOGTAG,
+                    account.getJid().asBareJid()
+                            + ": server gained publish-options capabilities. changing access"
+                            + " model");
+        }
+        final Iq packet =
+                mXmppConnectionService
+                        .getIqGenerator()
+                        .retrieveBundlesForDevice(account.getJid().asBareJid(), getOwnDeviceId());
+        mXmppConnectionService.sendIqPacket(
+                account,
+                packet,
+                response -> {
+                    if (response.getType() == Iq.Type.TIMEOUT) {
+                        return; // ignore timeout. do nothing
+                    }
 
-            PreKeyBundle bundle = IqParser.bundle(response);
-            final Map<Integer, ECPublicKey> keys = IqParser.preKeyPublics(response);
-            boolean flush = false;
-            if (bundle == null) {
-                Log.w(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Received invalid bundle:" + response);
-                bundle = new PreKeyBundle(-1, -1, -1, null, -1, null, null, null);
-                flush = true;
-            }
-            if (keys == null) {
-                Log.w(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Received invalid prekeys:" + response);
-            }
-            try {
-                boolean changed = false;
-                // Validate IdentityKey
-                IdentityKeyPair identityKeyPair = axolotlStore.getIdentityKeyPair();
-                if (flush || !identityKeyPair.getPublicKey().equals(bundle.getIdentityKey())) {
-                    Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Adding own IdentityKey " + identityKeyPair.getPublicKey() + " to PEP.");
-                    changed = true;
-                }
+                    if (response.getType() == Iq.Type.ERROR) {
+                        Element error = response.findChild("error");
+                        if (error == null || !error.hasChild("item-not-found")) {
+                            pepBroken = true;
+                            Log.w(
+                                    Config.LOGTAG,
+                                    AxolotlService.getLogprefix(account)
+                                            + "request for device bundles came back with something"
+                                            + " other than item-not-found"
+                                            + response);
+                            return;
+                        }
+                    }
 
-                // Validate signedPreKeyRecord + ID
-                SignedPreKeyRecord signedPreKeyRecord;
-                int numSignedPreKeys = axolotlStore.getSignedPreKeysCount();
-                try {
-                    signedPreKeyRecord = axolotlStore.loadSignedPreKey(bundle.getSignedPreKeyId());
-                    if (flush
-                            || !bundle.getSignedPreKey().equals(signedPreKeyRecord.getKeyPair().getPublicKey())
-                            || !Arrays.equals(bundle.getSignedPreKeySignature(), signedPreKeyRecord.getSignature())) {
-                        Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Adding new signedPreKey with ID " + (numSignedPreKeys + 1) + " to PEP.");
-                        signedPreKeyRecord = KeyHelper.generateSignedPreKey(identityKeyPair, numSignedPreKeys + 1);
-                        axolotlStore.storeSignedPreKey(signedPreKeyRecord.getId(), signedPreKeyRecord);
-                        changed = true;
+                    PreKeyBundle bundle = IqParser.bundle(response);
+                    final Map<Integer, ECPublicKey> keys = IqParser.preKeyPublics(response);
+                    boolean flush = false;
+                    if (bundle == null) {
+                        Log.w(
+                                Config.LOGTAG,
+                                AxolotlService.getLogprefix(account)
+                                        + "Received invalid bundle:"
+                                        + response);
+                        bundle = new PreKeyBundle(-1, -1, -1, null, -1, null, null, null);
+                        flush = true;
                     }
-                } catch (InvalidKeyIdException e) {
-                    Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Adding new signedPreKey with ID " + (numSignedPreKeys + 1) + " to PEP.");
-                    signedPreKeyRecord = KeyHelper.generateSignedPreKey(identityKeyPair, numSignedPreKeys + 1);
-                    axolotlStore.storeSignedPreKey(signedPreKeyRecord.getId(), signedPreKeyRecord);
-                    changed = true;
-                }
+                    if (keys == null) {
+                        Log.w(
+                                Config.LOGTAG,
+                                AxolotlService.getLogprefix(account)
+                                        + "Received invalid prekeys:"
+                                        + response);
+                    }
+                    try {
+                        boolean changed = false;
+                        // Validate IdentityKey
+                        IdentityKeyPair identityKeyPair = axolotlStore.getIdentityKeyPair();
+                        if (flush
+                                || !identityKeyPair
+                                        .getPublicKey()
+                                        .equals(bundle.getIdentityKey())) {
+                            Log.i(
+                                    Config.LOGTAG,
+                                    AxolotlService.getLogprefix(account)
+                                            + "Adding own IdentityKey "
+                                            + identityKeyPair.getPublicKey()
+                                            + " to PEP.");
+                            changed = true;
+                        }
 
-                // Validate PreKeys
-                Set<PreKeyRecord> preKeyRecords = new HashSet<>();
-                if (keys != null) {
-                    for (Integer id : keys.keySet()) {
+                        // Validate signedPreKeyRecord + ID
+                        SignedPreKeyRecord signedPreKeyRecord;
+                        int numSignedPreKeys = axolotlStore.getSignedPreKeysCount();
                         try {
-                            PreKeyRecord preKeyRecord = axolotlStore.loadPreKey(id);
-                            if (preKeyRecord.getKeyPair().getPublicKey().equals(keys.get(id))) {
-                                preKeyRecords.add(preKeyRecord);
+                            signedPreKeyRecord =
+                                    axolotlStore.loadSignedPreKey(bundle.getSignedPreKeyId());
+                            if (flush
+                                    || !bundle.getSignedPreKey()
+                                            .equals(signedPreKeyRecord.getKeyPair().getPublicKey())
+                                    || !Arrays.equals(
+                                            bundle.getSignedPreKeySignature(),
+                                            signedPreKeyRecord.getSignature())) {
+                                Log.i(
+                                        Config.LOGTAG,
+                                        AxolotlService.getLogprefix(account)
+                                                + "Adding new signedPreKey with ID "
+                                                + (numSignedPreKeys + 1)
+                                                + " to PEP.");
+                                signedPreKeyRecord =
+                                        KeyHelper.generateSignedPreKey(
+                                                identityKeyPair, numSignedPreKeys + 1);
+                                axolotlStore.storeSignedPreKey(
+                                        signedPreKeyRecord.getId(), signedPreKeyRecord);
+                                changed = true;
                             }
-                        } catch (InvalidKeyIdException ignored) {
+                        } catch (InvalidKeyIdException e) {
+                            Log.i(
+                                    Config.LOGTAG,
+                                    AxolotlService.getLogprefix(account)
+                                            + "Adding new signedPreKey with ID "
+                                            + (numSignedPreKeys + 1)
+                                            + " to PEP.");
+                            signedPreKeyRecord =
+                                    KeyHelper.generateSignedPreKey(
+                                            identityKeyPair, numSignedPreKeys + 1);
+                            axolotlStore.storeSignedPreKey(
+                                    signedPreKeyRecord.getId(), signedPreKeyRecord);
+                            changed = true;
                         }
-                    }
-                }
-                int newKeys = NUM_KEYS_TO_PUBLISH - preKeyRecords.size();
-                if (newKeys > 0) {
-                    List<PreKeyRecord> newRecords = KeyHelper.generatePreKeys(
-                            axolotlStore.getCurrentPreKeyId() + 1, newKeys);
-                    preKeyRecords.addAll(newRecords);
-                    for (PreKeyRecord record : newRecords) {
-                        axolotlStore.storePreKey(record.getId(), record);
-                    }
-                    changed = true;
-                    Log.i(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Adding " + newKeys + " new preKeys to PEP.");
-                }
 
+                        // Validate PreKeys
+                        Set<PreKeyRecord> preKeyRecords = new HashSet<>();
+                        if (keys != null) {
+                            for (Integer id : keys.keySet()) {
+                                try {
+                                    PreKeyRecord preKeyRecord = axolotlStore.loadPreKey(id);
+                                    if (preKeyRecord
+                                            .getKeyPair()
+                                            .getPublicKey()
+                                            .equals(keys.get(id))) {
+                                        preKeyRecords.add(preKeyRecord);
+                                    }
+                                } catch (InvalidKeyIdException ignored) {
+                                }
+                            }
+                        }
+                        int newKeys = NUM_KEYS_TO_PUBLISH - preKeyRecords.size();
+                        if (newKeys > 0) {
+                            List<PreKeyRecord> newRecords =
+                                    KeyHelper.generatePreKeys(
+                                            axolotlStore.getCurrentPreKeyId() + 1, newKeys);
+                            preKeyRecords.addAll(newRecords);
+                            for (PreKeyRecord record : newRecords) {
+                                axolotlStore.storePreKey(record.getId(), record);
+                            }
+                            changed = true;
+                            Log.i(
+                                    Config.LOGTAG,
+                                    AxolotlService.getLogprefix(account)
+                                            + "Adding "
+                                            + newKeys
+                                            + " new preKeys to PEP.");
+                        }
 
-                if (changed || changeAccessMode.get()) {
-                    if (account.getPrivateKeyAlias() != null && Config.X509_VERIFICATION) {
-                        mXmppConnectionService.publishDisplayName(account);
-                        publishDeviceVerificationAndBundle(signedPreKeyRecord, preKeyRecords, announce, wipe);
-                    } else {
-                        publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announce, wipe);
-                    }
-                } else {
-                    Log.d(Config.LOGTAG, getLogprefix(account) + "Bundle " + getOwnDeviceId() + " in PEP was current");
-                    if (wipe) {
-                        wipeOtherPepDevices();
-                    } else if (announce) {
-                        Log.d(Config.LOGTAG, getLogprefix(account) + "Announcing device " + getOwnDeviceId());
-                        publishOwnDeviceIdIfNeeded();
+                        if (changed || changeAccessMode.get()) {
+                            if (account.getPrivateKeyAlias() != null && Config.X509_VERIFICATION) {
+                                mXmppConnectionService.publishDisplayName(account);
+                                publishDeviceVerificationAndBundle(
+                                        signedPreKeyRecord, preKeyRecords, announce, wipe);
+                            } else {
+                                publishDeviceBundle(
+                                        signedPreKeyRecord, preKeyRecords, announce, wipe);
+                            }
+                        } else {
+                            Log.d(
+                                    Config.LOGTAG,
+                                    getLogprefix(account)
+                                            + "Bundle "
+                                            + getOwnDeviceId()
+                                            + " in PEP was current");
+                            if (wipe) {
+                                wipeOtherPepDevices();
+                            } else if (announce) {
+                                Log.d(
+                                        Config.LOGTAG,
+                                        getLogprefix(account)
+                                                + "Announcing device "
+                                                + getOwnDeviceId());
+                                publishOwnDeviceIdIfNeeded();
+                            }
+                        }
+                    } catch (InvalidKeyException e) {
+                        Log.e(
+                                Config.LOGTAG,
+                                AxolotlService.getLogprefix(account)
+                                        + "Failed to publish bundle "
+                                        + getOwnDeviceId()
+                                        + ", reason: "
+                                        + e.getMessage());
                     }
-                }
-            } catch (InvalidKeyException e) {
-                Log.e(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Failed to publish bundle " + getOwnDeviceId() + ", reason: " + e.getMessage());
-            }
-        });
+                });
     }
 
-    private void publishDeviceBundle(SignedPreKeyRecord signedPreKeyRecord,
-                                     Set<PreKeyRecord> preKeyRecords,
-                                     final boolean announceAfter,
-                                     final boolean wipe) {
+    private void publishDeviceBundle(
+            SignedPreKeyRecord signedPreKeyRecord,
+            Set<PreKeyRecord> preKeyRecords,
+            final boolean announceAfter,
+            final boolean wipe) {
         publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announceAfter, wipe, true);
     }
 
-    private void publishDeviceBundle(final SignedPreKeyRecord signedPreKeyRecord,
-                                     final Set<PreKeyRecord> preKeyRecords,
-                                     final boolean announceAfter,
-                                     final boolean wipe,
-                                     final boolean firstAttempt) {
-        final Bundle publishOptions = account.getXmppConnection().getFeatures().pepPublishOptions() ? PublishOptions.openAccess() : null;
-        final Iq publish = mXmppConnectionService.getIqGenerator().publishBundles(
-                signedPreKeyRecord, axolotlStore.getIdentityKeyPair().getPublicKey(),
-                preKeyRecords, getOwnDeviceId(), publishOptions);
-        Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + ": Bundle " + getOwnDeviceId() + " in PEP not current. Publishing...");
-        mXmppConnectionService.sendIqPacket(account, publish, response -> {
-            final boolean preconditionNotMet = PublishOptions.preconditionNotMet(response);
-            if (firstAttempt && preconditionNotMet) {
-                Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": precondition wasn't met for bundle. pushing node configuration");
-                final String node = AxolotlService.PEP_BUNDLES + ":" + getOwnDeviceId();
-                mXmppConnectionService.pushNodeConfiguration(account, node, publishOptions, new XmppConnectionService.OnConfigurationPushed() {
-                    @Override
-                    public void onPushSucceeded() {
-                        publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announceAfter, wipe, false);
-                    }
-
-                    @Override
-                    public void onPushFailed() {
-                        publishDeviceBundle(signedPreKeyRecord, preKeyRecords, announceAfter, wipe, false);
+    private void publishDeviceBundle(
+            final SignedPreKeyRecord signedPreKeyRecord,
+            final Set<PreKeyRecord> preKeyRecords,
+            final boolean announceAfter,
+            final boolean wipe,
+            final boolean firstAttempt) {
+        final Bundle publishOptions =
+                account.getXmppConnection().getFeatures().pepPublishOptions()
+                        ? PublishOptions.openAccess()
+                        : null;
+        final Iq publish =
+                mXmppConnectionService
+                        .getIqGenerator()
+                        .publishBundles(
+                                signedPreKeyRecord,
+                                axolotlStore.getIdentityKeyPair().getPublicKey(),
+                                preKeyRecords,
+                                getOwnDeviceId(),
+                                publishOptions);
+        Log.d(
+                Config.LOGTAG,
+                AxolotlService.getLogprefix(account)
+                        + ": Bundle "
+                        + getOwnDeviceId()
+                        + " in PEP not current. Publishing...");
+        mXmppConnectionService.sendIqPacket(
+                account,
+                publish,
+                response -> {
+                    final boolean preconditionNotMet = PublishOptions.preconditionNotMet(response);
+                    if (firstAttempt && preconditionNotMet) {
+                        Log.d(
+                                Config.LOGTAG,
+                                account.getJid().asBareJid()
+                                        + ": precondition wasn't met for bundle. pushing node"
+                                        + " configuration");
+                        final String node = AxolotlService.PEP_BUNDLES + ":" + getOwnDeviceId();
+                        mXmppConnectionService.pushNodeConfiguration(
+                                account,
+                                node,
+                                publishOptions,
+                                new XmppConnectionService.OnConfigurationPushed() {
+                                    @Override
+                                    public void onPushSucceeded() {
+                                        publishDeviceBundle(
+                                                signedPreKeyRecord,
+                                                preKeyRecords,
+                                                announceAfter,
+                                                wipe,
+                                                false);
+                                    }
+
+                                    @Override
+                                    public void onPushFailed() {
+                                        publishDeviceBundle(
+                                                signedPreKeyRecord,
+                                                preKeyRecords,
+                                                announceAfter,
+                                                wipe,
+                                                false);
+                                    }
+                                });
+                    } else if (response.getType() == Iq.Type.RESULT) {
+                        Log.d(
+                                Config.LOGTAG,
+                                AxolotlService.getLogprefix(account)
+                                        + "Successfully published bundle. ");
+                        if (wipe) {
+                            wipeOtherPepDevices();
+                        } else if (announceAfter) {
+                            Log.d(
+                                    Config.LOGTAG,
+                                    getLogprefix(account)
+                                            + "Announcing device "
+                                            + getOwnDeviceId());
+                            publishOwnDeviceIdIfNeeded();
+                        }
+                    } else if (response.getType() == Iq.Type.ERROR) {
+                        if (preconditionNotMet) {
+                            Log.d(
+                                    Config.LOGTAG,
+                                    getLogprefix(account)
+                                            + "bundle precondition still not met after second"
+                                            + " attempt");
+                        } else {
+                            Log.d(
+                                    Config.LOGTAG,
+                                    getLogprefix(account)
+                                            + "Error received while publishing bundle: "
+                                            + response.toString());
+                        }
+                        pepBroken = true;
                     }
                 });
-            } else if (response.getType() == Iq.Type.RESULT) {
-                Log.d(Config.LOGTAG, AxolotlService.getLogprefix(account) + "Successfully published bundle. ");
-                if (wipe) {
-                    wipeOtherPepDevices();
-                } else if (announceAfter) {
-                    Log.d(Config.LOGTAG, getLogprefix(account) + "Announcing device " + getOwnDeviceId());
-                    publishOwnDeviceIdIfNeeded();
-                }
-            } else if (response.getType() == Iq.Type.ERROR) {
-                if (preconditionNotMet) {
-                    Log.d(Config.LOGTAG, getLogprefix(account) + "bundle precondition still not met after second attempt");
-                } else {
-                    Log.d(Config.LOGTAG, getLogprefix(account) + "Error received while publishing bundle: " + response.toString());
-                }
-                pepBroken = true;
-            }
-        });
     }
 
     public void deleteOmemoIdentity() {

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

@@ -36,6 +36,7 @@ import eu.siacs.conversations.crypto.sasl.HashedToken;
 import eu.siacs.conversations.crypto.sasl.HashedTokenSha256;
 import eu.siacs.conversations.crypto.sasl.HashedTokenSha512;
 import eu.siacs.conversations.crypto.sasl.SaslMechanism;
+import eu.siacs.conversations.http.ServiceOutageStatus;
 import eu.siacs.conversations.services.AvatarService;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.utils.Resolver;
@@ -82,6 +83,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
     private static final String KEY_PGP_SIGNATURE = "pgp_signature";
     private static final String KEY_PGP_ID = "pgp_id";
     private static final String KEY_PINNED_MECHANISM = "pinned_mechanism";
+    public static final String KEY_SOS_URL = "sos_url";
     public static final String KEY_PRE_AUTH_REGISTRATION_TOKEN = "pre_auth_registration";
 
     protected final JSONObject keys;
@@ -118,6 +120,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
     private Integer color = null;
     private final HashMultimap<String, Contact> gateways = HashMultimap.create();
     private Element mamPrefs = null;
+    private ServiceOutageStatus serviceOutageStatus;
 
     public Account(final Jid jid, final String password) {
         this(
@@ -853,6 +856,24 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         throw new IllegalStateException("This method should not be called");
     }
 
+    public void setServiceOutageStatus(final ServiceOutageStatus sos) {
+        this.serviceOutageStatus = sos;
+    }
+
+    public ServiceOutageStatus getServiceOutageStatus() {
+        return this.serviceOutageStatus;
+    }
+
+    public boolean isServiceOutage() {
+        final var sos = this.serviceOutageStatus;
+        if (sos != null
+                && isOptionSet(Account.OPTION_LOGGED_IN_SUCCESSFULLY)
+                && ServiceOutageStatus.isPossibleOutage(this.status)) {
+            return sos.isNow();
+        }
+        return false;
+    }
+
     public enum State {
         DISABLED(false, false),
         LOGGED_OUT(false, false),
@@ -914,78 +935,43 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         }
 
         public int getReadableId() {
-            switch (this) {
-                case DISABLED:
-                    return R.string.account_status_disabled;
-                case LOGGED_OUT:
-                    return R.string.account_state_logged_out;
-                case ONLINE:
-                    return R.string.account_status_online;
-                case CONNECTING:
-                    return R.string.account_status_connecting;
-                case OFFLINE:
-                    return R.string.account_status_offline;
-                case UNAUTHORIZED:
-                    return R.string.account_status_unauthorized;
-                case SERVER_NOT_FOUND:
-                    return R.string.account_status_not_found;
-                case NO_INTERNET:
-                    return R.string.account_status_no_internet;
-                case CONNECTION_TIMEOUT:
-                    return R.string.account_status_connection_timeout;
-                case REGISTRATION_FAILED:
-                    return R.string.account_status_regis_fail;
-                case REGISTRATION_WEB:
-                    return R.string.account_status_regis_web;
-                case REGISTRATION_CONFLICT:
-                    return R.string.account_status_regis_conflict;
-                case REGISTRATION_SUCCESSFUL:
-                    return R.string.account_status_regis_success;
-                case REGISTRATION_NOT_SUPPORTED:
-                    return R.string.account_status_regis_not_sup;
-                case REGISTRATION_INVALID_TOKEN:
-                    return R.string.account_status_regis_invalid_token;
-                case TLS_ERROR:
-                    return R.string.account_status_tls_error;
-                case TLS_ERROR_DOMAIN:
-                    return R.string.account_status_tls_error_domain;
-                case INCOMPATIBLE_SERVER:
-                    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:
-                    return R.string.account_status_bind_failure;
-                case SESSION_FAILURE:
-                    return R.string.session_failure;
-                case DOWNGRADE_ATTACK:
-                    return R.string.sasl_downgrade;
-                case HOST_UNKNOWN:
-                    return R.string.account_status_host_unknown;
-                case POLICY_VIOLATION:
-                    return R.string.account_status_policy_violation;
-                case REGISTRATION_PLEASE_WAIT:
-                    return R.string.registration_please_wait;
-                case REGISTRATION_PASSWORD_TOO_WEAK:
-                    return R.string.registration_password_too_weak;
-                case STREAM_ERROR:
-                    return R.string.account_status_stream_error;
-                case STREAM_OPENING_ERROR:
-                    return R.string.account_status_stream_opening_error;
-                case PAYMENT_REQUIRED:
-                    return R.string.payment_required;
-                case SEE_OTHER_HOST:
-                    return R.string.reconnect_on_other_host;
-                case MISSING_INTERNET_PERMISSION:
-                    return R.string.missing_internet_permission;
-                case TEMPORARY_AUTH_FAILURE:
-                    return R.string.account_status_temporary_auth_failure;
-                default:
-                    return R.string.account_status_unknown;
-            }
+            return switch (this) {
+                case DISABLED -> R.string.account_status_disabled;
+                case LOGGED_OUT -> R.string.account_state_logged_out;
+                case ONLINE -> R.string.account_status_online;
+                case CONNECTING -> R.string.account_status_connecting;
+                case OFFLINE -> R.string.account_status_offline;
+                case UNAUTHORIZED -> R.string.account_status_unauthorized;
+                case SERVER_NOT_FOUND -> R.string.account_status_not_found;
+                case NO_INTERNET -> R.string.account_status_no_internet;
+                case CONNECTION_TIMEOUT -> R.string.account_status_connection_timeout;
+                case REGISTRATION_FAILED -> R.string.account_status_regis_fail;
+                case REGISTRATION_WEB -> R.string.account_status_regis_web;
+                case REGISTRATION_CONFLICT -> R.string.account_status_regis_conflict;
+                case REGISTRATION_SUCCESSFUL -> R.string.account_status_regis_success;
+                case REGISTRATION_NOT_SUPPORTED -> R.string.account_status_regis_not_sup;
+                case REGISTRATION_INVALID_TOKEN -> R.string.account_status_regis_invalid_token;
+                case TLS_ERROR -> R.string.account_status_tls_error;
+                case TLS_ERROR_DOMAIN -> R.string.account_status_tls_error_domain;
+                case INCOMPATIBLE_SERVER -> R.string.account_status_incompatible_server;
+                case INCOMPATIBLE_CLIENT -> R.string.account_status_incompatible_client;
+                case CHANNEL_BINDING -> R.string.account_status_channel_binding;
+                case TOR_NOT_AVAILABLE -> R.string.account_status_tor_unavailable;
+                case BIND_FAILURE -> R.string.account_status_bind_failure;
+                case SESSION_FAILURE -> R.string.session_failure;
+                case DOWNGRADE_ATTACK -> R.string.sasl_downgrade;
+                case HOST_UNKNOWN -> R.string.account_status_host_unknown;
+                case POLICY_VIOLATION -> R.string.account_status_policy_violation;
+                case REGISTRATION_PLEASE_WAIT -> R.string.registration_please_wait;
+                case REGISTRATION_PASSWORD_TOO_WEAK -> R.string.registration_password_too_weak;
+                case STREAM_ERROR -> R.string.account_status_stream_error;
+                case STREAM_OPENING_ERROR -> R.string.account_status_stream_opening_error;
+                case PAYMENT_REQUIRED -> R.string.payment_required;
+                case SEE_OTHER_HOST -> R.string.reconnect_on_other_host;
+                case MISSING_INTERNET_PERMISSION -> R.string.missing_internet_permission;
+                case TEMPORARY_AUTH_FAILURE -> R.string.account_status_temporary_auth_failure;
+                default -> R.string.account_status_unknown;
+            };
         }
     }
 }

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

@@ -10,6 +10,9 @@ import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.Namespace;
 import eu.siacs.conversations.xmpp.Jid;
+import im.conversations.android.xmpp.model.bookmark.Storage;
+import im.conversations.android.xmpp.model.bookmark2.Conference;
+import im.conversations.android.xmpp.model.pubsub.PubSub;
 import java.lang.ref.WeakReference;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -37,7 +40,8 @@ public class Bookmark extends Element implements ListItem {
         this.account = account;
     }
 
-    public static Map<Jid, Bookmark> parseFromStorage(Element storage, Account account) {
+    public static Map<Jid, Bookmark> parseFromStorage(
+            final Storage storage, final Account account) {
         if (storage == null) {
             return Collections.emptyMap();
         }
@@ -58,24 +62,24 @@ public class Bookmark extends Element implements ListItem {
         return bookmarks;
     }
 
-    public static Map<Jid, Bookmark> parseFromPubSub(final Element pubSub, final Account account) {
+    public static Map<Jid, Bookmark> parseFromPubSub(final PubSub pubSub, final Account account) {
         if (pubSub == null) {
             return Collections.emptyMap();
         }
-        final Element items = pubSub.findChild("items");
-        if (items != null && Namespace.BOOKMARKS2.equals(items.getAttribute("node"))) {
-            final Map<Jid, Bookmark> bookmarks = new HashMap<>();
-            for (Element item : items.getChildren()) {
-                if (item.getName().equals("item")) {
-                    final Bookmark bookmark = Bookmark.parseFromItem(item, account);
-                    if (bookmark != null) {
-                        bookmarks.put(bookmark.jid, bookmark);
-                    }
-                }
+        final var items = pubSub.getItems();
+        if (items == null || !Namespace.BOOKMARKS2.equals(items.getNode())) {
+            return Collections.emptyMap();
+        }
+        final Map<Jid, Bookmark> bookmarks = new HashMap<>();
+        for (final var item : items.getItemMap(Conference.class).entrySet()) {
+            final Bookmark bookmark =
+                    Bookmark.parseFromItem(item.getKey(), item.getValue(), account);
+            if (bookmark == null) {
+                continue;
             }
-            return bookmarks;
+            bookmarks.put(bookmark.jid, bookmark);
         }
-        return Collections.emptyMap();
+        return bookmarks;
     }
 
     public static Bookmark parse(Element element, Account account) {
@@ -89,32 +93,32 @@ public class Bookmark extends Element implements ListItem {
         return bookmark;
     }
 
-	public static Bookmark parseFromItem(Element item, Account account) {
-		final Element conference = item.findChild("conference", Namespace.BOOKMARKS2);
-		if (conference == null) {
-			return null;
-		}
-		final Bookmark bookmark = new Bookmark(account);
-		bookmark.jid = Jid.Invalid.getNullForInvalid(item.getAttributeAsJid("id"));
-		// TODO verify that we only use bare jids and ignore full jids
-		if (bookmark.jid == null) {
-			return null;
-		}
-		bookmark.setBookmarkName(conference.getAttribute("name"));
-		bookmark.setAutojoin(conference.getAttributeAsBoolean("autojoin"));
-		bookmark.setNick(conference.findChildContent("nick"));
-		bookmark.setPassword(conference.findChildContent("password"));
-		final Element extensions = conference.findChild("extensions", Namespace.BOOKMARKS2);
-		if (extensions != null) {
-			for (final Element ext : extensions.getChildren()) {
-				if (ext.getName().equals("group") && ext.getNamespace().equals("jabber:iq:roster")) {
-					bookmark.addGroup(ext.getContent());
-				}
-			}
-			bookmark.extensions = extensions;
-		}
-		return bookmark;
-	}
+    public static Bookmark parseFromItem(
+            final String id, final Conference conference, final Account account) {
+        if (id == null || conference == null) {
+            return null;
+        }
+        final Bookmark bookmark = new Bookmark(account);
+        bookmark.jid = Jid.Invalid.getNullForInvalid(Jid.ofOrInvalid(id));
+        // TODO verify that we only use bare jids and ignore full jids
+        if (bookmark.jid == null) {
+            return null;
+        }
+        bookmark.setBookmarkName(conference.getAttribute("name"));
+        bookmark.setAutojoin(conference.getAttributeAsBoolean("autojoin"));
+        bookmark.setNick(conference.findChildContent("nick"));
+        bookmark.setPassword(conference.findChildContent("password"));
+        final Element extensions = conference.findChild("extensions", Namespace.BOOKMARKS2);
+        if (extensions != null) {
+            for (final Element ext : extensions.getChildren()) {
+                if (ext.getName().equals("group") && ext.getNamespace().equals("jabber:iq:roster")) {
+                    bookmark.addGroup(ext.getContent());
+                }
+            }
+            bookmark.extensions = extensions;
+        }
+        return bookmark;
+    }
 
     public Element getExtensions() {
         return extensions;

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

@@ -3,15 +3,12 @@ package eu.siacs.conversations.entities;
 import android.content.ContentValues;
 import android.database.Cursor;
 import android.util.Base64;
-
-import androidx.annotation.NonNull;
-
 import com.google.common.base.Strings;
-
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
-
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.Namespace;
+import eu.siacs.conversations.xmpp.forms.Data;
+import eu.siacs.conversations.xmpp.forms.Field;
+import im.conversations.android.xmpp.model.stanza.Iq;
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
@@ -19,341 +16,338 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
-
-import eu.siacs.conversations.xml.Element;
-import eu.siacs.conversations.xml.Namespace;
-import eu.siacs.conversations.xmpp.forms.Data;
-import eu.siacs.conversations.xmpp.forms.Field;
-import im.conversations.android.xmpp.model.stanza.Iq;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
 
 public class ServiceDiscoveryResult {
-	public static final String TABLENAME = "discovery_results";
-	public static final String HASH = "hash";
-	public static final String VER = "ver";
-	public static final String RESULT = "result";
-	protected final String hash;
-	protected final byte[] ver;
-	protected final List<String> features;
-	protected final List<Data> forms;
-	private final List<Identity> identities;
-	public ServiceDiscoveryResult(final Iq packet) {
-		this.identities = new ArrayList<>();
-		this.features = new ArrayList<>();
-		this.forms = new ArrayList<>();
-		this.hash = "sha-1"; // We only support sha-1 for now
-
-		final List<Element> elements = packet.query().getChildren();
-
-		for (final Element element : elements) {
-			if (element.getName().equals("identity")) {
-				Identity id = new Identity(element);
-				if (id.getType() != null && id.getCategory() != null) {
-					identities.add(id);
-				}
-			} else if (element.getName().equals("feature")) {
-				if (element.getAttribute("var") != null) {
-					features.add(element.getAttribute("var"));
-				}
-			} else if (element.getName().equals("x") && element.getAttribute("xmlns").equals(Namespace.DATA)) {
-				forms.add(Data.parse(element));
-			}
-		}
-		this.ver = this.mkCapHash();
-	}
-	private ServiceDiscoveryResult(String hash, byte[] ver, JSONObject o) throws JSONException {
-		this.identities = new ArrayList<>();
-		this.features = new ArrayList<>();
-		this.forms = new ArrayList<>();
-		this.hash = hash;
-		this.ver = ver;
-
-		JSONArray identities = o.optJSONArray("identities");
-		if (identities != null) {
-			for (int i = 0; i < identities.length(); i++) {
-				this.identities.add(new Identity(identities.getJSONObject(i)));
-			}
-		}
-		JSONArray features = o.optJSONArray("features");
-		if (features != null) {
-			for (int i = 0; i < features.length(); i++) {
-				this.features.add(features.getString(i));
-			}
-		}
-		JSONArray forms = o.optJSONArray("forms");
-		if (forms != null) {
-			for (int i = 0; i < forms.length(); i++) {
-				this.forms.add(createFormFromJSONObject(forms.getJSONObject(i)));
-			}
-		}
-	}
-	
-	private ServiceDiscoveryResult() {
-		this.hash = "sha-1";
-		this.features = Collections.emptyList();
-		this.identities = Collections.emptyList();
-		this.ver = null;
-		this.forms = Collections.emptyList();
-	}
-
-	public static ServiceDiscoveryResult empty() {
-		return new ServiceDiscoveryResult();
-	}
-
-	public ServiceDiscoveryResult(Cursor cursor) throws JSONException {
-		this(
-				cursor.getString(cursor.getColumnIndexOrThrow(HASH)),
-				Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(VER)), Base64.DEFAULT),
-				new JSONObject(cursor.getString(cursor.getColumnIndexOrThrow(RESULT)))
-		);
-	}
-
-	private static String clean(String s) {
-		return s.replace("<","&lt;");
-	}
-
-	private static String blankNull(String s) {
-		return s == null ? "" : clean(s);
-	}
-
-	private static Data createFormFromJSONObject(JSONObject o) {
-		Data data = new Data();
-		JSONArray names = o.names();
-		for (int i = 0; i < names.length(); ++i) {
-			try {
-				String name = names.getString(i);
-				JSONArray jsonValues = o.getJSONArray(name);
-				ArrayList<String> values = new ArrayList<>(jsonValues.length());
-				for (int j = 0; j < jsonValues.length(); ++j) {
-					values.add(jsonValues.getString(j));
-				}
-				data.put(name, values);
-			} catch (Exception e) {
-				e.printStackTrace();
-			}
-		}
-		return data;
-	}
-
-	private static JSONObject createJSONFromForm(Data data) {
-		JSONObject object = new JSONObject();
-		for (Field field : data.getFields()) {
-			try {
-				JSONArray jsonValues = new JSONArray();
-				for (String value : field.getValues()) {
-					jsonValues.put(value);
-				}
-				object.put(field.getFieldName(), jsonValues);
-			} catch (Exception e) {
-				e.printStackTrace();
-			}
-		}
-		try {
-			JSONArray jsonValues = new JSONArray();
-			jsonValues.put(data.getFormType());
-			object.put(Data.FORM_TYPE, jsonValues);
-		} catch (Exception e) {
-			e.printStackTrace();
-		}
-		return object;
-	}
-
-	public String getVer() {
-		return Base64.encodeToString(this.ver, Base64.NO_WRAP);
-	}
-
-	public List<Identity> getIdentities() {
-		return this.identities;
-	}
-
-	public List<String> getFeatures() {
-		return this.features;
-	}
-
-	public Identity getIdentity(String category, String type) {
-		for (Identity id : this.getIdentities()) {
-			if ((category == null || id.getCategory().equals(category)) &&
-					(type == null || id.getType().equals(type))) {
-				return id;
-			}
-		}
-
-		return null;
-	}
-
-	public boolean hasIdentity(String category, String type) {
-		return getIdentity(category, type) != null;
-	}
-
-	public String getExtendedDiscoInformation(String formType, String name) {
-		for (Data form : this.forms) {
-			if (formType.equals(form.getFormType())) {
-				for (Field field : form.getFields()) {
-					if (name.equals(field.getFieldName())) {
-						return field.getValue();
-					}
-				}
-			}
-		}
-		return null;
-	}
-
-	private byte[] mkCapHash() {
-		StringBuilder s = new StringBuilder();
-
-		List<Identity> identities = this.getIdentities();
-		Collections.sort(identities);
-
-		for (Identity id : identities) {
-			s.append(blankNull(id.getCategory()))
-					.append("/")
-					.append(blankNull(id.getType()))
-					.append("/")
-					.append(blankNull(id.getLang()))
-					.append("/")
-					.append(blankNull(id.getName()))
-					.append("<");
-		}
-
-		final List<String> features = this.getFeatures();
-		Collections.sort(features);
-		for (final String feature : features) {
-			s.append(clean(feature)).append("<");
-		}
-
-		Collections.sort(forms, Comparator.comparing(Data::getFormType));
-		for (final Data form : forms) {
-			s.append(clean(form.getFormType())).append("<");
-			final List<Field> fields = form.getFields();
-			Collections.sort(
+    public static final String TABLENAME = "discovery_results";
+    public static final String HASH = "hash";
+    public static final String VER = "ver";
+    public static final String RESULT = "result";
+    protected final String hash;
+    protected final byte[] ver;
+    protected final List<String> features;
+    protected final List<Data> forms;
+    private final List<Identity> identities;
+
+    public ServiceDiscoveryResult(final Iq packet) {
+        this.identities = new ArrayList<>();
+        this.features = new ArrayList<>();
+        this.forms = new ArrayList<>();
+        this.hash = "sha-1"; // We only support sha-1 for now
+
+        final List<Element> elements = packet.query().getChildren();
+
+        for (final Element element : elements) {
+            if (element.getName().equals("identity")) {
+                Identity id = new Identity(element);
+                if (id.getType() != null && id.getCategory() != null) {
+                    identities.add(id);
+                }
+            } else if (element.getName().equals("feature")) {
+                if (element.getAttribute("var") != null) {
+                    features.add(element.getAttribute("var"));
+                }
+            } else if (element.getName().equals("x")
+                    && element.getAttribute("xmlns").equals(Namespace.DATA)) {
+                forms.add(Data.parse(element));
+            }
+        }
+        this.ver = this.mkCapHash();
+    }
+
+    private ServiceDiscoveryResult(String hash, byte[] ver, JSONObject o) throws JSONException {
+        this.identities = new ArrayList<>();
+        this.features = new ArrayList<>();
+        this.forms = new ArrayList<>();
+        this.hash = hash;
+        this.ver = ver;
+
+        JSONArray identities = o.optJSONArray("identities");
+        if (identities != null) {
+            for (int i = 0; i < identities.length(); i++) {
+                this.identities.add(new Identity(identities.getJSONObject(i)));
+            }
+        }
+        JSONArray features = o.optJSONArray("features");
+        if (features != null) {
+            for (int i = 0; i < features.length(); i++) {
+                this.features.add(features.getString(i));
+            }
+        }
+        JSONArray forms = o.optJSONArray("forms");
+        if (forms != null) {
+            for (int i = 0; i < forms.length(); i++) {
+                this.forms.add(createFormFromJSONObject(forms.getJSONObject(i)));
+            }
+        }
+    }
+
+    private ServiceDiscoveryResult() {
+        this.hash = "sha-1";
+        this.features = Collections.emptyList();
+        this.identities = Collections.emptyList();
+        this.ver = null;
+        this.forms = Collections.emptyList();
+    }
+
+    public static ServiceDiscoveryResult empty() {
+        return new ServiceDiscoveryResult();
+    }
+
+    public ServiceDiscoveryResult(Cursor cursor) throws JSONException {
+        this(
+                cursor.getString(cursor.getColumnIndexOrThrow(HASH)),
+                Base64.decode(cursor.getString(cursor.getColumnIndexOrThrow(VER)), Base64.DEFAULT),
+                new JSONObject(cursor.getString(cursor.getColumnIndexOrThrow(RESULT))));
+    }
+
+    private static String clean(String s) {
+        return s.replace("<", "&lt;");
+    }
+
+    private static String blankNull(String s) {
+        return s == null ? "" : clean(s);
+    }
+
+    private static Data createFormFromJSONObject(JSONObject o) {
+        Data data = new Data();
+        JSONArray names = o.names();
+        for (int i = 0; i < names.length(); ++i) {
+            try {
+                String name = names.getString(i);
+                JSONArray jsonValues = o.getJSONArray(name);
+                ArrayList<String> values = new ArrayList<>(jsonValues.length());
+                for (int j = 0; j < jsonValues.length(); ++j) {
+                    values.add(jsonValues.getString(j));
+                }
+                data.put(name, values);
+            } catch (Exception e) {
+                e.printStackTrace();
+            }
+        }
+        return data;
+    }
+
+    private static JSONObject createJSONFromForm(Data data) {
+        JSONObject object = new JSONObject();
+        for (Field field : data.getFields()) {
+            try {
+                JSONArray jsonValues = new JSONArray();
+                for (String value : field.getValues()) {
+                    jsonValues.put(value);
+                }
+                object.put(field.getFieldName(), jsonValues);
+            } catch (Exception e) {
+                e.printStackTrace();
+            }
+        }
+        try {
+            JSONArray jsonValues = new JSONArray();
+            jsonValues.put(data.getFormType());
+            object.put(Data.FORM_TYPE, jsonValues);
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return object;
+    }
+
+    public Identity getIdentity(String category, String type) {
+        for (Identity id : this.getIdentities()) {
+            if ((category == null || id.getCategory().equals(category)) &&
+                    (type == null || id.getType().equals(type))) {
+                return id;
+            }
+        }
+
+        return null;
+    }
+
+    public boolean hasIdentity(String category, String type) {
+        return getIdentity(category, type) != null;
+    }
+
+    public String getVer() {
+        return Base64.encodeToString(this.ver, Base64.NO_WRAP);
+    }
+
+    public List<Identity> getIdentities() {
+        return this.identities;
+    }
+
+    public List<String> getFeatures() {
+        return this.features;
+    }
+
+    public String getExtendedDiscoInformation(final String formType, final String name) {
+        for (final Data form : this.forms) {
+            if (formType.equals(form.getFormType())) {
+                for (final Field field : form.getFields()) {
+                    if (name.equals(field.getFieldName())) {
+                        return field.getValue();
+                    }
+                }
+            }
+        }
+        return null;
+    }
+
+    private byte[] mkCapHash() {
+        StringBuilder s = new StringBuilder();
+
+        List<Identity> identities = this.getIdentities();
+        Collections.sort(identities);
+
+        for (Identity id : identities) {
+            s.append(blankNull(id.getCategory()))
+                    .append("/")
+                    .append(blankNull(id.getType()))
+                    .append("/")
+                    .append(blankNull(id.getLang()))
+                    .append("/")
+                    .append(blankNull(id.getName()))
+                    .append("<");
+        }
+
+        final List<String> features = this.getFeatures();
+        Collections.sort(features);
+        for (final String feature : features) {
+            s.append(clean(feature)).append("<");
+        }
+
+        Collections.sort(forms, Comparator.comparing(Data::getFormType));
+        for (final Data form : forms) {
+            s.append(clean(form.getFormType())).append("<");
+            final List<Field> fields = form.getFields();
+            Collections.sort(
                     fields, Comparator.comparing(lhs -> Strings.nullToEmpty(lhs.getFieldName())));
-			for (final Field field : fields) {
-				s.append(Strings.nullToEmpty(field.getFieldName())).append("<");
-				final List<String> values = field.getValues();
-				Collections.sort(values, Comparator.comparing(ServiceDiscoveryResult::blankNull));
-				for (final String value : values) {
-					s.append(blankNull(value)).append("<");
-				}
-			}
-		}
-
-		MessageDigest md;
-		try {
-			md = MessageDigest.getInstance("SHA-1");
-		} catch (NoSuchAlgorithmException e) {
-			return null;
-		}
+            for (final Field field : fields) {
+                s.append(Strings.nullToEmpty(field.getFieldName())).append("<");
+                final List<String> values = field.getValues();
+                Collections.sort(values, Comparator.comparing(ServiceDiscoveryResult::blankNull));
+                for (final String value : values) {
+                    s.append(blankNull(value)).append("<");
+                }
+            }
+        }
+
+        MessageDigest md;
+        try {
+            md = MessageDigest.getInstance("SHA-1");
+        } catch (NoSuchAlgorithmException e) {
+            return null;
+        }
 
         return md.digest(s.toString().getBytes(StandardCharsets.UTF_8));
     }
 
-	private JSONObject toJSON() {
-		try {
-			JSONObject o = new JSONObject();
-
-			JSONArray ids = new JSONArray();
-			for (Identity id : this.getIdentities()) {
-				ids.put(id.toJSON());
-			}
-			o.put("identities", ids);
-
-			o.put("features", new JSONArray(this.getFeatures()));
-
-			JSONArray forms = new JSONArray();
-			for (Data data : this.forms) {
-				forms.put(createJSONFromForm(data));
-			}
-			o.put("forms", forms);
-
-			return o;
-		} catch (JSONException e) {
-			return null;
-		}
-	}
-
-	public ContentValues getContentValues() {
-		final ContentValues values = new ContentValues();
-		values.put(HASH, this.hash);
-		values.put(VER, getVer());
-		JSONObject jsonObject = toJSON();
-		values.put(RESULT, jsonObject == null ? "" : jsonObject.toString());
-		return values;
-	}
-
-	public static class Identity implements Comparable<Identity> {
-		protected final String type;
-		protected final String lang;
-		protected final String name;
-		final String category;
-
-		Identity(final String category, final String type, final String lang, final String name) {
-			this.category = category;
-			this.type = type;
-			this.lang = lang;
-			this.name = name;
-		}
-
-		Identity(final Element el) {
-			this(
-					el.getAttribute("category"),
-					el.getAttribute("type"),
-					el.getAttribute("xml:lang"),
-					el.getAttribute("name")
-			);
-		}
-
-		Identity(final JSONObject o) {
-
-			this(
-					o.optString("category", null),
-					o.optString("type", null),
-					o.optString("lang", null),
-					o.optString("name", null)
-			);
-		}
-
-		public String getCategory() {
-			return this.category;
-		}
-
-		public String getType() {
-			return this.type;
-		}
-
-		public String getLang() {
-			return this.lang;
-		}
-
-		public String getName() {
-			return this.name;
-		}
-
-		JSONObject toJSON() {
-			try {
-				JSONObject o = new JSONObject();
-				o.put("category", this.getCategory());
-				o.put("type", this.getType());
-				o.put("lang", this.getLang());
-				o.put("name", this.getName());
-				return o;
-			} catch (JSONException e) {
-				return null;
-			}
-		}
-
-		@Override
-		public int compareTo(final Identity o) {
-			int r = blankNull(this.getCategory()).compareTo(blankNull(o.getCategory()));
-			if (r == 0) {
-				r = blankNull(this.getType()).compareTo(blankNull(o.getType()));
-			}
-			if (r == 0) {
-				r = blankNull(this.getLang()).compareTo(blankNull(o.getLang()));
-			}
-			if (r == 0) {
-				r = blankNull(this.getName()).compareTo(blankNull(o.getName()));
-			}
-
-			return r;
-		}
-	}
+    private JSONObject toJSON() {
+        try {
+            JSONObject o = new JSONObject();
+
+            JSONArray ids = new JSONArray();
+            for (Identity id : this.getIdentities()) {
+                ids.put(id.toJSON());
+            }
+            o.put("identities", ids);
+
+            o.put("features", new JSONArray(this.getFeatures()));
+
+            JSONArray forms = new JSONArray();
+            for (Data data : this.forms) {
+                forms.put(createJSONFromForm(data));
+            }
+            o.put("forms", forms);
+
+            return o;
+        } catch (JSONException e) {
+            return null;
+        }
+    }
+
+    public ContentValues getContentValues() {
+        final ContentValues values = new ContentValues();
+        values.put(HASH, this.hash);
+        values.put(VER, getVer());
+        JSONObject jsonObject = toJSON();
+        values.put(RESULT, jsonObject == null ? "" : jsonObject.toString());
+        return values;
+    }
+
+    public static class Identity implements Comparable<Identity> {
+        protected final String type;
+        protected final String lang;
+        protected final String name;
+        final String category;
+
+        Identity(final String category, final String type, final String lang, final String name) {
+            this.category = category;
+            this.type = type;
+            this.lang = lang;
+            this.name = name;
+        }
+
+        Identity(final Element el) {
+            this(
+                    el.getAttribute("category"),
+                    el.getAttribute("type"),
+                    el.getAttribute("xml:lang"),
+                    el.getAttribute("name"));
+        }
+
+        Identity(final JSONObject o) {
+
+            this(
+                    o.optString("category", null),
+                    o.optString("type", null),
+                    o.optString("lang", null),
+                    o.optString("name", null));
+        }
+
+        public String getCategory() {
+            return this.category;
+        }
+
+        public String getType() {
+            return this.type;
+        }
+
+        public String getLang() {
+            return this.lang;
+        }
+
+        public String getName() {
+            return this.name;
+        }
+
+        JSONObject toJSON() {
+            try {
+                JSONObject o = new JSONObject();
+                o.put("category", this.getCategory());
+                o.put("type", this.getType());
+                o.put("lang", this.getLang());
+                o.put("name", this.getName());
+                return o;
+            } catch (JSONException e) {
+                return null;
+            }
+        }
+
+        @Override
+        public int compareTo(final Identity o) {
+            int r = blankNull(this.getCategory()).compareTo(blankNull(o.getCategory()));
+            if (r == 0) {
+                r = blankNull(this.getType()).compareTo(blankNull(o.getType()));
+            }
+            if (r == 0) {
+                r = blankNull(this.getLang()).compareTo(blankNull(o.getLang()));
+            }
+            if (r == 0) {
+                r = blankNull(this.getName()).compareTo(blankNull(o.getName()));
+            }
+
+            return r;
+        }
+    }
 }

src/main/java/eu/siacs/conversations/generator/MessageGenerator.java 🔗

@@ -18,11 +18,12 @@ import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
 import eu.siacs.conversations.xmpp.jingle.Media;
 import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
 import im.conversations.android.xmpp.model.correction.Replace;
+import im.conversations.android.xmpp.model.hints.Store;
 import im.conversations.android.xmpp.model.reactions.Reaction;
 import im.conversations.android.xmpp.model.reactions.Reactions;
+import im.conversations.android.xmpp.model.receipts.Received;
 import im.conversations.android.xmpp.model.unique.OriginId;
 import java.text.SimpleDateFormat;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Date;
 import java.util.Locale;
@@ -305,19 +306,14 @@ public class MessageGenerator extends AbstractGenerator {
     }
 
     public im.conversations.android.xmpp.model.stanza.Message received(
-            Account account,
             final Jid from,
             final String id,
-            ArrayList<String> namespaces,
-            im.conversations.android.xmpp.model.stanza.Message.Type type) {
+            final im.conversations.android.xmpp.model.stanza.Message.Type type) {
         final var receivedPacket = new im.conversations.android.xmpp.model.stanza.Message();
         receivedPacket.setType(type);
         receivedPacket.setTo(from);
-        receivedPacket.setFrom(account.getJid());
-        for (final String namespace : namespaces) {
-            receivedPacket.addChild("received", namespace).setAttribute("id", id);
-        }
-        receivedPacket.addChild("store", "urn:xmpp:hints");
+        receivedPacket.addExtension(new Received(id));
+        receivedPacket.addExtension(new Store());
         return receivedPacket;
     }
 

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

@@ -0,0 +1,162 @@
+package eu.siacs.conversations.http;
+
+import android.content.Context;
+import androidx.annotation.NonNull;
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.annotations.SerializedName;
+import eu.siacs.conversations.AppSettings;
+import eu.siacs.conversations.entities.Account;
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Locale;
+import java.util.Map;
+import okhttp3.Call;
+import okhttp3.Callback;
+import okhttp3.HttpUrl;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+
+public class ServiceOutageStatus {
+
+    private static final Collection<Account.State> SERVICE_OUTAGE_STATE =
+            Arrays.asList(
+                    Account.State.CONNECTION_TIMEOUT,
+                    Account.State.SERVER_NOT_FOUND,
+                    Account.State.STREAM_OPENING_ERROR);
+
+    private final boolean planned;
+    private final Instant beginning;
+
+    @SerializedName("expected_end")
+    private final Instant expectedEnd;
+
+    private final Map<String, String> message;
+
+    public ServiceOutageStatus(
+            final boolean planned,
+            final Instant beginning,
+            final Instant expectedEnd,
+            final Map<String, String> message) {
+        this.planned = planned;
+        this.beginning = beginning;
+        this.expectedEnd = expectedEnd;
+        this.message = message;
+    }
+
+    public boolean isNow() {
+        final var now = Instant.now();
+        final var hasDefault = this.message != null && this.message.containsKey("default");
+        return hasDefault
+                && this.beginning != null
+                && this.expectedEnd != null
+                && this.beginning.isBefore(now)
+                && this.expectedEnd.isAfter(now);
+    }
+
+    public static ListenableFuture<ServiceOutageStatus> fetch(
+            final Context context, final HttpUrl url) {
+        final var appSettings = new AppSettings(context);
+        final var builder = HttpConnectionManager.okHttpClient(context).newBuilder();
+        if (appSettings.isUseTor()) {
+            builder.proxy(HttpConnectionManager.getProxy());
+        }
+
+        var client = builder.build();
+
+        final SettableFuture<ServiceOutageStatus> future = SettableFuture.create();
+
+        var request = new Request.Builder().url(url).build();
+
+        client.newCall(request)
+                .enqueue(
+                        new Callback() {
+                            @Override
+                            public void onFailure(@NonNull Call call, @NonNull IOException e) {
+                                future.setException(e);
+                            }
+
+                            @Override
+                            public void onResponse(@NonNull Call call, @NonNull Response response) {
+                                try (final ResponseBody body = response.body()) {
+                                    if (!response.isSuccessful() || body == null) {
+                                        future.setException(
+                                                new IOException(
+                                                        "unexpected server response ("
+                                                                + response.code()
+                                                                + ")"));
+                                        return;
+                                    }
+                                    var gson =
+                                            new GsonBuilder()
+                                                    .registerTypeAdapter(
+                                                            Instant.class,
+                                                            new InstantDeserializer())
+                                                    .create();
+                                    future.set(
+                                            gson.fromJson(
+                                                    body.string(), ServiceOutageStatus.class));
+                                } catch (final IOException | JsonSyntaxException e) {
+                                    future.setException(e);
+                                }
+                            }
+                        });
+
+        return future;
+    }
+
+    public static boolean isPossibleOutage(final Account.State state) {
+        return SERVICE_OUTAGE_STATE.contains(state);
+    }
+
+    @NonNull
+    @Override
+    public String toString() {
+        return MoreObjects.toStringHelper(this)
+                .add("planned", planned)
+                .add("beginning", beginning)
+                .add("expectedEnd", expectedEnd)
+                .add("message", message)
+                .toString();
+    }
+
+    public boolean isPlanned() {
+        return this.planned;
+    }
+
+    public long getExpectedEnd() {
+        if (this.expectedEnd == null) {
+            return 0L;
+        }
+        return this.expectedEnd.toEpochMilli();
+    }
+
+    public String getMessage() {
+        final var translated = this.message.get(Locale.getDefault().getLanguage());
+        if (Strings.isNullOrEmpty(translated)) {
+            return this.message.get("default");
+        }
+        return translated;
+    }
+
+    private static class InstantDeserializer implements JsonDeserializer<Instant> {
+        @Override
+        public Instant deserialize(
+                JsonElement json, Type typeOfT, JsonDeserializationContext context)
+                throws JsonParseException {
+            return Instant.parse(json.getAsString());
+        }
+    }
+}

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

@@ -7,6 +7,7 @@ import android.util.Pair;
 import com.cheogram.android.BobTransfer;
 import com.cheogram.android.WebxdcUpdate;
 
+import androidx.annotation.NonNull;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 
@@ -66,14 +67,37 @@ import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
 import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
 import eu.siacs.conversations.xmpp.pep.Avatar;
 import im.conversations.android.xmpp.model.Extension;
+import im.conversations.android.xmpp.model.avatar.Metadata;
+import im.conversations.android.xmpp.model.axolotl.DeviceList;
 import im.conversations.android.xmpp.model.axolotl.Encrypted;
+import im.conversations.android.xmpp.model.bookmark.Storage;
+import im.conversations.android.xmpp.model.bookmark2.Conference;
 import im.conversations.android.xmpp.model.carbons.Received;
 import im.conversations.android.xmpp.model.carbons.Sent;
 import im.conversations.android.xmpp.model.correction.Replace;
 import im.conversations.android.xmpp.model.forward.Forwarded;
 import im.conversations.android.xmpp.model.markers.Displayed;
+import im.conversations.android.xmpp.model.nick.Nick;
 import im.conversations.android.xmpp.model.occupant.OccupantId;
+import im.conversations.android.xmpp.model.oob.OutOfBandData;
+import im.conversations.android.xmpp.model.pubsub.Items;
+import im.conversations.android.xmpp.model.pubsub.event.Delete;
+import im.conversations.android.xmpp.model.pubsub.event.Event;
+import im.conversations.android.xmpp.model.pubsub.event.Purge;
 import im.conversations.android.xmpp.model.reactions.Reactions;
+import im.conversations.android.xmpp.model.receipts.Request;
+import im.conversations.android.xmpp.model.unique.StanzaId;
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Consumer;
 
 public class MessageParser extends AbstractParser
         implements Consumer<im.conversations.android.xmpp.model.stanza.Message> {
@@ -89,7 +113,9 @@ public class MessageParser extends AbstractParser
     }
 
     private static String extractStanzaId(
-            Element packet, boolean isTypeGroupChat, Conversation conversation) {
+            final im.conversations.android.xmpp.model.stanza.Message packet,
+            final boolean isTypeGroupChat,
+            final Conversation conversation) {
         final Jid by;
         final boolean safeToExtract;
         if (isTypeGroupChat) {
@@ -100,23 +126,14 @@ public class MessageParser extends AbstractParser
             by = account.getJid().asBareJid();
             safeToExtract = account.getXmppConnection().getFeatures().stanzaIds();
         }
-        return safeToExtract ? extractStanzaId(packet, by) : null;
+        return safeToExtract ? StanzaId.get(packet, by) : null;
     }
 
-    private static String extractStanzaId(Account account, Element packet) {
+    private static String extractStanzaId(
+            final Account account,
+            final im.conversations.android.xmpp.model.stanza.Message packet) {
         final boolean safeToExtract = account.getXmppConnection().getFeatures().stanzaIds();
-        return safeToExtract ? extractStanzaId(packet, account.getJid().asBareJid()) : null;
-    }
-
-    private static String extractStanzaId(Element packet, Jid by) {
-        for (Element child : packet.getChildren()) {
-            if (child.getName().equals("stanza-id")
-                    && Namespace.STANZA_IDS.equals(child.getNamespace())
-                    && by.equals(Jid.Invalid.getNullForInvalid(child.getAttributeAsJid("by")))) {
-                return child.getAttribute("id");
-            }
-        }
-        return null;
+        return safeToExtract ? StanzaId.get(packet, account.getJid().asBareJid()) : null;
     }
 
     private static Jid getTrueCounterpart(Element mucUserElement, Jid fallback) {
@@ -266,11 +283,13 @@ public class MessageParser extends AbstractParser
         return null;
     }
 
-    private void parseEvent(final Element event, final Jid from, final Account account) {
-        final Element items = event.findChild("items");
-        final String node = items == null ? null : items.getAttribute("node");
+    private void parseEvent(final Items items, final Jid from, final Account account) {
+        final String node = items.getNode();
         if ("urn:xmpp:avatar:metadata".equals(node)) {
-            Avatar avatar = Avatar.parseMetadata(items);
+            // TODO support retract
+            final var entry = items.getFirstItemWithId(Metadata.class);
+            final var avatar =
+                    entry == null ? null : Avatar.parseMetadata(entry.getKey(), entry.getValue());
             if (avatar != null) {
                 avatar.owner = from.asBareJid();
                 if (mXmppConnectionService.getFileBackend().isAvatarCached(avatar)) {
@@ -296,24 +315,27 @@ public class MessageParser extends AbstractParser
                 }
             }
         } else if (Namespace.NICK.equals(node)) {
-            final Element i = items.findChild("item");
-            final String nick = i == null ? null : i.findChildContent("nick", Namespace.NICK);
+            final var nickItem = items.getFirstItem(Nick.class);
+            final String nick = nickItem == null ? null : nickItem.getContent();
             if (nick != null) {
                 setNick(account, from, nick);
             }
         } else if (AxolotlService.PEP_DEVICE_LIST.equals(node)) {
-            Element item = items.findChild("item");
-            final Set<Integer> deviceIds = IqParser.deviceIds(item);
-            Log.d(
-                    Config.LOGTAG,
-                    AxolotlService.getLogprefix(account)
-                            + "Received PEP device list "
-                            + deviceIds
-                            + " update from "
-                            + from
-                            + ", processing... ");
-            final AxolotlService axolotlService = account.getAxolotlService();
-            axolotlService.registerDevices(from, deviceIds);
+            final var deviceList = items.getFirstItem(DeviceList.class);
+            if (deviceList != null) {
+                final Set<Integer> deviceIds = deviceList.getDeviceIds();
+                Log.d(
+                        Config.LOGTAG,
+                        AxolotlService.getLogprefix(account)
+                                + "Received PEP device list "
+                                + deviceIds
+                                + " update from "
+                                + from
+                                + ", processing... ");
+                final AxolotlService axolotlService = account.getAxolotlService();
+                axolotlService.registerDevices(from, new HashSet<>(deviceIds));
+            }
+
         } else if (Namespace.BOOKMARKS.equals(node) && account.getJid().asBareJid().equals(from)) {
             final var connection = account.getXmppConnection();
             if (connection.getFeatures().bookmarksConversion()) {
@@ -324,9 +346,7 @@ public class MessageParser extends AbstractParser
                                     + ": received storage:bookmark notification even though we"
                                     + " opted into bookmarks:1");
                 }
-                final Element i = items.findChild("item");
-                final Element storage =
-                        i == null ? null : i.findChild("storage", Namespace.BOOKMARKS);
+                final var storage = items.getFirstItem(Storage.class);
                 final Map<Jid, Bookmark> bookmarks = Bookmark.parseFromStorage(storage, account);
                 mXmppConnectionService.processBookmarksInitial(account, bookmarks, true);
                 Log.d(
@@ -340,17 +360,19 @@ public class MessageParser extends AbstractParser
                                 + " not detected");
             }
         } else if (Namespace.BOOKMARKS2.equals(node) && account.getJid().asBareJid().equals(from)) {
-            final Element item = items.findChild("item");
-            final Element retract = items.findChild("retract");
-            if (item != null) {
-                final Bookmark bookmark = Bookmark.parseFromItem(item, account);
-                if (bookmark != null) {
-                    account.putBookmark(bookmark);
-                    mXmppConnectionService.processModifiedBookmark(bookmark);
-                    mXmppConnectionService.updateConversationUi();
+            final var retractions = items.getRetractions();
+            ;
+            for (final var item : items.getItemMap(Conference.class).entrySet()) {
+                final Bookmark bookmark =
+                        Bookmark.parseFromItem(item.getKey(), item.getValue(), account);
+                if (bookmark == null) {
+                    continue;
                 }
+                account.putBookmark(bookmark);
+                mXmppConnectionService.processModifiedBookmark(bookmark);
+                mXmppConnectionService.updateConversationUi();
             }
-            if (retract != null) {
+            for (final var retract : retractions) {
                 final Jid id = Jid.Invalid.getNullForInvalid(retract.getAttributeAsJid("id"));
                 if (id != null) {
                     account.removeBookmark(id);
@@ -364,35 +386,37 @@ public class MessageParser extends AbstractParser
         } else if (Config.MESSAGE_DISPLAYED_SYNCHRONIZATION
                 && Namespace.MDS_DISPLAYED.equals(node)
                 && account.getJid().asBareJid().equals(from)) {
-            final Element item = items.findChild("item");
-            mXmppConnectionService.processMdsItem(account, item);
-        } else {
-            Log.d(
-                    Config.LOGTAG,
-                    account.getJid().asBareJid()
-                            + " received pubsub notification for node="
-                            + node);
+            for (final var item :
+                    items.getItemMap(im.conversations.android.xmpp.model.mds.Displayed.class)
+                            .entrySet()) {
+                mXmppConnectionService.processMdsItem(account, item);
+            }
         }
     }
 
-    private void parseDeleteEvent(final Element event, final Jid from, final Account account) {
-        final Element delete = event.findChild("delete");
-        final String node = delete == null ? null : delete.getAttribute("node");
+    private void parseDeleteEvent(final Delete delete, final Jid from, final Account account) {
+        final String node = delete.getNode();
         if (Namespace.NICK.equals(node)) {
-            Log.d(Config.LOGTAG, "parsing nick delete event from " + from);
             setNick(account, from, null);
         } else if (Namespace.BOOKMARKS2.equals(node) && account.getJid().asBareJid().equals(from)) {
             Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": deleted bookmarks node");
             deleteAllBookmarks(account);
-        } else if (Namespace.AVATAR_METADATA.equals(node)
-                && account.getJid().asBareJid().equals(from)) {
-            Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": deleted avatar metadata node");
+        } else if (Namespace.AVATAR_METADATA.equals(node)) {
+            final boolean isAccount = account.getJid().asBareJid().equals(from);
+            if (isAccount) {
+                account.setAvatar(null);
+                mXmppConnectionService.databaseBackend.updateAccount(account);
+                mXmppConnectionService.getAvatarService().clear(account);
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid() + ": deleted avatar metadata node");
+            }
         }
     }
 
-    private void parsePurgeEvent(final Element event, final Jid from, final Account account) {
-        final Element purge = event.findChild("purge");
-        final String node = purge == null ? null : purge.getAttribute("node");
+    private void parsePurgeEvent(
+            @NonNull final Purge purge, final Jid from, final Account account) {
+        final String node = purge.getNode();
         if (Namespace.BOOKMARKS2.equals(node) && account.getJid().asBareJid().equals(from)) {
             Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": purged bookmarks");
             deleteAllBookmarks(account);
@@ -570,7 +594,9 @@ public class MessageParser extends AbstractParser
         final boolean isTypeGroupChat =
                 packet.getType()
                         == im.conversations.android.xmpp.model.stanza.Message.Type.GROUPCHAT;
-        final String pgpEncrypted = packet.findChildContent("x", "jabber:x:encrypted");
+        final var encrypted =
+                packet.getOnlyExtension(im.conversations.android.xmpp.model.pgp.Encrypted.class);
+        final String pgpEncrypted = encrypted == null ? null : encrypted.getContent();
 
         Element replaceElement = packet.findChild("replace", "urn:xmpp:message-correct:0");
         Set<Message.FileParams> attachments = new LinkedHashSet<>();
@@ -607,6 +633,8 @@ public class MessageParser extends AbstractParser
 
         final var reactions = packet.getExtension(Reactions.class);
 
+        final var oob = packet.getExtension(OutOfBandData.class);
+        final String oobUrl = oob != null ? oob.getURL() : null;
         final var axolotlEncrypted = packet.getOnlyExtension(Encrypted.class);
         int status;
         final Jid counterpart;
@@ -647,7 +675,8 @@ public class MessageParser extends AbstractParser
             final Jid mucTrueCounterPartByPresence;
             if (conversation != null) {
                 final var mucOptions = conversation.getMucOptions();
-                occupant = mucOptions.occupantId() ? packet.getExtension(OccupantId.class) : null;
+                occupant =
+                        mucOptions.occupantId() ? packet.getOnlyExtension(OccupantId.class) : null;
                 final var user =
                         occupant == null ? null : mucOptions.findUserByOccupantId(occupant.getId(), from);
                 mucTrueCounterPartByPresence = user == null ? null : user.getRealJid();
@@ -666,7 +695,8 @@ public class MessageParser extends AbstractParser
                     mXmppConnectionService.find(account, from.asBareJid());
             if (conversation != null) {
                 final var mucOptions = conversation.getMucOptions();
-                occupant = mucOptions.occupantId() ? packet.getExtension(OccupantId.class) : null;
+                occupant =
+                        mucOptions.occupantId() ? packet.getOnlyExtension(OccupantId.class) : null;
             } else {
                 occupant = null;
             }
@@ -1331,9 +1361,7 @@ public class MessageParser extends AbstractParser
                         && !packet.hasChild("thread")) { // We already know it has no body per above
                     if (conversation != null && conversation.getMode() == Conversation.MODE_MULTI) {
                         conversation.setHasMessagesLeftOnServer(conversation.countMessages() > 0);
-                        final LocalizedContent subject =
-                                packet.findInternationalizedChildContentInDefaultNamespace(
-                                        "subject");
+                        final LocalizedContent subject = packet.getSubject();
                         if (subject != null
                                 && conversation.getMucOptions().setSubject(subject.content)) {
                             mXmppConnectionService.updateConversation(conversation);
@@ -1575,15 +1603,22 @@ public class MessageParser extends AbstractParser
                     packet);
         }
 
-        final Element event =
-                original.findChild("event", "http://jabber.org/protocol/pubsub#event");
+        final var event = original.getExtension(Event.class);
         if (event != null && Jid.Invalid.hasValidFrom(original) && original.getFrom().isBareJid()) {
-            if (event.hasChild("items")) {
-                parseEvent(event, original.getFrom(), account);
-            } else if (event.hasChild("delete")) {
-                parseDeleteEvent(event, original.getFrom(), account);
-            } else if (event.hasChild("purge")) {
-                parsePurgeEvent(event, original.getFrom(), account);
+            final var action = event.getAction();
+            final var node = action == null ? null : action.getNode();
+            if (node == null) {
+                Log.d(
+                        Config.LOGTAG,
+                        account.getJid().asBareJid()
+                                + ": no node found in PubSub event from "
+                                + original.getFrom());
+            } else if (action instanceof Items items) {
+                parseEvent(items, original.getFrom(), account);
+            } else if (action instanceof Purge purge) {
+                parsePurgeEvent(purge, original.getFrom(), account);
+            } else if (action instanceof Delete delete) {
+                parseDeleteEvent(delete, from, account);
             }
         }
 
@@ -1884,27 +1919,14 @@ public class MessageParser extends AbstractParser
             final Account account,
             final im.conversations.android.xmpp.model.stanza.Message packet,
             final String remoteMsgId,
-            MessageArchiveService.Query query) {
-        final boolean markable = packet.hasChild("markable", "urn:xmpp:chat-markers:0");
-        final boolean request = packet.hasChild("request", "urn:xmpp:receipts");
+            final MessageArchiveService.Query query) {
+        final var request = packet.hasExtension(Request.class);
         if (query == null) {
-            final ArrayList<String> receiptsNamespaces = new ArrayList<>();
-            if (markable) {
-                receiptsNamespaces.add("urn:xmpp:chat-markers:0");
-            }
             if (request) {
-                receiptsNamespaces.add("urn:xmpp:receipts");
-            }
-            if (receiptsNamespaces.size() > 0) {
                 final var receipt =
                         mXmppConnectionService
                                 .getMessageGenerator()
-                                .received(
-                                        account,
-                                        packet.getFrom(),
-                                        remoteMsgId,
-                                        receiptsNamespaces,
-                                        packet.getType());
+                                .received(packet.getFrom(), remoteMsgId, packet.getType());
                 mXmppConnectionService.sendMessagePacket(account, receipt);
             }
         } else if (query.isCatchup()) {

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

@@ -83,8 +83,8 @@ public class PresenceParser extends AbstractParser
                     Element item = x.findChild("item");
                     if (item != null && !from.isBareJid()) {
                         mucOptions.setError(MucOptions.Error.NONE);
-                        MucOptions.User user = parseItem(conversation, item, from, occupantIdEl, nick == null ? null : nick.getContent(), hats);
-                        final var occupant = packet.getExtension(OccupantId.class);
+                        final MucOptions.User user = parseItem(conversation, item, from, occupantIdEl, nick == null ? null : nick.getContent(), hats);
+                        final var occupant = packet.getOnlyExtension(OccupantId.class);
                         final String occupantId =
                                 mucOptions.occupantId() && occupant != null
                                         ? occupant.getId()

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

@@ -2,20 +2,17 @@ package eu.siacs.conversations.services;
 
 import android.content.Context;
 import android.net.ConnectivityManager;
+import static eu.siacs.conversations.entities.Transferable.VALID_CRYPTO_EXTENSIONS;
+
 import android.os.PowerManager;
 import android.os.SystemClock;
 import android.util.Log;
-
+import androidx.annotation.Nullable;
 import androidx.core.content.ContextCompat;
-
-import org.bouncycastle.crypto.engines.AESEngine;
-import org.bouncycastle.crypto.io.CipherInputStream;
-import org.bouncycastle.crypto.io.CipherOutputStream;
-import org.bouncycastle.crypto.modes.AEADBlockCipher;
-import org.bouncycastle.crypto.modes.GCMBlockCipher;
-import org.bouncycastle.crypto.params.AEADParameters;
-import org.bouncycastle.crypto.params.KeyParameter;
-
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.entities.DownloadableFile;
+import eu.siacs.conversations.utils.Compatibility;
 import java.io.FileInputStream;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
@@ -23,20 +20,18 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.util.concurrent.atomic.AtomicLong;
-
-import javax.annotation.Nullable;
-
-import eu.siacs.conversations.Config;
-import eu.siacs.conversations.R;
-import eu.siacs.conversations.entities.DownloadableFile;
-import eu.siacs.conversations.utils.Compatibility;
 import okhttp3.MediaType;
 import okhttp3.RequestBody;
 import okio.BufferedSink;
 import okio.Okio;
 import okio.Source;
-
-import static eu.siacs.conversations.entities.Transferable.VALID_CRYPTO_EXTENSIONS;
+import org.bouncycastle.crypto.engines.AESEngine;
+import org.bouncycastle.crypto.io.CipherInputStream;
+import org.bouncycastle.crypto.io.CipherOutputStream;
+import org.bouncycastle.crypto.modes.AEADBlockCipher;
+import org.bouncycastle.crypto.modes.GCMBlockCipher;
+import org.bouncycastle.crypto.params.AEADParameters;
+import org.bouncycastle.crypto.params.KeyParameter;
 
 public class AbstractConnectionManager {
 
@@ -51,18 +46,19 @@ public class AbstractConnectionManager {
     public static InputStream upgrade(DownloadableFile file, InputStream is) {
         if (file.getKey() != null && file.getIv() != null) {
             AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
-            cipher.init(true, new AEADParameters(new KeyParameter(file.getKey()), 128, file.getIv()));
+            cipher.init(
+                    true, new AEADParameters(new KeyParameter(file.getKey()), 128, file.getIv()));
             return new CipherInputStream(is, cipher);
         } else {
             return is;
         }
     }
 
+    // For progress tracking see:
+    // https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/Progress.java
 
-    //For progress tracking see:
-    //https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/Progress.java
-
-    public static RequestBody requestBody(final DownloadableFile file, final ProgressListener progressListener) {
+    public static RequestBody requestBody(
+            final DownloadableFile file, final ProgressListener progressListener) {
         return new RequestBody() {
 
             @Override
@@ -95,7 +91,8 @@ public class AbstractConnectionManager {
         void onProgress(long progress);
     }
 
-    public static OutputStream createOutputStream(DownloadableFile file, boolean append, boolean decrypt) {
+    public static OutputStream createOutputStream(
+            DownloadableFile file, boolean append, boolean decrypt) {
         FileOutputStream os;
         try {
             os = new FileOutputStream(file, append);
@@ -108,7 +105,8 @@ public class AbstractConnectionManager {
         }
         try {
             AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
-            cipher.init(false, new AEADParameters(new KeyParameter(file.getKey()), 128, file.getIv()));
+            cipher.init(
+                    false, new AEADParameters(new KeyParameter(file.getKey()), 128, file.getIv()));
             return new CipherOutputStream(os, cipher);
         } catch (Exception e) {
             Log.d(Config.LOGTAG, "unable to create cipher output stream", e);
@@ -136,7 +134,9 @@ public class AbstractConnectionManager {
 
     public void updateConversationUi(boolean force) {
         synchronized (LAST_UI_UPDATE_CALL) {
-            if (force || SystemClock.elapsedRealtime() - LAST_UI_UPDATE_CALL.get() >= UI_REFRESH_THRESHOLD) {
+            if (force
+                    || SystemClock.elapsedRealtime() - LAST_UI_UPDATE_CALL.get()
+                            >= UI_REFRESH_THRESHOLD) {
                 LAST_UI_UPDATE_CALL.set(SystemClock.elapsedRealtime());
                 mXmppConnectionService.updateConversationUi();
             }
@@ -144,7 +144,8 @@ public class AbstractConnectionManager {
     }
 
     public PowerManager.WakeLock createWakeLock(final String name) {
-        final PowerManager powerManager = ContextCompat.getSystemService(mXmppConnectionService, PowerManager.class);
+        final PowerManager powerManager =
+                ContextCompat.getSystemService(mXmppConnectionService, PowerManager.class);
         return powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, name);
     }
 
@@ -166,7 +167,7 @@ public class AbstractConnectionManager {
         }
 
         public static Extension of(String path) {
-            //TODO accept List<String> pathSegments
+            // TODO accept List<String> pathSegments
             final int pos = path.lastIndexOf('/');
             final String filename = path.substring(pos + 1).toLowerCase();
             final String[] parts = filename.split("\\.");

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

@@ -115,6 +115,9 @@ import java.util.function.Consumer;
 
 import io.ipfs.cid.Cid;
 
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.MoreExecutors;
 import eu.siacs.conversations.AppSettings;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
@@ -145,6 +148,7 @@ import eu.siacs.conversations.generator.IqGenerator;
 import eu.siacs.conversations.generator.MessageGenerator;
 import eu.siacs.conversations.generator.PresenceGenerator;
 import eu.siacs.conversations.http.HttpConnectionManager;
+import eu.siacs.conversations.http.ServiceOutageStatus;
 import eu.siacs.conversations.parser.AbstractParser;
 import eu.siacs.conversations.parser.IqParser;
 import eu.siacs.conversations.persistance.DatabaseBackend;
@@ -202,7 +206,12 @@ import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
 import eu.siacs.conversations.xmpp.mam.MamReference;
 import eu.siacs.conversations.xmpp.pep.Avatar;
 import eu.siacs.conversations.xmpp.pep.PublishOptions;
+import im.conversations.android.xmpp.model.avatar.Metadata;
+import im.conversations.android.xmpp.model.bookmark.Storage;
+import im.conversations.android.xmpp.model.mds.Displayed;
+import im.conversations.android.xmpp.model.pubsub.PubSub;
 import im.conversations.android.xmpp.model.stanza.Iq;
+import im.conversations.android.xmpp.model.storage.PrivateStorage;
 import java.io.File;
 import java.security.Security;
 import java.security.cert.CertificateException;
@@ -230,6 +239,7 @@ import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Consumer;
 import me.leolin.shortcutbadger.ShortcutBadger;
+import okhttp3.HttpUrl;
 import org.conscrypt.Conscrypt;
 import org.jxmpp.stringprep.libidn.LibIdnXmppStringprep;
 import org.openintents.openpgp.IOpenPgpService2;
@@ -431,6 +441,10 @@ public class XmppConnectionService extends Service {
 
                 @Override
                 public void onStatusChanged(final Account account) {
+                    final var status = account.getStatus();
+                    if (ServiceOutageStatus.isPossibleOutage(status)) {
+                        fetchServiceOutageStatus(account);
+                    }
                     XmppConnection connection = account.getXmppConnection();
                     updateAccountUi();
 
@@ -562,6 +576,7 @@ public class XmppConnectionService extends Service {
                     getNotificationService().updateErrorNotification();
                 }
             };
+
     private OpenPgpServiceConnection pgpServiceConnection;
     private PgpEngine mPgpEngine = null;
     private WakeLock wakeLock;
@@ -1397,6 +1412,34 @@ public class XmppConnectionService extends Service {
         }
     }
 
+    private void fetchServiceOutageStatus(final Account account) {
+        final var sosUrl = account.getKey(Account.KEY_SOS_URL);
+        if (Strings.isNullOrEmpty(sosUrl)) {
+            return;
+        }
+        final var url = HttpUrl.parse(sosUrl);
+        if (url == null) {
+            return;
+        }
+        Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": fetching service outage " + url);
+        Futures.addCallback(
+                ServiceOutageStatus.fetch(getApplicationContext(), url),
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(final ServiceOutageStatus sos) {
+                        Log.d(Config.LOGTAG, "fetched " + sos);
+                        account.setServiceOutageStatus(sos);
+                        updateAccountUi();
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull Throwable throwable) {
+                        Log.d(Config.LOGTAG, "error fetching sos", throwable);
+                    }
+                },
+                MoreExecutors.directExecutor());
+    }
+
     public boolean processUnifiedPushMessage(
             final Account account, final Jid transport, final Element push) {
         return unifiedPushBroker.processPushMessage(account, transport, push);
@@ -2584,14 +2627,17 @@ public class XmppConnectionService extends Service {
 
     public void fetchBookmarks(final Account account) {
         final Iq iqPacket = new Iq(Iq.Type.GET);
-        final Element query = iqPacket.query("jabber:iq:private");
-        query.addChild("storage", Namespace.BOOKMARKS);
+        iqPacket.addExtension(new PrivateStorage()).addExtension(new Storage());
         final Consumer<Iq> callback =
                 (response) -> {
                     if (response.getType() == Iq.Type.RESULT) {
-                        final Element query1 = response.query();
-                        final Element storage = query1.findChild("storage", "storage:bookmarks");
-                        Map<Jid, Bookmark> bookmarks = Bookmark.parseFromStorage(storage, account);
+                        final var privateStorage = response.getExtension(PrivateStorage.class);
+                        if (privateStorage == null) {
+                            return;
+                        }
+                        final var bookmarkStorage = privateStorage.getExtension(Storage.class);
+                        Map<Jid, Bookmark> bookmarks =
+                                Bookmark.parseFromStorage(bookmarkStorage, account);
                         processBookmarksInitial(account, bookmarks, false);
                     } else {
                         Log.d(
@@ -2609,7 +2655,7 @@ public class XmppConnectionService extends Service {
                 retrieve,
                 (response) -> {
                     if (response.getType() == Iq.Type.RESULT) {
-                        final Element pubsub = response.findChild("pubsub", Namespace.PUBSUB);
+                        final var pubsub = response.getExtension(PubSub.class);
                         final Map<Jid, Bookmark> bookmarks =
                                 Bookmark.parseFromPubSub(pubsub, account);
                         processBookmarksInitial(account, bookmarks, true);
@@ -2627,30 +2673,34 @@ public class XmppConnectionService extends Service {
                     if (response.getType() != Iq.Type.RESULT) {
                         return;
                     }
-                    final var pubSub = response.findChild("pubsub", Namespace.PUBSUB);
-                    final Element items = pubSub == null ? null : pubSub.findChild("items");
-                    if (items == null
-                            || !Namespace.MDS_DISPLAYED.equals(items.getAttribute("node"))) {
+                    final var pubsub = response.getExtension(PubSub.class);
+                    if (pubsub == null) {
                         return;
                     }
-                    for (final Element child : items.getChildren()) {
-                        if ("item".equals(child.getName())) {
-                            processMdsItem(account, child);
+                    final var items = pubsub.getItems();
+                    if (items == null) {
+                        return;
+                    }
+                    if (Namespace.MDS_DISPLAYED.equals(items.getNode())) {
+                        for (final var item :
+                                items.getItemMap(
+                                                im.conversations.android.xmpp.model.mds.Displayed
+                                                        .class)
+                                        .entrySet()) {
+                            processMdsItem(account, item);
                         }
                     }
                 });
     }
 
-    public void processMdsItem(final Account account, final Element item) {
-        final Jid jid =
-                item == null ? null : Jid.Invalid.getNullForInvalid(item.getAttributeAsJid("id"));
+    public void processMdsItem(final Account account, final Map.Entry<String, Displayed> item) {
+        final Jid jid = Jid.Invalid.getNullForInvalid(Jid.ofOrInvalid(item.getKey()));
         if (jid == null) {
             return;
         }
-        final Element displayed = item.findChild("displayed", Namespace.MDS_DISPLAYED);
-        final Element stanzaId =
-                displayed == null ? null : displayed.findChild("stanza-id", Namespace.STANZA_IDS);
-        final String id = stanzaId == null ? null : stanzaId.getAttribute("id");
+        final var displayed = item.getValue();
+        final var stanzaId = displayed.getStanzaId();
+        final String id = stanzaId == null ? null : stanzaId.getId();
         final Conversation conversation = find(account, jid);
         if (id != null && conversation != null) {
             conversation.setDisplayState(id);
@@ -5556,16 +5606,20 @@ public class XmppConnectionService extends Service {
                 packet,
                 new Consumer<Iq>() {
 
-                    private Avatar parseAvatar(Iq packet) {
-                        Element pubsub =
-                                packet.findChild("pubsub", "http://jabber.org/protocol/pubsub");
-                        if (pubsub != null) {
-                            Element items = pubsub.findChild("items");
-                            if (items != null) {
-                                return Avatar.parseMetadata(items);
-                            }
+                    private Avatar parseAvatar(final Iq packet) {
+                        final var pubsub = packet.getExtension(PubSub.class);
+                        if (pubsub == null) {
+                            return null;
+                        }
+                        final var items = pubsub.getItems();
+                        if (items == null) {
+                            return null;
+                        }
+                        final var item = items.getFirstItemWithId(Metadata.class);
+                        if (item == null) {
+                            return null;
                         }
-                        return null;
+                        return Avatar.parseMetadata(item.getKey(), item.getValue());
                     }
 
                     private boolean errorIsItemNotFound(Iq packet) {
@@ -5805,30 +5859,39 @@ public class XmppConnectionService extends Service {
                 account,
                 packet,
                 response -> {
-                    if (response.getType() == Iq.Type.RESULT) {
-                        Element pubsub =
-                                response.findChild("pubsub", "http://jabber.org/protocol/pubsub");
-                        if (pubsub != null) {
-                            Element items = pubsub.findChild("items");
-                            if (items != null) {
-                                Avatar avatar = Avatar.parseMetadata(items);
-                                if (avatar != null) {
-                                    avatar.owner = account.getJid().asBareJid();
-                                    if (fileBackend.isAvatarCached(avatar)) {
-                                        if (account.setAvatar(avatar.getFilename())) {
-                                            databaseBackend.updateAccount(account);
-                                        }
-                                        getAvatarService().clear(account);
-                                        callback.success(avatar);
-                                    } else {
-                                        fetchAvatarPep(account, avatar, callback);
-                                    }
-                                    return;
-                                }
-                            }
+                    if (response.getType() != Iq.Type.RESULT) {
+                        callback.error(0, null);
+                    }
+                    final var pubsub = packet.getExtension(PubSub.class);
+                    if (pubsub == null) {
+                        callback.error(0, null);
+                        return;
+                    }
+                    final var items = pubsub.getItems();
+                    if (items == null) {
+                        callback.error(0, null);
+                        return;
+                    }
+                    final var item = items.getFirstItemWithId(Metadata.class);
+                    if (item == null) {
+                        callback.error(0, null);
+                        return;
+                    }
+                    final var avatar = Avatar.parseMetadata(item.getKey(), item.getValue());
+                    if (avatar == null) {
+                        callback.error(0, null);
+                        return;
+                    }
+                    avatar.owner = account.getJid().asBareJid();
+                    if (fileBackend.isAvatarCached(avatar)) {
+                        if (account.setAvatar(avatar.getFilename())) {
+                            databaseBackend.updateAccount(account);
                         }
+                        getAvatarService().clear(account);
+                        callback.success(avatar);
+                    } else {
+                        fetchAvatarPep(account, avatar, callback);
                     }
-                    callback.error(0, null);
                 });
     }
 

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

@@ -3,20 +3,27 @@ package eu.siacs.conversations.ui;
 import static eu.siacs.conversations.ui.XmppActivity.configureActionBar;
 
 import android.os.Bundle;
-
+import android.text.SpannableString;
+import android.text.method.LinkMovementMethod;
 import androidx.databinding.DataBindingUtil;
-
+import de.gultsch.common.Linkify;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.ActivityAboutBinding;
+import eu.siacs.conversations.ui.text.FixedURLSpan;
 
 public class AboutActivity extends ActionBarActivity {
 
-
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
 
-        final ActivityAboutBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_about);
+        final ActivityAboutBinding binding =
+                DataBindingUtil.setContentView(this, R.layout.activity_about);
+        final var text = new SpannableString(getString(R.string.pref_about_message));
+        Linkify.addLinks(text);
+        FixedURLSpan.fix(text);
+        binding.about.setText(text);
+        binding.about.setMovementMethod(LinkMovementMethod.getInstance());
         Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
 
         setSupportActionBar(binding.toolbar);

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

@@ -14,7 +14,7 @@ import android.os.Build;
 import android.os.Bundle;
 import android.preference.PreferenceManager;
 import android.text.Editable;
-import android.text.SpannableStringBuilder;
+import android.text.SpannableString;
 import android.text.TextWatcher;
 import android.text.method.LinkMovementMethod;
 import android.view.LayoutInflater;
@@ -678,7 +678,7 @@ public class ConferenceDetailsActivity extends XmppActivity
             this.binding.mucTitle.setVisibility(View.GONE);
         }
         if (printableValue(subject)) {
-            SpannableStringBuilder spannable = new SpannableStringBuilder(subject);
+            final var spannable = new SpannableString(subject);
             StylingHelper.format(spannable, this.binding.mucSubject.getCurrentTextColor());
             Linkify.addLinks(spannable);
             FixedURLSpan.fix(spannable);
@@ -689,7 +689,6 @@ public class ConferenceDetailsActivity extends XmppActivity
                                     .TextAppearance_Material3_BodyMedium
                             : com.google.android.material.R.style
                                     .TextAppearance_Material3_BodyLarge);
-            this.binding.mucSubject.setAutoLinkMask(0);
             this.binding.mucSubject.setVisibility(View.VISIBLE);
             this.binding.mucSubject.setMovementMethod(LinkMovementMethod.getInstance());
         } else {

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

@@ -2,18 +2,15 @@ package eu.siacs.conversations.ui;
 
 import android.content.Intent;
 import android.os.Bundle;
-
 import androidx.annotation.Nullable;
 import androidx.appcompat.app.AppCompatActivity;
 
-import eu.siacs.conversations.ui.util.SettingsUtils;
-
 public class ConversationActivity extends AppCompatActivity {
 
-	@Override
-	protected void onCreate(@Nullable Bundle savedInstanceState) {
-		super.onCreate(savedInstanceState);
-		startActivity(new Intent(this, ConversationsActivity.class));
-		finish();
-	}
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        startActivity(new Intent(this, ConversationsActivity.class));
+        finish();
+    }
 }

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

@@ -19,8 +19,11 @@ import android.provider.Settings;
 import android.security.KeyChain;
 import android.security.KeyChainAliasCallback;
 import android.text.Editable;
+import android.text.SpannableString;
 import android.text.TextUtils;
 import android.text.TextWatcher;
+import android.text.format.DateUtils;
+import android.text.method.LinkMovementMethod;
 import android.util.Log;
 import android.view.Menu;
 import android.view.MenuItem;
@@ -55,6 +58,7 @@ import java.util.List;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicInteger;
 
+import de.gultsch.common.Linkify;
 import eu.siacs.conversations.AppSettings;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
@@ -74,6 +78,7 @@ import eu.siacs.conversations.services.XmppConnectionService.OnCaptchaRequested;
 import eu.siacs.conversations.ui.TimePreference;
 import eu.siacs.conversations.ui.adapter.KnownHostsAdapter;
 import eu.siacs.conversations.ui.adapter.PresenceTemplateAdapter;
+import eu.siacs.conversations.ui.text.FixedURLSpan;
 import eu.siacs.conversations.ui.util.AvatarWorkerTask;
 import eu.siacs.conversations.ui.util.MenuDoubleTabUtil;
 import eu.siacs.conversations.ui.util.PendingItem;
@@ -529,7 +534,7 @@ public class EditAccountActivity extends OmemoActivity
 
         final List<Account> accounts =
                 xmppConnectionService == null ? null : xmppConnectionService.getAccounts();
-        if (accounts != null && accounts.size() == 0 && Config.MAGIC_CREATE_DOMAIN != null) {
+        if (accounts != null && accounts.isEmpty() && Config.MAGIC_CREATE_DOMAIN != null) {
             Intent intent =
                     SignupUtils.getSignUpIntent(this, mForceRegister != null && mForceRegister);
             StartConversationActivity.addInviteUri(intent, getIntent());
@@ -967,9 +972,9 @@ public class EditAccountActivity extends OmemoActivity
     }
 
     @Override
-    public void onNewIntent(final Intent intent) {
+    public void onNewIntent(@NonNull final Intent intent) {
         super.onNewIntent(intent);
-        if (intent != null && intent.getData() != null) {
+        if (intent.getData() != null) {
             final XmppUri uri = new XmppUri(intent.getData());
             if (xmppConnectionServiceBound) {
                 processFingerprintVerification(uri, false);
@@ -1540,6 +1545,7 @@ public class EditAccountActivity extends OmemoActivity
                 this.binding.verificationMessage.setText("Not DNSSEC Verified");
                 this.binding.verificationIndicator.setImageResource(R.drawable.shield_question);
             }
+            this.binding.serviceOutage.setVisibility(View.GONE);
         } else {
             final TextInputLayout errorLayout;
             final var status = this.mAccount.getStatus();
@@ -1569,6 +1575,44 @@ public class EditAccountActivity extends OmemoActivity
             this.binding.stats.setVisibility(View.GONE);
             this.binding.otherDeviceKeysCard.setVisibility(View.GONE);
             this.binding.verificationBox.setVisibility(View.GONE);
+            final var sos = mAccount.getServiceOutageStatus();
+            if (mAccount.isServiceOutage() && sos != null) {
+                this.binding.serviceOutage.setVisibility(View.VISIBLE);
+                if (sos.isPlanned()) {
+                    this.binding.sosTitle.setText(R.string.account_status_service_outage_scheduled);
+                } else {
+                    this.binding.sosTitle.setText(R.string.account_status_service_outage_known);
+                }
+                final var sosMessage = sos.getMessage();
+                if (Strings.isNullOrEmpty(sosMessage)) {
+                    this.binding.sosMessage.setVisibility(View.GONE);
+                } else {
+                    final var sosMessageSpannable = new SpannableString(sosMessage);
+                    Linkify.addLinks(sosMessageSpannable);
+                    FixedURLSpan.fix(sosMessageSpannable);
+                    this.binding.sosMessage.setText(sosMessageSpannable);
+                    this.binding.sosMessage.setVisibility(View.VISIBLE);
+                    this.binding.sosMessage.setMovementMethod(LinkMovementMethod.getInstance());
+                }
+                final var expectedEnd = sos.getExpectedEnd();
+                if (expectedEnd <= 0) {
+                    this.binding.sosScheduledEnd.setVisibility(View.GONE);
+                } else {
+                    this.binding.sosScheduledEnd.setVisibility(View.VISIBLE);
+                    this.binding.sosScheduledEnd.setText(
+                            getString(
+                                    R.string.sos_scheduled_return,
+                                    DateUtils.formatDateTime(
+                                            this,
+                                            expectedEnd,
+                                            DateUtils.FORMAT_SHOW_TIME
+                                                    | DateUtils.FORMAT_NUMERIC_DATE
+                                                    | DateUtils.FORMAT_SHOW_YEAR
+                                                    | DateUtils.FORMAT_SHOW_DATE)));
+                }
+            } else {
+                this.binding.serviceOutage.setVisibility(View.GONE);
+            }
         }
     }
 

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

@@ -52,11 +52,25 @@ public class AccountAdapter extends ArrayAdapter<Account> {
         } else {
             viewHolder = (ViewHolder) view.getTag();
         }
+        if (account == null) {
+            return view;
+        }
         viewHolder.binding.accountJid.setText(account.getJid().asBareJid().toString());
         AvatarWorkerTask.loadAvatar(account, viewHolder.binding.accountImage, R.dimen.avatar);
-        viewHolder.binding.accountStatus.setText(
-                getContext().getString(account.getStatus().getReadableId()));
-        switch (account.getStatus()) {
+        final var status = account.getStatus();
+        if (account.isServiceOutage()) {
+            final var sos = account.getServiceOutageStatus();
+            if (sos != null && sos.isPlanned()) {
+                viewHolder.binding.accountStatus.setText(
+                        R.string.account_status_service_outage_scheduled);
+            } else {
+                viewHolder.binding.accountStatus.setText(
+                        R.string.account_status_service_outage_known);
+            }
+        } else {
+            viewHolder.binding.accountStatus.setText(status.getReadableId());
+        }
+        switch (status) {
             case ONLINE:
                 viewHolder.binding.accountStatus.setTextColor(
                         MaterialColors.getColor(

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

@@ -34,7 +34,7 @@ import android.content.ActivityNotFoundException;
 import android.content.Context;
 import android.content.Intent;
 import android.net.Uri;
-import android.text.Editable;
+import android.text.Spannable;
 import android.text.Spanned;
 import android.text.style.URLSpan;
 import android.util.Log;
@@ -60,6 +60,19 @@ public class FixedURLSpan extends URLSpan {
 
 	protected final Account account;
 
+	public static void fix(final Spannable spannable) {
+		for (final URLSpan urlspan : spannable.getSpans(0, spannable.length() - 1, URLSpan.class)) {
+			final int start = spannable.getSpanStart(urlspan);
+			final int end = spannable.getSpanEnd(urlspan);
+			spannable.removeSpan(urlspan);
+			spannable.setSpan(
+					new FixedURLSpan(urlspan.getURL()),
+					start,
+					end,
+					Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+		}
+	}
+
 	public FixedURLSpan(final String url) {
 		this(url, null);
 	}
@@ -69,19 +82,6 @@ public class FixedURLSpan extends URLSpan {
 		this.account = account;
 	}
 
-	public static void fix(final Editable editable) {
-		for (final URLSpan urlspan : editable.getSpans(0, editable.length() - 1, URLSpan.class)) {
-			final int start = editable.getSpanStart(urlspan);
-			final int end = editable.getSpanEnd(urlspan);
-			editable.removeSpan(urlspan);
-			editable.setSpan(
-					new FixedURLSpan(urlspan.getURL()),
-					start,
-					end,
-					Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
-		}
-	}
-
 	@Override
 	public void onClick(View widget) {
 		final Uri uri = Uri.parse(getURL());

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

@@ -83,7 +83,7 @@ public class StylingHelper {
         }
     }
 
-    public static void format(final Editable editable, int start, int end, @ColorInt int textColor, final boolean composing) {
+    public static void format(final Spannable editable, int start, int end, @ColorInt int textColor, final boolean composing) {
         for (ImStyleParser.Style style : ImStyleParser.parse(editable, start, end)) {
             final int keywordLength = style.getKeyword().length();
             int keywordLengthStart = keywordLength;
@@ -109,13 +109,12 @@ public class StylingHelper {
         }
     }
 
-    public static void format(final Editable editable, @ColorInt int textColor) {
+    public static void format(final Spannable editable, @ColorInt int textColor) {
         format(editable, textColor, false);
     }
 
-    public static void format(final Editable editable, @ColorInt int textColor, final boolean composing) {
-        int end = 0;
-        format(editable, end, editable.length() - 1, textColor, composing);
+    public static void format(final Spannable editable, @ColorInt int textColor, final boolean composing) {
+        format(editable, 0, editable.length() - 1, textColor, composing);
     }
 
     public static void highlight(
@@ -150,7 +149,7 @@ public class StylingHelper {
     }
 
     private static void highlight(
-            final TextView view, final Editable editable, final String needle) {
+            final TextView view, final Spannable editable, final String needle) {
         final int length = needle.length();
         String string = editable.toString();
         int start = indexOfIgnoreCase(string, needle, 0);
@@ -225,7 +224,7 @@ public class StylingHelper {
         };
     }
 
-    private static void makeKeywordOpaque(final Editable editable, int start, int end, @ColorInt int fallbackTextColor, final boolean composing) {
+    private static void makeKeywordOpaque(final Spannable editable, int start, int end, @ColorInt int fallbackTextColor, final boolean composing) {
         QuoteSpan[] quoteSpans = editable.getSpans(start, end, QuoteSpan.class);
         @ColorInt int textColor = quoteSpans.length > 0 ? quoteSpans[0].getColor() : fallbackTextColor;
         @ColorInt int keywordColor = transformColor(textColor);

src/main/java/eu/siacs/conversations/xml/Element.java 🔗

@@ -101,10 +101,6 @@ public class Element implements Node {
 		return element == null ? null : element.getContent();
 	}
 
-	public LocalizedContent findInternationalizedChildContentInDefaultNamespace(String name) {
-		return LocalizedContent.get(this, name);
-	}
-
 	public Element findChild(String name, String xmlns) {
 		for (Element child : getChildren()) {
 			if (name.equals(child.getName()) && xmlns.equals(child.getAttribute("xmlns"))) {

src/main/java/eu/siacs/conversations/xml/LocalizedContent.java 🔗

@@ -1,8 +1,6 @@
 package eu.siacs.conversations.xml;
 
 import com.google.common.collect.Iterables;
-
-import java.util.HashMap;
 import java.util.Locale;
 import java.util.Map;
 
@@ -14,29 +12,13 @@ public class LocalizedContent {
     public final String language;
     public final int count;
 
-    public LocalizedContent(String content, String language, int count) {
+    public LocalizedContent(final String content, final String language, final int count) {
         this.content = content;
         this.language = language;
         this.count = count;
     }
 
-    public static LocalizedContent get(final Element element, String name) {
-        final HashMap<String, String> contents = new HashMap<>();
-        final String parentLanguage = element.getAttribute("xml:lang");
-        for(Element child : element.getChildren()) {
-            if (name.equals(child.getName())) {
-                final String namespace = child.getNamespace();
-                final String childLanguage = child.getAttribute("xml:lang");
-                final String lang = childLanguage == null ? parentLanguage : childLanguage;
-                final String content = child.getContent();
-                if (content != null && (namespace == null || Namespace.JABBER_CLIENT.equals(namespace))) {
-                    if (contents.put(lang, content == null ? "" : content) != null) {
-                        //anything that has multiple contents for the same language is invalid
-                        return null;
-                    }
-                }
-            }
-        }
+    public static LocalizedContent get(final Map<String, String> contents) {
         if (contents.isEmpty()) {
             return null;
         }
@@ -45,10 +27,6 @@ public class LocalizedContent {
         if (localized != null) {
             return new LocalizedContent(localized, userLanguage, contents.size());
         }
-        final String defaultLanguageContent = contents.get(null);
-        if (defaultLanguageContent != null) {
-            return new LocalizedContent(defaultLanguageContent, STREAM_LANGUAGE, contents.size());
-        }
         final String streamLanguageContent = contents.get(STREAM_LANGUAGE);
         if (streamLanguageContent != null) {
             return new LocalizedContent(streamLanguageContent, STREAM_LANGUAGE, contents.size());

src/main/java/eu/siacs/conversations/xml/Namespace.java 🔗

@@ -113,4 +113,6 @@ public final class Namespace {
 
     public static final String ENTITY_CAPABILITIES = "http://jabber.org/protocol/caps";
     public static final String ENTITY_CAPABILITIES_2 = "urn:xmpp:caps";
+    public static final String PRIVATE_XML_STORAGE = "jabber:iq:private";
+    public static final String SERVICE_OUTAGE_STATUS = "urn:xmpp:sos:0";
 }

src/main/java/eu/siacs/conversations/xml/XmlReader.java 🔗

@@ -2,61 +2,56 @@ package eu.siacs.conversations.xml;
 
 import android.util.Log;
 import android.util.Xml;
-
 import eu.siacs.conversations.Config;
-
 import im.conversations.android.xmpp.ExtensionFactory;
-import im.conversations.android.xmpp.model.Extension;
 import im.conversations.android.xmpp.model.StreamElement;
-
-import org.xmlpull.v1.XmlPullParser;
-import org.xmlpull.v1.XmlPullParserException;
-
 import java.io.Closeable;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
 
 public class XmlReader implements Closeable {
-	private final XmlPullParser parser;
-	private InputStream is;
+    private final XmlPullParser parser;
+    private InputStream is;
 
-	public XmlReader() {
-		this.parser = Xml.newPullParser();
-		try {
-			this.parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true);
-		} catch (XmlPullParserException e) {
-			Log.d(Config.LOGTAG, "error setting namespace feature on parser");
-		}
-	}
+    public XmlReader() {
+        this.parser = Xml.newPullParser();
+        try {
+            this.parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true);
+        } catch (XmlPullParserException e) {
+            Log.d(Config.LOGTAG, "error setting namespace feature on parser");
+        }
+    }
 
-	public void setInputStream(InputStream inputStream) throws IOException {
-		if (inputStream == null) {
-			throw new IOException();
-		}
-		this.is = inputStream;
-		try {
-			parser.setInput(new InputStreamReader(this.is));
-		} catch (XmlPullParserException e) {
-			throw new IOException("error resetting parser");
-		}
-	}
+    public void setInputStream(InputStream inputStream) throws IOException {
+        if (inputStream == null) {
+            throw new IOException();
+        }
+        this.is = inputStream;
+        try {
+            parser.setInput(new InputStreamReader(this.is));
+        } catch (XmlPullParserException e) {
+            throw new IOException("error resetting parser");
+        }
+    }
 
-	public void reset() throws IOException {
-		if (this.is == null) {
-			throw new IOException();
-		}
-		try {
-			parser.setInput(new InputStreamReader(this.is));
-		} catch (XmlPullParserException e) {
-			throw new IOException("error resetting parser");
-		}
-	}
+    public void reset() throws IOException {
+        if (this.is == null) {
+            throw new IOException();
+        }
+        try {
+            parser.setInput(new InputStreamReader(this.is));
+        } catch (XmlPullParserException e) {
+            throw new IOException("error resetting parser");
+        }
+    }
 
-	@Override
-	public void close() {
-		this.is = null;
-	}
+    @Override
+    public void close() {
+        this.is = null;
+    }
 
 	public Tag readTag() throws IOException {
 		try {
@@ -88,21 +83,27 @@ public class XmlReader implements Closeable {
 				}
 			}
 
-		} catch (Throwable throwable) {
-			throw new IOException("xml parser mishandled "+throwable.getClass().getSimpleName()+"("+throwable.getMessage()+")", throwable);
-		}
-		return null;
-	}
+        } catch (Throwable throwable) {
+            throw new IOException(
+                    "xml parser mishandled "
+                            + throwable.getClass().getSimpleName()
+                            + "("
+                            + throwable.getMessage()
+                            + ")",
+                    throwable);
+        }
+        return null;
+    }
 
-	public <T extends StreamElement> T readElement(final Tag current, final Class<T> clazz)
-			throws IOException {
-		final Element element = readElement(current);
-		if (clazz.isInstance(element)) {
-			return clazz.cast(element);
-		}
-		throw new IOException(
-				String.format("Read unexpected {%s}%s", element.getNamespace(), element.getName()));
-	}
+    public <T extends StreamElement> T readElement(final Tag current, final Class<T> clazz)
+            throws IOException {
+        final Element element = readElement(current);
+        if (clazz.isInstance(element)) {
+            return clazz.cast(element);
+        }
+        throw new IOException(
+                String.format("Read unexpected {%s}%s", element.getNamespace(), element.getName()));
+    }
 
 	public Element readElement(final Tag currentTag) throws IOException {
 		final var attributes = currentTag.getAttributes();

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

@@ -3194,6 +3194,20 @@ public class XmppConnection implements Runnable {
             this.blockListRequested = value;
         }
 
+        public HttpUrl getServiceOutageStatus() {
+            final var disco = connection.disco.get(account.getDomain());
+            if (disco == null) {
+                return null;
+            }
+            final var address =
+                    disco.getExtendedDiscoInformation(
+                            Namespace.SERVICE_OUTAGE_STATUS, "external-status-addresses");
+            if (Strings.isNullOrEmpty(address)) {
+                return null;
+            }
+            return HttpUrl.parse(address);
+        }
+
         public boolean httpUpload(long filesize) {
             if (Config.DISABLE_HTTP_UPLOAD) {
                 return false;

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

@@ -1,16 +1,13 @@
 package eu.siacs.conversations.xmpp.jingle;
 
 import android.util.Log;
-
 import androidx.annotation.NonNull;
-
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Objects;
 import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Contact;
@@ -20,10 +17,8 @@ import eu.siacs.conversations.entities.ServiceDiscoveryResult;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
-
 import im.conversations.android.xmpp.model.jingle.Jingle;
 import im.conversations.android.xmpp.model.stanza.Iq;
-
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
@@ -187,8 +182,7 @@ public abstract class AbstractJingleConnection {
 
     abstract void deliverPacket(Iq jinglePacket);
 
-    protected void receiveOutOfOrderAction(
-            final Iq jinglePacket, final Jingle.Action action) {
+    protected void receiveOutOfOrderAction(final Iq jinglePacket, final Jingle.Action action) {
         Log.d(
                 Config.LOGTAG,
                 String.format(
@@ -198,7 +192,8 @@ public abstract class AbstractJingleConnection {
             Log.d(
                     Config.LOGTAG,
                     String.format(
-                            "%s: got a reason to terminate with out-of-order. but already in state %s",
+                            "%s: got a reason to terminate with out-of-order. but already in state"
+                                    + " %s",
                             id.account.getJid().asBareJid(), getState()));
             respondWithOutOfOrder(jinglePacket);
         } else {
@@ -267,10 +262,7 @@ public abstract class AbstractJingleConnection {
     }
 
     private void respondWithJingleError(
-            final Iq original,
-            String jingleCondition,
-            String condition,
-            String conditionType) {
+            final Iq original, String jingleCondition, String condition, String conditionType) {
         jingleConnectionManager.respondWithJingleError(
                 id.account, original, jingleCondition, condition, conditionType);
     }
@@ -371,15 +363,15 @@ public abstract class AbstractJingleConnection {
             return new Id(account, with, sessionId);
         }
 
-        public static Id of(Account account, Jid with) {
+        public static Id of(final Account account, final Jid with) {
             return new Id(account, with, JingleConnectionManager.nextRandomId());
         }
 
-        public static Id of(Message message) {
+        public static Id of(final Message message) {
             return new Id(
                     message.getConversation().getAccount(),
                     message.getCounterpart(),
-                    JingleConnectionManager.nextRandomId());
+                    message.getUuid());
         }
 
         public Contact getContact() {
@@ -430,8 +422,8 @@ public abstract class AbstractJingleConnection {
             case DECLINE, BUSY -> State.TERMINATED_DECLINED_OR_BUSY;
             case CANCEL, TIMEOUT -> State.TERMINATED_CANCEL_OR_TIMEOUT;
             case SECURITY_ERROR -> State.TERMINATED_SECURITY_ERROR;
-            case FAILED_APPLICATION, UNSUPPORTED_TRANSPORTS, UNSUPPORTED_APPLICATIONS -> State
-                    .TERMINATED_APPLICATION_FAILURE;
+            case FAILED_APPLICATION, UNSUPPORTED_TRANSPORTS, UNSUPPORTED_APPLICATIONS ->
+                    State.TERMINATED_APPLICATION_FAILURE;
             default -> State.TERMINATED_CONNECTIVITY_ERROR;
         };
     }

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

@@ -101,6 +101,7 @@ public class JingleFileTransferConnection extends AbstractJingleConnection
                 this.xmppConnectionService.findOrCreateConversation(
                         id.account, id.with.asBareJid(), false, false);
         this.message = new Message(conversation, "", Message.ENCRYPTION_NONE);
+        this.message.setRemoteMsgId(id.sessionId);
         this.message.setStatus(Message.STATUS_RECEIVED);
         this.message.setErrorMessage(null);
         this.message.setTransferable(this);

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

@@ -1,18 +1,17 @@
 package eu.siacs.conversations.xmpp.jingle;
 
+import androidx.annotation.NonNull;
 import com.google.common.collect.ImmutableSet;
-
 import java.util.Locale;
 import java.util.Set;
 
-import javax.annotation.Nonnull;
-
 public enum Media {
-
-    VIDEO, AUDIO, UNKNOWN;
+    VIDEO,
+    AUDIO,
+    UNKNOWN;
 
     @Override
-    @Nonnull
+    @NonNull
     public String toString() {
         return super.toString().toLowerCase(Locale.ROOT);
     }

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

@@ -1,5 +1,6 @@
 package eu.siacs.conversations.xmpp.jingle;
 
+import androidx.annotation.NonNull;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Objects;
 import com.google.common.base.Preconditions;
@@ -26,7 +27,6 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import javax.annotation.Nonnull;
 
 public class RtpContentMap extends AbstractContentMap<RtpDescription, IceUdpTransportInfo> {
 
@@ -492,7 +492,7 @@ public class RtpContentMap extends AbstractContentMap<RtpDescription, IceUdpTran
         }
 
         @Override
-        @Nonnull
+        @NonNull
         public String toString() {
             return MoreObjects.toStringHelper(this)
                     .add("added", added)

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

@@ -1,23 +1,18 @@
 package eu.siacs.conversations.xmpp.jingle;
 
 import android.util.Log;
-
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import com.google.common.base.CaseFormat;
 import com.google.common.base.Optional;
 import com.google.common.base.Preconditions;
-
 import eu.siacs.conversations.Config;
-
+import java.util.UUID;
 import org.webrtc.MediaStreamTrack;
 import org.webrtc.PeerConnection;
 import org.webrtc.RtpSender;
 import org.webrtc.RtpTransceiver;
 
-import java.util.UUID;
-
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-
 class TrackWrapper<T extends MediaStreamTrack> {
     public final T track;
     public final RtpSender rtpSender;
@@ -63,7 +58,7 @@ class TrackWrapper<T extends MediaStreamTrack> {
     }
 
     public static <T extends MediaStreamTrack> RtpTransceiver getTransceiver(
-            @Nonnull final PeerConnection peerConnection, final TrackWrapper<T> trackWrapper) {
+            @NonNull final PeerConnection peerConnection, final TrackWrapper<T> trackWrapper) {
         final RtpSender rtpSender = trackWrapper.rtpSender;
         final String rtpSenderId;
         try {

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

@@ -2,12 +2,15 @@ package eu.siacs.conversations.xmpp.jingle;
 
 import android.content.Context;
 import android.util.Log;
-
+import androidx.annotation.Nullable;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.SettableFuture;
-
+import eu.siacs.conversations.Config;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Set;
 import org.webrtc.Camera2Enumerator;
 import org.webrtc.CameraEnumerationAndroid;
 import org.webrtc.CameraEnumerator;
@@ -17,14 +20,6 @@ import org.webrtc.PeerConnectionFactory;
 import org.webrtc.SurfaceTextureHelper;
 import org.webrtc.VideoSource;
 
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Set;
-
-import javax.annotation.Nullable;
-
-import eu.siacs.conversations.Config;
-
 class VideoSourceWrapper {
 
     private static final int CAPTURING_RESOLUTION = 1920;
@@ -151,7 +146,7 @@ class VideoSourceWrapper {
                     return videoSourceWrapper;
                 }
             }
-            if (deviceNames.size() == 0) {
+            if (deviceNames.isEmpty()) {
                 return null;
             } else {
                 return of(enumerator, Iterables.get(deviceNames, 0), deviceNames);

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

@@ -25,8 +25,6 @@ import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicBoolean;
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
 import org.webrtc.AudioSource;
 import org.webrtc.AudioTrack;
 import org.webrtc.CandidatePairChangeEvent;
@@ -60,8 +58,8 @@ import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicBoolean;
 
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 
 public class WebRTCWrapper {
 
@@ -706,7 +704,7 @@ public class WebRTCWrapper {
                 MoreExecutors.directExecutor());
     }
 
-    @Nonnull
+    @NonNull
     private ListenableFuture<PeerConnection> getPeerConnectionFuture() {
         final PeerConnection peerConnection = this.peerConnection;
         if (peerConnection == null) {
@@ -716,7 +714,7 @@ public class WebRTCWrapper {
         }
     }
 
-    @Nonnull
+    @NonNull
     private PeerConnection requirePeerConnection() {
         final PeerConnection peerConnection = this.peerConnection;
         if (peerConnection == null) {
@@ -744,7 +742,7 @@ public class WebRTCWrapper {
         return true;
     }
 
-    @Nonnull
+    @NonNull
     private PeerConnectionFactory requirePeerConnectionFactory() {
         final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory;
         if (peerConnectionFactory == null) {

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

@@ -5,6 +5,7 @@ import static eu.siacs.conversations.xmpp.jingle.WebRTCWrapper.logDescription;
 
 import android.content.Context;
 import android.util.Log;
+import androidx.annotation.NonNull;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
 import com.google.common.io.Closeables;
@@ -41,7 +42,6 @@ import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.atomic.AtomicBoolean;
-import javax.annotation.Nonnull;
 import org.webrtc.CandidatePairChangeEvent;
 import org.webrtc.DataChannel;
 import org.webrtc.IceCandidate;
@@ -437,7 +437,7 @@ public class WebRTCDataChannelTransport implements Transport {
                 localDescriptionExecutorService);
     }
 
-    @Nonnull
+    @NonNull
     private PeerConnectionFactory requirePeerConnectionFactory() {
         final PeerConnectionFactory peerConnectionFactory = this.peerConnectionFactory;
         if (peerConnectionFactory == null) {
@@ -446,7 +446,7 @@ public class WebRTCDataChannelTransport implements Transport {
         return peerConnectionFactory;
     }
 
-    @Nonnull
+    @NonNull
     private PeerConnection requirePeerConnection() {
         final var future = this.peerConnectionFuture;
         if (future != null && future.isDone()) {

src/main/java/eu/siacs/conversations/xmpp/pep/Avatar.java 🔗

@@ -9,83 +9,77 @@ import io.ipfs.cid.Cid;
 import eu.siacs.conversations.utils.CryptoHelper;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xmpp.Jid;
+import im.conversations.android.xmpp.model.avatar.Metadata;
 
 public class Avatar {
 
-	public enum Origin { PEP, VCARD }
+    public enum Origin {
+        PEP,
+        VCARD
+    }
 
     public String type;
-	public String sha1sum;
-	public String image;
-	public int height;
-	public int width;
-	public long size;
-	public Jid owner;
-	public Origin origin = Origin.PEP; //default to maintain compat
+    public String sha1sum;
+    public String image;
+    public int height;
+    public int width;
+    public long size;
+    public Jid owner;
+    public Origin origin = Origin.PEP; // default to maintain compat
 
-	public byte[] getImageAsBytes() {
-		return Base64.decode(image, Base64.DEFAULT);
-	}
+    public byte[] getImageAsBytes() {
+        return Base64.decode(image, Base64.DEFAULT);
+    }
 
-	public String getFilename() {
-		return sha1sum;
-	}
+    public String getFilename() {
+        return sha1sum;
+    }
 
-	public static Avatar parseMetadata(Element items) {
-		Element item = items.findChild("item");
-		if (item == null) {
-			return null;
-		}
-		Element metadata = item.findChild("metadata");
-		if (metadata == null) {
-			return null;
-		}
-		String primaryId = item.getAttribute("id");
-		if (primaryId == null) {
-			return null;
-		}
-		for (Element child : metadata.getChildren()) {
-			if (child.getName().equals("info")
-					&& primaryId.equals(child.getAttribute("id"))) {
-				Avatar avatar = new Avatar();
-				String height = child.getAttribute("height");
-				String width = child.getAttribute("width");
-				String size = child.getAttribute("bytes");
-				try {
-					if (height != null) {
-						avatar.height = Integer.parseInt(height);
-					}
-					if (width != null) {
-						avatar.width = Integer.parseInt(width);
-					}
-					if (size != null) {
-						avatar.size = Long.parseLong(size);
-					}
-				} catch (NumberFormatException e) {
-					return null;
-				}
-				avatar.type = child.getAttribute("type");
-				String hash = child.getAttribute("id");
-				if (!isValidSHA1(hash)) {
-					return null;
-				}
-				avatar.sha1sum = hash;
-				avatar.origin = Origin.PEP;
-				return avatar;
-			}
-		}
-		return null;
-	}
+    public static Avatar parseMetadata(final String primaryId, final Metadata metadata) {
+        if (primaryId == null || metadata == null) {
+            return null;
+        }
+        for (Element child : metadata.getChildren()) {
+            if (child.getName().equals("info") && primaryId.equals(child.getAttribute("id"))) {
+                Avatar avatar = new Avatar();
+                String height = child.getAttribute("height");
+                String width = child.getAttribute("width");
+                String size = child.getAttribute("bytes");
+                try {
+                    if (height != null) {
+                        avatar.height = Integer.parseInt(height);
+                    }
+                    if (width != null) {
+                        avatar.width = Integer.parseInt(width);
+                    }
+                    if (size != null) {
+                        avatar.size = Long.parseLong(size);
+                    }
+                } catch (NumberFormatException e) {
+                    return null;
+                }
+                avatar.type = child.getAttribute("type");
+                String hash = child.getAttribute("id");
+                if (!isValidSHA1(hash)) {
+                    return null;
+                }
+                avatar.sha1sum = hash;
+                avatar.origin = Origin.PEP;
+                return avatar;
+            }
+        }
+        return null;
+    }
 
-	@Override
-	public boolean equals(Object object) {
-		if (object != null && object instanceof Avatar) {
-			Avatar other = (Avatar) object;
-			return other.getFilename().equals(this.getFilename());
-		} else {
-			return false;
-		}
-	}
+    @Override
+    public boolean equals(Object object) {
+        if (object != null && object instanceof Avatar) {
+            Avatar other = (Avatar) object;
+            return other.getFilename().equals(this.getFilename());
+        } else {
+            return false;
+        }
+    }
 
 	public Cid cid() {
 		if (sha1sum == null) return null;
@@ -111,7 +105,7 @@ public class Avatar {
 		return avatar;
 	}
 
-	private static boolean isValidSHA1(String s) {
-		return s != null && s.matches("[a-fA-F0-9]{40}");
-	}
+    private static boolean isValidSHA1(String s) {
+        return s != null && s.matches("[a-fA-F0-9]{40}");
+    }
 }

src/main/java/eu/siacs/conversations/xmpp/pep/PublishOptions.java 🔗

@@ -12,12 +12,14 @@ public class PublishOptions {
     public static Bundle openAccess() {
         final Bundle options = new Bundle();
         options.putString("pubsub#access_model", "open");
+        options.putString("pubsub#notify_delete", "true");
         return options;
     }
 
     public static Bundle presenceAccess() {
         final Bundle options = new Bundle();
         options.putString("pubsub#access_model", "presence");
+        options.putString("pubsub#notify_delete", "true");
         return options;
     }
 

src/main/java/im/conversations/android/xmpp/model/bookmark/Conference.java 🔗

@@ -1,32 +1,10 @@
 package im.conversations.android.xmpp.model.bookmark;
 
-import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
 
-@XmlElement
 public class Conference extends Extension {
 
     public Conference() {
         super(Conference.class);
     }
-
-    public boolean isAutoJoin() {
-        return this.getAttributeAsBoolean("autojoin");
-    }
-
-    public String getConferenceName() {
-        return this.getAttribute("name");
-    }
-
-    public void setAutoJoin(boolean autoJoin) {
-        setAttribute("autojoin", autoJoin);
-    }
-
-    public Nick getNick() {
-        return this.getExtension(Nick.class);
-    }
-
-    public Extensions getExtensions() {
-        return this.getExtension(Extensions.class);
-    }
 }

src/main/java/im/conversations/android/xmpp/model/bookmark/package-info.java 🔗

@@ -1,5 +1,5 @@
-@XmlPackage(namespace = Namespace.BOOKMARKS2)
+@XmlPackage(namespace = Namespace.BOOKMARKS)
 package im.conversations.android.xmpp.model.bookmark;
 
-import im.conversations.android.annotation.XmlPackage;
 import eu.siacs.conversations.xml.Namespace;
+import im.conversations.android.annotation.XmlPackage;

src/main/java/im/conversations/android/xmpp/model/bookmark2/Conference.java 🔗

@@ -0,0 +1,32 @@
+package im.conversations.android.xmpp.model.bookmark2;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class Conference extends Extension {
+
+    public Conference() {
+        super(Conference.class);
+    }
+
+    public boolean isAutoJoin() {
+        return this.getAttributeAsBoolean("autojoin");
+    }
+
+    public String getConferenceName() {
+        return this.getAttribute("name");
+    }
+
+    public void setAutoJoin(boolean autoJoin) {
+        setAttribute("autojoin", autoJoin);
+    }
+
+    public Nick getNick() {
+        return this.getExtension(Nick.class);
+    }
+
+    public Extensions getExtensions() {
+        return this.getExtension(Extensions.class);
+    }
+}

src/main/java/im/conversations/android/xmpp/model/bookmark/Extensions.java → src/main/java/im/conversations/android/xmpp/model/bookmark2/Extensions.java 🔗

@@ -1,4 +1,4 @@
-package im.conversations.android.xmpp.model.bookmark;
+package im.conversations.android.xmpp.model.bookmark2;
 
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;

src/main/java/im/conversations/android/xmpp/model/bookmark/Nick.java → src/main/java/im/conversations/android/xmpp/model/bookmark2/Nick.java 🔗

@@ -1,4 +1,4 @@
-package im.conversations.android.xmpp.model.bookmark;
+package im.conversations.android.xmpp.model.bookmark2;
 
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;

src/main/java/im/conversations/android/xmpp/model/mds/Displayed.java 🔗

@@ -3,10 +3,15 @@ package im.conversations.android.xmpp.model.mds;
 import eu.siacs.conversations.xml.Namespace;
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
+import im.conversations.android.xmpp.model.unique.StanzaId;
 
 @XmlElement(namespace = Namespace.MDS_DISPLAYED)
 public class Displayed extends Extension {
     public Displayed() {
         super(Displayed.class);
     }
+
+    public StanzaId getStanzaId() {
+        return this.getOnlyExtension(StanzaId.class);
+    }
 }

src/main/java/im/conversations/android/xmpp/model/pubsub/Items.java 🔗

@@ -45,6 +45,11 @@ public interface Items {
         return Iterables.getFirst(map.values(), null);
     }
 
+    default <T extends Extension> Map.Entry<String, T> getFirstItemWithId(final Class<T> clazz) {
+        final var entries = getItemMap(clazz).entrySet();
+        return Iterables.getFirst(entries, null);
+    }
+
     default <T extends Extension> T getOnlyItem(final Class<T> clazz) {
         final var map = getItemMap(clazz);
         return Iterables.getOnlyElement(map.values());

src/main/java/im/conversations/android/xmpp/model/pubsub/event/Action.java 🔗

@@ -0,0 +1,14 @@
+package im.conversations.android.xmpp.model.pubsub.event;
+
+import im.conversations.android.xmpp.model.Extension;
+
+public abstract class Action extends Extension {
+
+    public Action(Class<? extends Action> clazz) {
+        super(clazz);
+    }
+
+    public String getNode() {
+        return this.getAttribute("node");
+    }
+}

src/main/java/im/conversations/android/xmpp/model/pubsub/event/Event.java 🔗

@@ -12,25 +12,17 @@ public class Event extends Extension {
         super(Event.class);
     }
 
-    public Items getItems() {
-        return this.getExtension(ItemsWrapper.class);
-    }
-
-    public Purge getPurge() {
-        return this.getExtension(Purge.class);
+    public Action getAction() {
+        return this.getOnlyExtension(Action.class);
     }
 
     @XmlElement(name = "items")
-    public static class ItemsWrapper extends Extension implements Items {
+    public static class ItemsWrapper extends Action implements Items {
 
         public ItemsWrapper() {
             super(ItemsWrapper.class);
         }
 
-        public String getNode() {
-            return this.getAttribute("node");
-        }
-
         public Collection<? extends im.conversations.android.xmpp.model.pubsub.Item> getItems() {
             return this.getExtensions(Item.class);
         }

src/main/java/im/conversations/android/xmpp/model/pubsub/event/Purge.java 🔗

@@ -1,10 +1,9 @@
 package im.conversations.android.xmpp.model.pubsub.event;
 
 import im.conversations.android.annotation.XmlElement;
-import im.conversations.android.xmpp.model.Extension;
 
 @XmlElement
-public class Purge extends Extension {
+public class Purge extends Action {
 
     public Purge() {
         super(Purge.class);

src/main/java/im/conversations/android/xmpp/model/pubsub/event/Retract.java 🔗

@@ -1,10 +1,9 @@
 package im.conversations.android.xmpp.model.pubsub.event;
 
 import im.conversations.android.annotation.XmlElement;
-import im.conversations.android.xmpp.model.Extension;
 
 @XmlElement
-public class Retract extends Extension {
+public class Retract extends Action {
 
     public Retract() {
         super(Retract.class);

src/main/java/im/conversations/android/xmpp/model/stanza/Message.java 🔗

@@ -1,11 +1,17 @@
 package im.conversations.android.xmpp.model.stanza;
 
+import com.google.common.base.Joiner;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Maps;
+import eu.siacs.conversations.Config;
 import eu.siacs.conversations.xml.Element;
 import eu.siacs.conversations.xml.LocalizedContent;
-
 import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
 import im.conversations.android.xmpp.model.jabber.Body;
-
+import im.conversations.android.xmpp.model.jabber.Subject;
 import java.util.Locale;
 
 @XmlElement
@@ -21,9 +27,38 @@ public class Message extends Stanza {
     }
 
     public LocalizedContent getBody() {
-        return findInternationalizedChildContentInDefaultNamespace("body");
+        return getLocalizedContent(Body.class);
+    }
+
+    public LocalizedContent getSubject() {
+        return getLocalizedContent(Subject.class);
+    }
+
+    private LocalizedContent getLocalizedContent(final Class<? extends Extension> clazz) {
+        final var builder = new ImmutableMultimap.Builder<String, String>();
+        final var messageLanguage = this.getAttribute("xml:lang");
+        final var parentLanguage =
+                Strings.isNullOrEmpty(messageLanguage)
+                        ? LocalizedContent.STREAM_LANGUAGE
+                        : messageLanguage;
+        for (final var element : this.getExtensions(clazz)) {
+            final var elementLanguage = element.getAttribute("xml:lang");
+            final var language =
+                    Strings.isNullOrEmpty(elementLanguage) ? parentLanguage : elementLanguage;
+            final var content = element.getContent();
+            if (content == null) {
+                continue;
+            }
+            builder.put(language, content);
+        }
+        final var multiMap = builder.build().asMap();
+        if (Config.TREAT_MULTI_CONTENT_AS_INVALID
+                && Iterables.any(multiMap.values(), v -> v.size() > 1)) {
+            return null;
+        }
+        return LocalizedContent.get(Maps.transformValues(multiMap, v -> Joiner.on('\n').join(v)));
     }
-    
+
     public Type getType() {
         final var value = this.getAttribute("type");
         if (value == null) {

src/main/java/im/conversations/android/xmpp/model/storage/PrivateStorage.java 🔗

@@ -0,0 +1,13 @@
+package im.conversations.android.xmpp.model.storage;
+
+import eu.siacs.conversations.xml.Namespace;
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement(name = "query", namespace = Namespace.PRIVATE_XML_STORAGE)
+public class PrivateStorage extends Extension {
+
+    public PrivateStorage() {
+        super(PrivateStorage.class);
+    }
+}

src/main/java/im/conversations/android/xmpp/model/unique/StanzaId.java 🔗

@@ -1,8 +1,10 @@
 package im.conversations.android.xmpp.model.unique;
 
+import com.google.common.collect.ImmutableMap;
 import eu.siacs.conversations.xmpp.Jid;
 import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
+import java.util.Map;
 
 @XmlElement
 public class StanzaId extends Extension {
@@ -18,4 +20,23 @@ public class StanzaId extends Extension {
     public String getId() {
         return this.getAttribute("id");
     }
+
+    public static String get(
+            final im.conversations.android.xmpp.model.stanza.Message packet, final Jid by) {
+        final var builder = new ImmutableMap.Builder<Jid, String>();
+        for (final var stanzaId : packet.getExtensions(StanzaId.class)) {
+            final var id = stanzaId.getId();
+            final var byAttribute = Jid.Invalid.getNullForInvalid(stanzaId.getBy());
+            if (byAttribute != null && id != null) {
+                builder.put(byAttribute, id);
+            }
+        }
+        final Map<Jid, String> byToId;
+        try {
+            byToId = builder.buildOrThrow();
+        } catch (final IllegalArgumentException e) {
+            return null;
+        }
+        return byToId.get(by);
+    }
 }

src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java 🔗

@@ -2,13 +2,11 @@ package im.conversations.android.xmpp.processor;
 
 import android.text.TextUtils;
 import android.util.Log;
-
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.generator.IqGenerator;
 import eu.siacs.conversations.services.XmppConnectionService;
 import eu.siacs.conversations.xmpp.XmppConnection;
-
 import im.conversations.android.xmpp.model.stanza.Iq;
 
 public class BindProcessor implements Runnable {
@@ -24,14 +22,21 @@ public class BindProcessor implements Runnable {
     @Override
     public void run() {
         final XmppConnection connection = account.getXmppConnection();
+        final var features = connection.getFeatures();
         service.cancelAvatarFetches(account);
         final boolean loggedInSuccessfully =
                 account.setOption(Account.OPTION_LOGGED_IN_SUCCESSFULLY, true);
+        final boolean sosModified;
+        final var sos = features.getServiceOutageStatus();
+        if (sos != null) {
+            Log.d(Config.LOGTAG, account.getJid().asBareJid() + " server has SOS on " + sos);
+            sosModified = account.setKey(Account.KEY_SOS_URL, sos.toString());
+        } else {
+            sosModified = false;
+        }
         final boolean gainedFeature =
-                account.setOption(
-                        Account.OPTION_HTTP_UPLOAD_AVAILABLE,
-                        connection.getFeatures().httpUpload(0));
-        if (loggedInSuccessfully || gainedFeature) {
+                account.setOption(Account.OPTION_HTTP_UPLOAD_AVAILABLE, features.httpUpload(0));
+        if (loggedInSuccessfully || gainedFeature || sosModified) {
             service.databaseBackend.updateAccount(account);
         }
 
@@ -57,18 +62,17 @@ public class BindProcessor implements Runnable {
 
         connection.fetchRoster();
 
-        if (connection.getFeatures().bookmarks2()) {
+        if (features.bookmarks2()) {
             service.fetchBookmarks2(account);
-        } else if (!connection.getFeatures().bookmarksConversion()) {
+        } else if (!features.bookmarksConversion()) {
             service.fetchBookmarks(account);
         }
 
-        if (connection.getFeatures().mds()) {
+        if (features.mds()) {
             service.fetchMessageDisplayedSynchronization(account);
         } else {
             Log.d(Config.LOGTAG, account.getJid() + ": server has no support for mds");
         }
-        final var features = connection.getFeatures();
         final boolean bind2 = features.bind2();
         final boolean flexible = features.flexibleOfflineMessageRetrieval();
         final boolean catchup = service.getMessageArchiveService().inCatchup(account);

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

@@ -1,4 +1,5 @@
-<layout xmlns:android="http://schemas.android.com/apk/res/android">
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
 
     <LinearLayout
         android:layout_width="match_parent"
@@ -25,13 +26,13 @@
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content">
                 <TextView
+                    android:id="@+id/about"
                     android:layout_width="wrap_content"
                     android:layout_height="wrap_content"
-                    android:autoLink="web"
                     android:fontFamily="monospace"
                     android:linksClickable="true"
                     android:layout_margin="@dimen/card_padding_regular"
-                    android:text="@string/pref_about_message"
+                    tools:text="@string/pref_about_message"
                     android:textAppearance="?textAppearanceBodyMedium"
                     android:typeface="monospace" />
             </LinearLayout>

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

@@ -4,7 +4,8 @@
 
     <RelativeLayout
         android:layout_width="match_parent"
-        android:layout_height="match_parent">
+        android:layout_height="match_parent"
+        android:fitsSystemWindows="true">
 
         <com.google.android.material.appbar.AppBarLayout
             android:id="@+id/app_bar_layout"

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

@@ -6,6 +6,7 @@
     <LinearLayout
         android:layout_width="fill_parent"
         android:layout_height="fill_parent"
+        android:fitsSystemWindows="true"
         android:orientation="vertical">
 
         <com.google.android.material.appbar.AppBarLayout

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

@@ -4,6 +4,7 @@
     <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="match_parent"
+        android:fitsSystemWindows="true"
         android:orientation="vertical">
 
         <com.google.android.material.appbar.AppBarLayout

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

@@ -6,6 +6,7 @@
     <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="match_parent"
+        android:fitsSystemWindows="true"
         android:orientation="vertical">
 
         <com.google.android.material.appbar.AppBarLayout

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

@@ -9,6 +9,7 @@
     <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="match_parent"
+        android:fitsSystemWindows="true"
         android:orientation="vertical">
 
         <com.google.android.material.appbar.AppBarLayout

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

@@ -5,7 +5,8 @@
 
     <RelativeLayout
         android:layout_width="match_parent"
-        android:layout_height="match_parent">
+        android:layout_height="match_parent"
+        android:fitsSystemWindows="true">
 
         <com.google.android.material.appbar.AppBarLayout
             android:id="@+id/app_bar_layout"
@@ -146,6 +147,56 @@
                     </RelativeLayout>
                 </com.google.android.material.card.MaterialCardView>
 
+                <com.google.android.material.card.MaterialCardView
+                    android:id="@+id/service_outage"
+                    style="?attr/materialCardViewElevatedStyle"
+                    android:layout_width="fill_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginLeft="@dimen/activity_horizontal_margin"
+                    android:layout_marginTop="@dimen/activity_vertical_margin"
+                    android:layout_marginRight="@dimen/activity_horizontal_margin"
+                    android:layout_marginBottom="@dimen/activity_vertical_margin"
+                    android:visibility="gone"
+                    app:cardBackgroundColor="?colorErrorContainer"
+                    tools:visibility="visible">
+
+                    <LinearLayout
+                        android:layout_width="match_parent"
+                        android:layout_height="wrap_content"
+                        android:orientation="vertical"
+                        android:padding="@dimen/card_padding_regular">
+
+
+                        <TextView
+                            android:id="@+id/sos_title"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:text="@string/account_status_service_outage_known"
+                            android:textAppearance="?textAppearanceTitleLarge"
+                            android:textColor="?colorOnErrorContainer" />
+
+                        <TextView
+                            android:id="@+id/sos_message"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginTop="8dp"
+                            android:textAppearance="?textAppearanceBodyMedium"
+                            android:textColor="?colorOnErrorContainer"
+                            android:textColorLink="?colorOnErrorContainer"
+                            tools:text="Our service is currently performing server updates" />
+
+                        <TextView
+                            android:id="@+id/sos_scheduled_end"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginTop="16sp"
+                            android:textAppearance="?textAppearanceBodyLarge"
+                            android:textColor="?colorOnErrorContainer"
+                            tools:text="@string/sos_scheduled_return" />
+                    </LinearLayout>
+                </com.google.android.material.card.MaterialCardView>
+
+
                 <com.google.android.material.card.MaterialCardView
                     android:id="@+id/os_optimization"
                     android:layout_width="fill_parent"

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

@@ -5,6 +5,7 @@
     <LinearLayout
         android:layout_width="fill_parent"
         android:layout_height="fill_parent"
+        android:fitsSystemWindows="true"
         android:orientation="vertical">
 
         <com.google.android.material.appbar.AppBarLayout

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

@@ -4,6 +4,7 @@
     <LinearLayout
         android:layout_width="fill_parent"
         android:layout_height="fill_parent"
+        android:fitsSystemWindows="true"
         android:orientation="vertical">
 
         <com.google.android.material.appbar.AppBarLayout

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

@@ -6,6 +6,7 @@
     <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="match_parent"
+        android:fitsSystemWindows="true"
         android:orientation="vertical">
 
         <com.google.android.material.appbar.AppBarLayout
@@ -87,7 +88,6 @@
                                             android:id="@+id/muc_subject"
                                             android:layout_width="wrap_content"
                                             android:layout_height="wrap_content"
-                                            android:autoLink="web"
                                             android:textAppearance="?textAppearanceTitleMedium" />
 
                                         <androidx.constraintlayout.widget.ConstraintLayout

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

@@ -5,6 +5,7 @@
     <LinearLayout
         android:layout_width="fill_parent"
         android:layout_height="fill_parent"
+        android:fitsSystemWindows="true"
         android:orientation="vertical">
 
         <com.google.android.material.appbar.AppBarLayout

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

@@ -4,7 +4,8 @@
 
     <RelativeLayout
         android:layout_width="match_parent"
-        android:layout_height="match_parent">
+        android:layout_height="match_parent"
+        android:fitsSystemWindows="true">
 
         <com.google.android.material.appbar.AppBarLayout
             android:id="@+id/app_bar_layout"

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

@@ -5,7 +5,8 @@
 
     <RelativeLayout
         android:layout_width="match_parent"
-        android:layout_height="match_parent">
+        android:layout_height="match_parent"
+        android:fitsSystemWindows="true">
 
         <LinearLayout
             android:id="@+id/pip_placeholder"

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

@@ -5,6 +5,7 @@
     <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="match_parent"
+        android:fitsSystemWindows="true"
         android:orientation="vertical">
 
         <com.google.android.material.appbar.AppBarLayout
@@ -20,10 +21,10 @@
         </com.google.android.material.appbar.AppBarLayout>
 
         <ListView
-            android:background="@drawable/background_search"
             android:id="@+id/search_results"
             android:layout_width="match_parent"
             android:layout_height="match_parent"
+            android:background="@drawable/background_search"
             android:divider="@android:color/transparent"
             android:dividerHeight="0dp"
             android:listSelector="@android:color/transparent"

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

@@ -4,7 +4,8 @@
 
     <androidx.coordinatorlayout.widget.CoordinatorLayout
         android:layout_width="match_parent"
-        android:layout_height="match_parent">
+        android:layout_height="match_parent"
+        android:fitsSystemWindows="true">
 
         <com.google.android.material.appbar.AppBarLayout
             android:id="@+id/app_bar_layout"

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

@@ -5,6 +5,7 @@
     <RelativeLayout
         android:layout_width="match_parent"
         android:layout_height="match_parent"
+        android:fitsSystemWindows="true"
         tools:context=".ui.ShareLocationActivity">
 
         <com.google.android.material.appbar.AppBarLayout

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

@@ -5,6 +5,7 @@
     <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="match_parent"
+        android:fitsSystemWindows="true"
         android:orientation="vertical">
 
         <com.google.android.material.appbar.AppBarLayout

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

@@ -3,7 +3,8 @@
 
     <RelativeLayout
         android:layout_width="match_parent"
-        android:layout_height="match_parent">
+        android:layout_height="match_parent"
+        android:fitsSystemWindows="true">
 
         <com.google.android.material.appbar.AppBarLayout
             android:id="@+id/app_bar_layout"

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

@@ -4,7 +4,8 @@
 
     <RelativeLayout
         android:layout_width="match_parent"
-        android:layout_height="match_parent">
+        android:layout_height="match_parent"
+        android:fitsSystemWindows="true">
 
         <com.google.android.material.appbar.AppBarLayout
             android:id="@+id/app_bar_layout"
@@ -52,9 +53,9 @@
             android:layout_alignParentEnd="true"
             android:layout_alignParentBottom="true"
             android:contentDescription="@string/add_contact_or_create_or_join_group_chat"
-            app:sdMainFabClosedSrc="@drawable/ic_add_24dp"
             app:sdMainFabClosedBackgroundColor="?colorPrimaryContainer"
             app:sdMainFabClosedIconColor="?colorOnPrimaryContainer"
+            app:sdMainFabClosedSrc="@drawable/ic_add_24dp"
             app:sdMainFabOpenedBackgroundColor="?colorPrimaryContainer"
             app:sdMainFabOpenedIconColor="?colorOnPrimaryContainer"
             app:sdOverlayLayout="@id/overlay"

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

@@ -3,7 +3,8 @@
 
     <RelativeLayout
         android:layout_width="match_parent"
-        android:layout_height="match_parent">
+        android:layout_height="match_parent"
+        android:fitsSystemWindows="true">
 
         <com.google.android.material.appbar.AppBarLayout
             android:id="@+id/app_bar_layout"

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

@@ -4,7 +4,7 @@
     <string name="action_accounts">Gestionar cuentas</string>
     <string name="action_account">Gestionar cuenta</string>
     <string name="action_contact_details">Detalles del contacto</string>
-    <string name="action_muc_details">Detalles de conversación</string>
+    <string name="action_muc_details">Detalles de la conversación grupal</string>
     <string name="channel_details">Detalles del canal</string>
     <string name="action_add_account">Añadir cuenta</string>
     <string name="action_edit_contact">Editar contacto</string>
@@ -119,7 +119,7 @@
     <string name="pref_notification_grace_period">Periodo de gracia</string>
     <string name="pref_notification_grace_period_summary">El tiempo que se silencian las notificaciones tras detectar actividad en otro de tus dispositivos.</string>
     <string name="pref_advanced_options">Avanzado</string>
-    <string name="pref_never_send_crash_summary">Al enviar los informes de los fallos, ayudará a un mayor desarrollo</string>
+    <string name="pref_never_send_crash_summary">Al enviar los informes, estás ayudando al desarrollo</string>
     <string name="pref_confirm_messages">Confirmar mensajes</string>
     <string name="pref_confirm_messages_summary">Permitir a sus contactos saber cuando ha recibido y leído sus mensajes</string>
     <string name="pref_prevent_screenshots">Impedir capturas de pantalla</string>
@@ -413,7 +413,7 @@
     <string name="pdf_document">documento PDF</string>
     <string name="apk">aplicación para Android</string>
     <string name="vcard">Contacto</string>
-    <string name="avatar_has_been_published">Se ha publicado el avatar.</string>
+    <string name="avatar_has_been_published">Foto de perfil ha sido publicada!</string>
     <string name="sending_x_file">Enviando %s</string>
     <string name="offering_x_file">Ofreciendo %s</string>
     <string name="hide_offline">Ocultar desconectados</string>
@@ -484,7 +484,7 @@
     <string name="action_renew_certificate">Renovar certificado</string>
     <string name="error_fetching_omemo_key">¡Error buscando clave OMEMO!</string>
     <string name="verified_omemo_key_with_certificate">¡Clave OMEMO con certificado verificada!</string>
-    <string name="device_does_not_support_certificates">Su dispositivo no admite la selección de certificados de cliente.</string>
+    <string name="device_does_not_support_certificates">Tu dispositivo no admite la selección de certificados de cliente!</string>
     <string name="pref_connection_options">Conexión</string>
     <string name="pref_use_tor">Conectar via Tor</string>
     <string name="pref_use_tor_summary">Todas las conexiones se realizan a través de la red TOR. Requiere Orbot</string>
@@ -826,7 +826,7 @@
     <string name="ebook">e-book</string>
     <string name="video_original">Original (sin comprimir)</string>
     <string name="open_with">Abrir con…</string>
-    <string name="set_profile_picture">Avatar</string>
+    <string name="set_profile_picture">Foto de Perfil</string>
     <string name="choose_account">Elige una cuenta</string>
     <string name="restore_backup">Restaurar copia de respaldo</string>
     <string name="restore">Restaurar</string>
@@ -1108,12 +1108,12 @@
     <string name="clients_may_not_support_av">El cliente XMPP de su contacto puede que no admita llamadas de audio/vídeo.</string>
     <string name="could_not_modify_call">No se pudo modificar la llamada</string>
     <string name="pref_chat_bubbles">Burbujas de chat</string>
-    <string name="pref_chat_bubbles_summary">Color, Tamaño de fuente, Imágenes de perfil</string>
+    <string name="pref_chat_bubbles_summary">Color de fondo, Tamaño de fuente, Imágenes de perfil</string>
     <string name="pref_title_bubbles">Burbujas de Chat</string>
     <string name="pref_call_integration">Integración de llamadas</string>
     <string name="pref_show_avatars">Mostrar avatares</string>
     <string name="pref_show_avatars_summary">Mostrar imágenes de perfil para tus mensajes y conversaciones 1:1, aparte de las conversaciones en grupo.</string>
-    <string name="pref_call_integration_summary">Las llamadas desde esta app interactúan con las llamadas telefónicas regulares, como ser finalizar una llamada cuando recibimos otra.</string>
+    <string name="pref_call_integration_summary">Las llamadas desde esta app interactúan con las llamadas telefónicas regulares, como finalizar una llamada cuando recibimos otra.</string>
     <string name="pref_align_start">Mensajes alineados a la izquierda</string>
     <string name="pref_align_start_summary">Mostrar todos los mensajes, incluso los propios, sobre el margen izquierdo para una distribución uniforme del chat.</string>
     <string name="custom_notifications">Notificaciones personalizadas</string>
@@ -1127,4 +1127,12 @@
     <string name="pref_backup_location">Ubicación de la copia de seguridad</string>
     <string name="restore_omemo_key">Restaurar claves OMEMO</string>
     <string name="account_status_channel_binding">Canal no disponible</string>
+    <string name="uri">URI</string>
+    <string name="copy_geo_uri">Copiar geolocalización</string>
+    <string name="copy_email_address">Copiar dirección email</string>
+    <string name="copied_email_address">Email copiado al portapapeles</string>
+    <string name="uri_copied_to_clipboard">URI copiada al portapapeles</string>
+    <string name="copy_URI">Copiar URI</string>
+    <string name="copied_phone_number">Número de teléfono copiado al portapapeles</string>
+    <string name="copy_telephone_number">Copiar numero de teléfono</string>
 </resources>

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

@@ -1129,4 +1129,12 @@
     <string name="restore_omemo_key">Ripristina chiavi OMEMO</string>
     <string name="non_quicksy_backup">Quicksy può ripristinare backup solo per profili quicksy.im</string>
     <string name="pref_backup_location">Percorso backup</string>
+    <string name="uri">URI</string>
+    <string name="copy_geo_uri">Copia posizione geografica</string>
+    <string name="copy_email_address">Copia indirizzo email</string>
+    <string name="copied_email_address">Indirizzo email copiato negli appunti</string>
+    <string name="copied_phone_number">Numero di telefono copiato negli appunti</string>
+    <string name="copy_telephone_number">Copia numero di telefono</string>
+    <string name="copy_URI">Copia URI</string>
+    <string name="uri_copied_to_clipboard">URI copiato negli appunti</string>
 </resources>

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

@@ -1127,4 +1127,7 @@
     <string name="delete_avatar_message">Would you like to delete your avatar? Some clients might continue to display a cached copy of your avatar.</string>
     <string name="show_to_contacts_only">Show to contacts only</string>
     <string name="pref_backup_location">Backup location</string>
+    <string name="account_status_service_outage_scheduled">Planned Downtime</string>
+    <string name="account_status_service_outage_known">Service Down (Known Issue)</string>
+    <string name="sos_scheduled_return">The service is scheduled to return at %s</string>
 </resources>

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

@@ -14,6 +14,7 @@
     <style name="Theme.Conversations3.SplashScreen" parent="@style/Theme.Conversations3">
         <item name="android:windowBackground">@drawable/background_splash_screen</item>
         <item name="android:windowLightStatusBar">true</item>
+        <item name="android:windowFullscreen">true</item>
     </style>
 
     <style name="Theme.Conversations3.Dialog" parent="@style/Theme.Conversations3">

src/quicksy/res/layout/activity_enter_number.xml 🔗

@@ -5,6 +5,7 @@
     <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="match_parent"
+        android:fitsSystemWindows="true"
         android:orientation="vertical">
 
         <com.google.android.material.appbar.AppBarLayout

src/quicksy/res/layout/activity_tos.xml 🔗

@@ -4,6 +4,7 @@
     <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="match_parent"
+        android:fitsSystemWindows="true"
         android:orientation="vertical">
 
         <com.google.android.material.appbar.AppBarLayout

src/quicksy/res/layout/activity_verify.xml 🔗

@@ -4,6 +4,7 @@
     <LinearLayout
         android:layout_width="match_parent"
         android:layout_height="match_parent"
+        android:fitsSystemWindows="true"
         android:orientation="vertical">
 
         <com.google.android.material.appbar.AppBarLayout