diff --git a/conversations.doap b/conversations.doap index 89132d021139da15ac127e11e0864754d730c742..e402ff875d9811b2e758457e9aceaa4b5a441ef4 100644 --- a/conversations.doap +++ b/conversations.doap @@ -489,6 +489,13 @@ complete 0.1.0 + + + + + complete + 0.3.0 + diff --git a/src/main/java/eu/siacs/conversations/entities/Account.java b/src/main/java/eu/siacs/conversations/entities/Account.java index 075ee301aa08380e072f67cf3b94dfdb02b5cf07..6673f7dced1706ca795e1d19f51d1f6bc724fb6d 100644 --- a/src/main/java/eu/siacs/conversations/entities/Account.java +++ b/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; + }; } } } diff --git a/src/main/java/eu/siacs/conversations/entities/ServiceDiscoveryResult.java b/src/main/java/eu/siacs/conversations/entities/ServiceDiscoveryResult.java index 3f2d2a5adf7f907a58c5866a29ead6d9e7a71585..3a99f6ca28e5b26872119b6ba188c31e1231232e 100644 --- a/src/main/java/eu/siacs/conversations/entities/ServiceDiscoveryResult.java +++ b/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 features; - protected final List forms; - private final List 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 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 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 getIdentities() { - return this.identities; - } - - public List 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 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 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 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 features; + protected final List forms; + private final List 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 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 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 getIdentities() { + return this.identities; + } + + public List 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 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 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 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 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 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 { - 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 { + 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; + } + } } diff --git a/src/main/java/eu/siacs/conversations/http/ServiceOutageStatus.java b/src/main/java/eu/siacs/conversations/http/ServiceOutageStatus.java new file mode 100644 index 0000000000000000000000000000000000000000..f74a9e4d7ff760d7c09edfd95ef2ed2e104e5fd8 --- /dev/null +++ b/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 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 message; + + public ServiceOutageStatus( + final boolean planned, + final Instant beginning, + final Instant expectedEnd, + final Map 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 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 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 { + @Override + public Instant deserialize( + JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + return Instant.parse(json.getAsString()); + } + } +} diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 900028152aa061bcc31767fdb2b0e12dfc03abdb..849831940de0fa7cb906a6f72acf9d00cc7fdead 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/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); diff --git a/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java b/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java index 728af3cd4a752eb19d04340dfc42661b147dc8e9..2cb112170ca6e65f9f49d29deb6beb4625709fef 100644 --- a/src/main/java/eu/siacs/conversations/ui/EditAccountActivity.java +++ b/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 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); + } } } diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java index 96b1470c37a0fa01bc0eee516dae7cbe2d55045b..470dd69475db6e8ca1626273d02f6eef2fba8887 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java @@ -49,11 +49,25 @@ public class AccountAdapter extends ArrayAdapter { } 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( diff --git a/src/main/java/eu/siacs/conversations/xml/Namespace.java b/src/main/java/eu/siacs/conversations/xml/Namespace.java index 9095de54e8cc5e076dbec2843ae0e5334fdaa53f..15d88f75aaa6a0800774a945a78b1101754602f6 100644 --- a/src/main/java/eu/siacs/conversations/xml/Namespace.java +++ b/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"; } diff --git a/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java b/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java index 953e6e1cc176725eb53f6dce0b75e318a03fdd1f..11c6df3bbb0995af4311d2c09c9cb98d8444b9f1 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/XmppConnection.java +++ b/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; diff --git a/src/main/java/eu/siacs/conversations/xmpp/pep/PublishOptions.java b/src/main/java/eu/siacs/conversations/xmpp/pep/PublishOptions.java index c9b764752d3bbea3225ffa4b67bb1a3a75b31730..2dcaf9bac7b07c6cc89d7d7a3b6db18789f59fc7 100644 --- a/src/main/java/eu/siacs/conversations/xmpp/pep/PublishOptions.java +++ b/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; } diff --git a/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java b/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java index 1423df7f50239d8511b511e3cb25c94c5a9fdd2f..3230185b648a92acc122d474f13a4f265572b1cf 100644 --- a/src/main/java/im/conversations/android/xmpp/processor/BindProcessor.java +++ b/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); diff --git a/src/main/res/layout/activity_edit_account.xml b/src/main/res/layout/activity_edit_account.xml index 45d8710d03673fb3208b6a006993cd28d0c72c5f..57db26b861b3883d97286452b5cfc6612770683c 100644 --- a/src/main/res/layout/activity_edit_account.xml +++ b/src/main/res/layout/activity_edit_account.xml @@ -146,6 +146,55 @@ + + + + + + + + + + + + + + Would you like to delete your avatar? Some clients might continue to display a cached copy of your avatar. Show to contacts only Backup location + Planned Downtime + Service Down (Known Issue) + The service is scheduled to return at %s