diff --git a/app/src/main/java/im/conversations/android/database/dao/DiscoDao.java b/app/src/main/java/im/conversations/android/database/dao/DiscoDao.java index 90ea69f5d..92599165f 100644 --- a/app/src/main/java/im/conversations/android/database/dao/DiscoDao.java +++ b/app/src/main/java/im/conversations/android/database/dao/DiscoDao.java @@ -8,6 +8,7 @@ import androidx.room.Query; import androidx.room.Transaction; import com.google.common.base.Strings; import com.google.common.collect.Collections2; +import com.google.common.util.concurrent.ListenableFuture; import im.conversations.android.database.entity.DiscoEntity; import im.conversations.android.database.entity.DiscoExtensionEntity; import im.conversations.android.database.entity.DiscoExtensionFieldEntity; @@ -16,6 +17,7 @@ import im.conversations.android.database.entity.DiscoFeatureEntity; import im.conversations.android.database.entity.DiscoIdentityEntity; import im.conversations.android.database.entity.DiscoItemEntity; import im.conversations.android.database.model.Account; +import im.conversations.android.database.model.DiscoItemWithExtension; import im.conversations.android.xmpp.Entity; import im.conversations.android.xmpp.EntityCapabilities; import im.conversations.android.xmpp.EntityCapabilities2; @@ -24,7 +26,9 @@ import im.conversations.android.xmpp.model.data.Value; import im.conversations.android.xmpp.model.disco.info.InfoQuery; import im.conversations.android.xmpp.model.disco.items.Item; import java.util.Collection; +import java.util.List; import org.jxmpp.jid.BareJid; +import org.jxmpp.jid.DomainBareJid; import org.jxmpp.jid.Jid; import org.jxmpp.jid.parts.Resourcepart; @@ -223,4 +227,11 @@ public abstract class DiscoDao { "Discovering features for %s is not implemented", entity.getClass().getName())); } + + @Query( + "SELECT disco_item.discoId,address FROM disco_item JOIN disco_feature ON" + + " disco_item.discoId=disco_feature.discoId WHERE feature=:feature AND" + + " (address=:address OR parentAddress=:address)") + public abstract ListenableFuture> getItemByFeature( + DomainBareJid address, final String feature); } diff --git a/app/src/main/java/im/conversations/android/database/model/DiscoExtension.java b/app/src/main/java/im/conversations/android/database/model/DiscoExtension.java new file mode 100644 index 000000000..0fc8424cb --- /dev/null +++ b/app/src/main/java/im/conversations/android/database/model/DiscoExtension.java @@ -0,0 +1,36 @@ +package im.conversations.android.database.model; + +import androidx.room.Relation; +import com.google.common.collect.Iterables; +import im.conversations.android.database.entity.DiscoExtensionFieldEntity; +import im.conversations.android.database.entity.DiscoExtensionFieldValueEntity; +import java.util.List; + +public class DiscoExtension { + + public long id; + public String type; + + @Relation( + entity = DiscoExtensionFieldEntity.class, + parentColumn = "id", + entityColumn = "extensionId") + public List fields; + + public Field getField(final String name) { + return Iterables.find(fields, f -> name.equals(f.field), null); + } + + public static class Field { + + public long id; + public String field; + + @Relation( + entity = DiscoExtensionFieldValueEntity.class, + parentColumn = "id", + entityColumn = "fieldId", + projection = {"value"}) + public List values; + } +} diff --git a/app/src/main/java/im/conversations/android/database/model/DiscoItemWithExtension.java b/app/src/main/java/im/conversations/android/database/model/DiscoItemWithExtension.java new file mode 100644 index 000000000..885f3b01f --- /dev/null +++ b/app/src/main/java/im/conversations/android/database/model/DiscoItemWithExtension.java @@ -0,0 +1,23 @@ +package im.conversations.android.database.model; + +import androidx.room.Relation; +import com.google.common.collect.Iterables; +import im.conversations.android.database.entity.DiscoExtensionEntity; +import java.util.List; +import org.jxmpp.jid.Jid; + +public class DiscoItemWithExtension { + + public Long discoId; + public Jid address; + + @Relation( + entity = DiscoExtensionEntity.class, + parentColumn = "discoId", + entityColumn = "discoId") + public List extensions; + + public DiscoExtension getExtension(final String type) { + return Iterables.find(extensions, e -> type.equals(e.type), null); + } +} diff --git a/app/src/main/java/im/conversations/android/xmpp/manager/BookmarkManager.java b/app/src/main/java/im/conversations/android/xmpp/manager/BookmarkManager.java index 912feeae7..c8b6df67e 100644 --- a/app/src/main/java/im/conversations/android/xmpp/manager/BookmarkManager.java +++ b/app/src/main/java/im/conversations/android/xmpp/manager/BookmarkManager.java @@ -17,6 +17,7 @@ import im.conversations.android.xmpp.model.pubsub.event.Retract; import java.util.Collection; import java.util.Map; import java.util.Objects; +import org.jxmpp.jid.BareJid; import org.jxmpp.jid.Jid; import org.jxmpp.jid.impl.JidCreate; import org.slf4j.Logger; @@ -86,7 +87,7 @@ public class BookmarkManager extends AbstractManager { } } - public ListenableFuture publishBookmark(final Jid address, final boolean autoJoin) { + public ListenableFuture publishBookmark(final BareJid address, final boolean autoJoin) { return publishBookmark(address, autoJoin, null); } diff --git a/app/src/main/java/im/conversations/android/xmpp/manager/DiscoManager.java b/app/src/main/java/im/conversations/android/xmpp/manager/DiscoManager.java index 49e896462..f44ab98c7 100644 --- a/app/src/main/java/im/conversations/android/xmpp/manager/DiscoManager.java +++ b/app/src/main/java/im/conversations/android/xmpp/manager/DiscoManager.java @@ -13,6 +13,7 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import im.conversations.android.BuildConfig; import im.conversations.android.R; +import im.conversations.android.database.model.DiscoItemWithExtension; import im.conversations.android.xml.Namespace; import im.conversations.android.xmpp.Entity; import im.conversations.android.xmpp.EntityCapabilities; @@ -240,6 +241,13 @@ public class DiscoManager extends AbstractManager { MoreExecutors.directExecutor()); } + public ListenableFuture> getServerItemByFeature( + final String feature) { + return getDatabase() + .discoDao() + .getItemByFeature(getAccount().address.asDomainBareJid(), feature); + } + public boolean hasFeature(final Entity entity, final String feature) { return getDatabase().discoDao().hasFeature(getAccount().id, entity, feature); } diff --git a/app/src/main/java/im/conversations/android/xmpp/manager/HttpUploadManager.java b/app/src/main/java/im/conversations/android/xmpp/manager/HttpUploadManager.java index 9ef6be4bd..bd184ab2f 100644 --- a/app/src/main/java/im/conversations/android/xmpp/manager/HttpUploadManager.java +++ b/app/src/main/java/im/conversations/android/xmpp/manager/HttpUploadManager.java @@ -1,11 +1,166 @@ package im.conversations.android.xmpp.manager; import android.content.Context; +import androidx.annotation.NonNull; +import com.google.common.base.MoreObjects; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.google.common.primitives.Longs; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import im.conversations.android.database.model.DiscoItemWithExtension; +import im.conversations.android.xml.Namespace; import im.conversations.android.xmpp.XmppConnection; +import im.conversations.android.xmpp.model.stanza.Iq; +import im.conversations.android.xmpp.model.upload.Get; +import im.conversations.android.xmpp.model.upload.Header; +import im.conversations.android.xmpp.model.upload.Put; +import im.conversations.android.xmpp.model.upload.Request; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import org.jxmpp.jid.Jid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class HttpUploadManager extends AbstractManager { + private static final List ALLOWED_HEADERS = + Arrays.asList("Authorization", "Cookie", "Expires"); + + private static final Logger LOGGER = LoggerFactory.getLogger(HttpUploadManager.class); + public HttpUploadManager(Context context, XmppConnection connection) { super(context, connection); } + + public ListenableFuture request( + final String filename, final long size, final MediaType mediaType) { + return Futures.transformAsync( + getManager(DiscoManager.class).getServerItemByFeature(Namespace.HTTP_UPLOAD), + items -> { + final List services = Service.of(items); + final Service service = + Iterables.find( + services, + s -> s.maxFileSize == 0 || s.maxFileSize >= size, + null); + if (service == null) { + throw new IllegalStateException( + String.format( + "No upload service found that can handle files of size %d", + size)); + } + LOGGER.info("Requesting slot from {}", service.address); + return request(service.address, filename, size, mediaType); + }, + MoreExecutors.directExecutor()); + } + + private ListenableFuture request( + final Jid address, final String filename, final long size, final MediaType mediaType) { + final var iq = new Iq(Iq.Type.GET); + iq.setTo(address); + final var request = iq.addExtension(new Request()); + request.setFilename(filename); + request.setSize(size); + request.setContentType(mediaType.type()); + final var iqFuture = connection.sendIqPacket(iq); + // catch and rethrow 'file-too-large' + return Futures.transform( + iqFuture, + result -> { + final var slot = + result.getExtension( + im.conversations.android.xmpp.model.upload.Slot.class); + if (slot == null) { + throw new IllegalStateException("No slot in response"); + } + final var get = slot.getExtension(Get.class); + final var put = slot.getExtension(Put.class); + final var getUrl = get == null ? null : get.getUrl(); + final var putUrl = put == null ? null : put.getUrl(); + if (get == null || put == null) { + throw new IllegalStateException("Missing put or get URL in response"); + } + final ImmutableMap.Builder headers = + new ImmutableMap.Builder<>(); + for (final Header header : put.getHeaders()) { + final String name = header.getHeaderName(); + final String value = header.getContent(); + if (value != null && ALLOWED_HEADERS.contains(name)) { + headers.put(name, value); + } + } + return new Slot(putUrl, headers.buildKeepingLast(), getUrl); + }, + MoreExecutors.directExecutor()); + } + + public static class Slot { + public final HttpUrl put; + public final Map headers; + public final HttpUrl get; + + public Slot(HttpUrl put, final Map headers, final HttpUrl get) { + this.put = put; + this.headers = headers; + this.get = get; + } + + @NonNull + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("put", put) + .add("headers", headers) + .add("get", get) + .toString(); + } + } + + private static class Service { + public final Jid address; + public final long maxFileSize; + + @NonNull + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("address", address) + .add("maxFileSize", maxFileSize) + .toString(); + } + + private Service(Jid address, long maxFileSize) { + this.address = address; + this.maxFileSize = maxFileSize; + } + + public static List of(List items) { + return Lists.transform(items, Service::of); + } + + private static Service of(final DiscoItemWithExtension item) { + final var discoExtension = item.getExtension(Namespace.HTTP_UPLOAD); + final long maxFileSize; + if (discoExtension == null) { + maxFileSize = 0; + } else { + final var field = discoExtension.getField("max-file-size"); + final var value = field == null ? null : Iterables.getFirst(field.values, null); + if (Strings.isNullOrEmpty(value)) { + maxFileSize = 0; + } else { + maxFileSize = Longs.tryParse(value); + } + } + return new Service(item.address, maxFileSize); + } + } } diff --git a/app/src/main/java/im/conversations/android/xmpp/manager/MultiUserChatManager.java b/app/src/main/java/im/conversations/android/xmpp/manager/MultiUserChatManager.java index ad33c929c..11bae2029 100644 --- a/app/src/main/java/im/conversations/android/xmpp/manager/MultiUserChatManager.java +++ b/app/src/main/java/im/conversations/android/xmpp/manager/MultiUserChatManager.java @@ -63,7 +63,8 @@ public class MultiUserChatManager extends AbstractManager { } private void enterExisting(final MucWithNick mucWithNick, final InfoQuery infoQuery) { - if (infoQuery.hasFeature(Namespace.MUC)) { + if (infoQuery.hasFeature(Namespace.MUC) + && infoQuery.hasIdentityWithCategory("conference")) { sendJoinPresence(mucWithNick); } else { getDatabase().chatDao().setMucState(mucWithNick.chatId, MucState.NOT_A_MUC); @@ -91,8 +92,6 @@ public class MultiUserChatManager extends AbstractManager { final MucUser mucUser = presencePacket.getExtension(MucUser.class); Preconditions.checkArgument( mucUser.getStatus().contains(MucUser.STATUS_CODE_SELF_PRESENCE)); - // TODO flag chat as joined - LOGGER.info("Received self presence for {}", presencePacket.getFrom()); final var database = getDatabase(); database.runInTransaction( () -> { @@ -107,7 +106,6 @@ public class MultiUserChatManager extends AbstractManager { "Available presence received for archived or non existent chat"); return; } - // TODO set status codes database.chatDao() .setMucState( chatIdentifier.id, MucState.AVAILABLE, mucUser.getStatus()); @@ -132,8 +130,11 @@ public class MultiUserChatManager extends AbstractManager { } else if (chatIdentifier.archived) { database.chatDao().setMucState(chatIdentifier.id, null); } else { - // TODO set status codes - database.chatDao().setMucState(chatIdentifier.id, MucState.UNAVAILABLE); + database.chatDao() + .setMucState( + chatIdentifier.id, + MucState.UNAVAILABLE, + mucUser.getStatus()); } }); } diff --git a/app/src/main/java/im/conversations/android/xmpp/model/disco/info/InfoQuery.java b/app/src/main/java/im/conversations/android/xmpp/model/disco/info/InfoQuery.java index 5c75f7211..55f104e25 100644 --- a/app/src/main/java/im/conversations/android/xmpp/model/disco/info/InfoQuery.java +++ b/app/src/main/java/im/conversations/android/xmpp/model/disco/info/InfoQuery.java @@ -31,4 +31,8 @@ public class InfoQuery extends Extension { public Collection getIdentities() { return this.getExtensions(Identity.class); } + + public boolean hasIdentityWithCategory(final String category) { + return Iterables.any(getIdentities(), i -> category.equals(i.getCategory())); + } } diff --git a/app/src/main/java/im/conversations/android/xmpp/model/upload/Get.java b/app/src/main/java/im/conversations/android/xmpp/model/upload/Get.java new file mode 100644 index 000000000..5fad9afd4 --- /dev/null +++ b/app/src/main/java/im/conversations/android/xmpp/model/upload/Get.java @@ -0,0 +1,22 @@ +package im.conversations.android.xmpp.model.upload; + +import com.google.common.base.Strings; +import im.conversations.android.annotation.XmlElement; +import im.conversations.android.xmpp.model.Extension; +import okhttp3.HttpUrl; + +@XmlElement +public class Get extends Extension { + + public Get() { + super(Get.class); + } + + public HttpUrl getUrl() { + final var url = this.getAttribute("url"); + if (Strings.isNullOrEmpty(url)) { + return null; + } + return HttpUrl.parse(url); + } +} diff --git a/app/src/main/java/im/conversations/android/xmpp/model/upload/Header.java b/app/src/main/java/im/conversations/android/xmpp/model/upload/Header.java new file mode 100644 index 000000000..00546d0d9 --- /dev/null +++ b/app/src/main/java/im/conversations/android/xmpp/model/upload/Header.java @@ -0,0 +1,16 @@ +package im.conversations.android.xmpp.model.upload; + +import im.conversations.android.annotation.XmlElement; +import im.conversations.android.xmpp.model.Extension; + +@XmlElement +public class Header extends Extension { + + public Header() { + super(Header.class); + } + + public String getHeaderName() { + return this.getAttribute("name"); + } +} diff --git a/app/src/main/java/im/conversations/android/xmpp/model/upload/Put.java b/app/src/main/java/im/conversations/android/xmpp/model/upload/Put.java new file mode 100644 index 000000000..1b52a495c --- /dev/null +++ b/app/src/main/java/im/conversations/android/xmpp/model/upload/Put.java @@ -0,0 +1,27 @@ +package im.conversations.android.xmpp.model.upload; + +import com.google.common.base.Strings; +import im.conversations.android.annotation.XmlElement; +import im.conversations.android.xmpp.model.Extension; +import java.util.Collection; +import okhttp3.HttpUrl; + +@XmlElement +public class Put extends Extension { + + public Put() { + super(Put.class); + } + + public HttpUrl getUrl() { + final var url = this.getAttribute("url"); + if (Strings.isNullOrEmpty(url)) { + return null; + } + return HttpUrl.parse(url); + } + + public Collection
getHeaders() { + return this.getExtensions(Header.class); + } +} diff --git a/app/src/main/java/im/conversations/android/xmpp/model/upload/Request.java b/app/src/main/java/im/conversations/android/xmpp/model/upload/Request.java new file mode 100644 index 000000000..bbf8a98c1 --- /dev/null +++ b/app/src/main/java/im/conversations/android/xmpp/model/upload/Request.java @@ -0,0 +1,24 @@ +package im.conversations.android.xmpp.model.upload; + +import im.conversations.android.annotation.XmlElement; +import im.conversations.android.xmpp.model.Extension; + +@XmlElement +public class Request extends Extension { + + public Request() { + super(Request.class); + } + + public void setFilename(String filename) { + this.setAttribute("filename", filename); + } + + public void setSize(long size) { + this.setAttribute("size", size); + } + + public void setContentType(String type) { + this.setAttribute("content-ype", type); + } +} diff --git a/app/src/main/java/im/conversations/android/xmpp/model/upload/Slot.java b/app/src/main/java/im/conversations/android/xmpp/model/upload/Slot.java new file mode 100644 index 000000000..df9015781 --- /dev/null +++ b/app/src/main/java/im/conversations/android/xmpp/model/upload/Slot.java @@ -0,0 +1,12 @@ +package im.conversations.android.xmpp.model.upload; + +import im.conversations.android.annotation.XmlElement; +import im.conversations.android.xmpp.model.Extension; + +@XmlElement +public class Slot extends Extension { + + public Slot() { + super(Slot.class); + } +} diff --git a/app/src/main/java/im/conversations/android/xmpp/model/upload/package-info.java b/app/src/main/java/im/conversations/android/xmpp/model/upload/package-info.java new file mode 100644 index 000000000..eb0be49cb --- /dev/null +++ b/app/src/main/java/im/conversations/android/xmpp/model/upload/package-info.java @@ -0,0 +1,5 @@ +@XmlPackage(namespace = Namespace.HTTP_UPLOAD) +package im.conversations.android.xmpp.model.upload; + +import im.conversations.android.annotation.XmlPackage; +import im.conversations.android.xml.Namespace;