implement Service Outage Status and show message in account screen

Daniel Gultsch created

Change summary

conversations.doap                                                        |   7 
src/main/java/eu/siacs/conversations/entities/Account.java                | 128 
src/main/java/eu/siacs/conversations/entities/ServiceDiscoveryResult.java | 662 
src/main/java/eu/siacs/conversations/http/ServiceOutageStatus.java        | 162 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java  |  37 
src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java          |  41 
src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java       |  20 
src/main/java/eu/siacs/conversations/xml/Namespace.java                   |   1 
src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java             |  14 
src/main/java/eu/siacs/conversations/xmpp/pep/PublishOptions.java         |   2 
src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java  |  24 
src/main/res/layout/activity_edit_account.xml                             |  49 
src/main/res/values/strings.xml                                           |   3 
13 files changed, 728 insertions(+), 422 deletions(-)

Detailed changes

conversations.doap 🔗

@@ -489,6 +489,13 @@
             <xmpp:status>complete</xmpp:status>
             <xmpp:version>0.1.0</xmpp:version>
         </xmpp:SupportedXep>
+    </implements>
+        <implements>
+        <xmpp:SupportedXep>
+            <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0455.html"/>
+            <xmpp:status>complete</xmpp:status>
+            <xmpp:version>0.3.0</xmpp:version>
+        </xmpp:SupportedXep>
     </implements>
     <implements>
         <xmpp:SupportedXep>

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

@@ -18,6 +18,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;
@@ -73,6 +74,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;
@@ -105,6 +107,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
     private String pinnedChannelBinding;
     private String fastMechanism;
     private String fastToken;
+    private ServiceOutageStatus serviceOutageStatus;
 
     public Account(final Jid jid, final String password) {
         this(
@@ -783,6 +786,22 @@ 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 && ServiceOutageStatus.isPossibleOutage(this.status)) {
+            return sos.isNow();
+        }
+        return false;
+    }
+
     public enum State {
         DISABLED(false, false),
         LOGGED_OUT(false, false),
@@ -844,78 +863,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/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,337 +16,334 @@ 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 boolean hasIdentity(String category, String type) {
-		for (Identity id : this.getIdentities()) {
-			if ((category == null || id.getCategory().equals(category)) &&
-					(type == null || id.getType().equals(type))) {
-				return true;
-			}
-		}
-
-		return false;
-	}
-
-	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 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 boolean hasIdentity(String category, String type) {
+        for (Identity id : this.getIdentities()) {
+            if ((category == null || id.getCategory().equals(category))
+                    && (type == null || id.getType().equals(type))) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    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/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/services/XmppConnectionService.java 🔗

@@ -58,6 +58,9 @@ import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Maps;
+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;
@@ -87,6 +90,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;
@@ -170,6 +174,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;
@@ -358,6 +363,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();
 
@@ -489,6 +498,7 @@ public class XmppConnectionService extends Service {
                     getNotificationService().updateErrorNotification();
                 }
             };
+
     private OpenPgpServiceConnection pgpServiceConnection;
     private PgpEngine mPgpEngine = null;
     private WakeLock wakeLock;
@@ -1133,6 +1143,33 @@ 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);
+                    }
+
+                    @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);

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

@@ -18,6 +18,7 @@ import android.security.KeyChainAliasCallback;
 import android.text.Editable;
 import android.text.TextUtils;
 import android.text.TextWatcher;
+import android.text.format.DateUtils;
 import android.util.Log;
 import android.view.Menu;
 import android.view.MenuItem;
@@ -503,7 +504,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());
@@ -905,9 +906,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);
@@ -1400,6 +1401,7 @@ public class EditAccountActivity extends OmemoActivity
             } else {
                 this.binding.otherDeviceKeysCard.setVisibility(View.GONE);
             }
+            this.binding.serviceOutage.setVisibility(View.GONE);
         } else {
             final TextInputLayout errorLayout;
             final var status = this.mAccount.getStatus();
@@ -1428,6 +1430,39 @@ public class EditAccountActivity extends OmemoActivity
             removeErrorsOnAllBut(errorLayout);
             this.binding.stats.setVisibility(View.GONE);
             this.binding.otherDeviceKeysCard.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 {
+                    this.binding.sosMessage.setText(sosMessage);
+                    this.binding.sosMessage.setVisibility(View.VISIBLE);
+                }
+                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_ABBREV_ALL
+                                                    | DateUtils.FORMAT_SHOW_DATE)));
+                }
+            } else {
+                this.binding.serviceOutage.setVisibility(View.GONE);
+            }
         }
     }
 

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

@@ -49,11 +49,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/xml/Namespace.java 🔗

@@ -113,4 +113,5 @@ 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/xmpp/XmppConnection.java 🔗

@@ -3138,6 +3138,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/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/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_edit_account.xml 🔗

@@ -146,6 +146,55 @@
                     </RelativeLayout>
                 </com.google.android.material.card.MaterialCardView>
 
+                <com.google.android.material.card.MaterialCardView
+                    style="?attr/materialCardViewElevatedStyle"
+                    android:id="@+id/service_outage"
+                    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"
+                    tools:visibility="visible"
+                    app:cardBackgroundColor="?colorErrorContainer">
+
+                    <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:textColor="?colorOnErrorContainer"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:text="@string/account_status_service_outage_known"
+                            android:textAppearance="?textAppearanceTitleLarge" />
+
+                        <TextView
+                            android:textColor="?colorOnErrorContainer"
+                            android:id="@+id/sos_message"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginTop="8dp"
+                            tools:text="Our service is currently performing server updates"
+                            android:textAppearance="?textAppearanceBodyMedium" />
+
+                        <TextView
+                            android:textColor="?colorOnErrorContainer"
+                            android:id="@+id/sos_scheduled_end"
+                            android:layout_width="wrap_content"
+                            android:layout_height="wrap_content"
+                            android:layout_marginTop="8dp"
+                            tools:text="@string/sos_scheduled_return"
+                            android:textAppearance="?textAppearanceBodyMedium" />
+                    </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/values/strings.xml 🔗

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