Detailed changes
@@ -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>
@@ -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;
+ };
}
}
}
@@ -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("<","<");
- }
-
- 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("<", "<");
+ }
+
+ 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;
+ }
+ }
}
@@ -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());
+ }
+ }
+}
@@ -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);
@@ -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);
+ }
}
}
@@ -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(
@@ -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";
}
@@ -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;
@@ -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;
}
@@ -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);
@@ -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"
@@ -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>