From 0f5297c957ba28193f2c9a682dd205215e32cee1 Mon Sep 17 00:00:00 2001 From: Fernando Ramirez Date: Thu, 28 Jan 2021 18:25:25 +0100 Subject: [PATCH] XEP-0084: User Avatar Co-authored-by: Paul Schaub --- documentation/extensions/index.md | 1 + .../smack/util/XmlStringBuilder.java | 8 + .../smackx/avatar/AvatarMetadataStore.java | 46 ++ .../avatar/MemoryAvatarMetadataStore.java | 84 +++ .../smackx/avatar/MetadataInfo.java | 127 +++++ .../smackx/avatar/MetadataPointer.java | 86 +++ .../smackx/avatar/UserAvatarException.java | 66 +++ .../smackx/avatar/UserAvatarManager.java | 504 ++++++++++++++++++ .../smackx/avatar/element/DataExtension.java | 102 ++++ .../avatar/element/MetadataExtension.java | 175 ++++++ .../smackx/avatar/element/package-info.java | 24 + .../avatar/listener/AvatarListener.java | 31 ++ .../smackx/avatar/listener/package-info.java | 24 + .../smackx/avatar/package-info.java | 24 + .../smackx/avatar/provider/DataProvider.java | 44 ++ .../avatar/provider/MetadataProvider.java | 156 ++++++ .../smackx/avatar/provider/package-info.java | 24 + .../extensions.providers | 12 + .../avatar/AvatarMetadataStoreTest.java | 54 ++ .../smackx/avatar/DataExtensionTest.java | 49 ++ .../smackx/avatar/MetadataExtensionTest.java | 214 ++++++++ .../smackx/avatar/MetadataInfoTest.java | 63 +++ .../smack/smackrepl/Avatar.java | 127 +++++ 23 files changed, 2045 insertions(+) create mode 100644 smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/AvatarMetadataStore.java create mode 100644 smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/MemoryAvatarMetadataStore.java create mode 100644 smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/MetadataInfo.java create mode 100644 smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/MetadataPointer.java create mode 100644 smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/UserAvatarException.java create mode 100644 smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/UserAvatarManager.java create mode 100644 smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/element/DataExtension.java create mode 100644 smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/element/MetadataExtension.java create mode 100644 smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/element/package-info.java create mode 100644 smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/listener/AvatarListener.java create mode 100644 smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/listener/package-info.java create mode 100644 smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/package-info.java create mode 100644 smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/provider/DataProvider.java create mode 100644 smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/provider/MetadataProvider.java create mode 100644 smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/provider/package-info.java create mode 100644 smack-extensions/src/test/java/org/jivesoftware/smackx/avatar/AvatarMetadataStoreTest.java create mode 100644 smack-extensions/src/test/java/org/jivesoftware/smackx/avatar/DataExtensionTest.java create mode 100644 smack-extensions/src/test/java/org/jivesoftware/smackx/avatar/MetadataExtensionTest.java create mode 100644 smack-extensions/src/test/java/org/jivesoftware/smackx/avatar/MetadataInfoTest.java create mode 100644 smack-repl/src/main/java/org/igniterealtime/smack/smackrepl/Avatar.java diff --git a/documentation/extensions/index.md b/documentation/extensions/index.md index 96501b39f..b26fff5db 100644 --- a/documentation/extensions/index.md +++ b/documentation/extensions/index.md @@ -58,6 +58,7 @@ Smack Extensions and currently supported XEPs of smack-extensions | Advanced Message Processing | [XEP-0079](https://xmpp.org/extensions/xep-0079.html) | n/a | Enables entities to request, and servers to perform, advanced processing of XMPP message stanzas. | | User Location | [XEP-0080](https://xmpp.org/extensions/xep-0080.html) | n/a | Enabled communicating information about the current geographical or physical location of an entity. | | XMPP Date Time Profiles | [XEP-0082](https://xmpp.org/extensions/xep-0082.html) | n/a | Standardization of Date and Time representation in XMPP. | +| User Avatar | [XEP-0084](https://xmpp.org/extensions/xep-0084.html) | 1.1.2 | Allows to exchange user avatars, which are small images or icons associated with human users. | | Chat State Notifications | [XEP-0085](https://xmpp.org/extensions/xep-0085.html) | n/a | Communicating the status of a user in a chat session. | | [Time Exchange](time.md) | [XEP-0090](https://xmpp.org/extensions/xep-0090.html) | n/a | Allows local time information to be shared between users. | | Software Version | [XEP-0092](https://xmpp.org/extensions/xep-0092.html) | n/a | Retrieve and announce the software application of an XMPP entity. | diff --git a/smack-core/src/main/java/org/jivesoftware/smack/util/XmlStringBuilder.java b/smack-core/src/main/java/org/jivesoftware/smack/util/XmlStringBuilder.java index 49972aacb..c8d23a917 100644 --- a/smack-core/src/main/java/org/jivesoftware/smack/util/XmlStringBuilder.java +++ b/smack-core/src/main/java/org/jivesoftware/smack/util/XmlStringBuilder.java @@ -18,6 +18,7 @@ package org.jivesoftware.smack.util; import java.io.IOException; import java.io.Writer; +import java.net.URL; import java.util.ArrayList; import java.util.Collection; import java.util.Date; @@ -361,6 +362,13 @@ public class XmlStringBuilder implements Appendable, CharSequence, Element { return this; } + public XmlStringBuilder optAttribute(String name, URL url) { + if (url != null) { + attribute(name, url.toExternalForm()); + } + return this; + } + /** * Same as {@link #optAttribute(String, CharSequence)}, but with a different method name. This method can be used if * the provided attribute value argument type causes ambiguity in method overloading. For example if the type is a diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/AvatarMetadataStore.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/AvatarMetadataStore.java new file mode 100644 index 000000000..6ee213558 --- /dev/null +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/AvatarMetadataStore.java @@ -0,0 +1,46 @@ +/** + * + * Copyright 2019 Paul Schaub + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.avatar; + +import org.jxmpp.jid.EntityBareJid; + +/** + * The {@link AvatarMetadataStore} interface defines methods used by the {@link UserAvatarManager} to determine, + * whether the client already has a local copy of a published avatar or if the user needs to be informed about the + * update in order to download the image. + */ +public interface AvatarMetadataStore { + + /** + * Determine, if the client already has a copy of the avatar with {@code itemId} available or not. + * + * @param jid {@link EntityBareJid} of the entity that published the avatar. + * @param itemId itemId of the avatar + * + * @return true if the client already has a local copy of the avatar, false otherwise + */ + boolean hasAvatarAvailable(EntityBareJid jid, String itemId); + + /** + * Mark the tuple (jid, itemId) as available. This means that the client already has a local copy of the avatar + * available and wishes not to be notified about this particular avatar anymore. + * + * @param jid {@link EntityBareJid} of the entity that published the avatar. + * @param itemId itemId of the avatar + */ + void setAvatarAvailable(EntityBareJid jid, String itemId); +} diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/MemoryAvatarMetadataStore.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/MemoryAvatarMetadataStore.java new file mode 100644 index 000000000..556034eca --- /dev/null +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/MemoryAvatarMetadataStore.java @@ -0,0 +1,84 @@ +/** + * + * Copyright 2020 Paul Schaub + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.avatar; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.jivesoftware.smack.util.HashCode; +import org.jivesoftware.smack.util.Objects; + +import org.jxmpp.jid.EntityBareJid; + +public class MemoryAvatarMetadataStore implements AvatarMetadataStore { + + private Map, Boolean> availabilityMap = new ConcurrentHashMap<>(); + + @Override + public boolean hasAvatarAvailable(EntityBareJid jid, String itemId) { + Boolean available = availabilityMap.get(new Tuple<>(jid, itemId)); + return available != null && available; + } + + @Override + public void setAvatarAvailable(EntityBareJid jid, String itemId) { + availabilityMap.put(new Tuple<>(jid, itemId), Boolean.TRUE); + } + + private static class Tuple { + private final A first; + private final B second; + + Tuple(A first, B second) { + this.first = first; + this.second = second; + } + + public A getFirst() { + return first; + } + + public B getSecond() { + return second; + } + + @Override + public int hashCode() { + return HashCode.builder() + .append(first) + .append(second) + .build(); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + if (!(obj instanceof Tuple)) { + return false; + } + + @SuppressWarnings("unchecked") Tuple other = (Tuple) obj; + return Objects.equals(getFirst(), other.getFirst()) + && Objects.equals(getSecond(), other.getSecond()); + } + } +} diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/MetadataInfo.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/MetadataInfo.java new file mode 100644 index 000000000..8972ee069 --- /dev/null +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/MetadataInfo.java @@ -0,0 +1,127 @@ +/** + * + * Copyright 2017 Fernando Ramirez, 2019 Paul Schaub + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.avatar; + +import java.net.URL; + +import org.jivesoftware.smack.datatypes.UInt16; +import org.jivesoftware.smack.datatypes.UInt32; +import org.jivesoftware.smack.util.StringUtils; + +/** + * User Avatar metadata info model class. + * + * @author Fernando Ramirez + * @author Paul Schaub + * @see XEP-0084: User + * Avatar + */ +public class MetadataInfo { + + public static final int MAX_HEIGHT = 65536; + public static final int MAX_WIDTH = 65536; + + private final String id; + private final URL url; + private final UInt32 bytes; + private final String type; + private final UInt16 height; + private final UInt16 width; + + /** + * MetadataInfo constructor. + * + * @param id SHA-1 hash of the image data + * @param url http(s) url of the image + * @param bytes size of the image in bytes + * @param type content type of the image + * @param pixelsHeight height of the image in pixels + * @param pixelsWidth width of the image in pixels + */ + public MetadataInfo(String id, URL url, long bytes, String type, int pixelsHeight, int pixelsWidth) { + this.id = StringUtils.requireNotNullNorEmpty(id, "ID is required."); + this.url = url; + if (bytes <= 0) { + throw new IllegalArgumentException("Number of bytes MUST be greater than 0."); + } + this.bytes = UInt32.from(bytes); + this.type = StringUtils.requireNotNullNorEmpty(type, "Content Type is required."); + if (pixelsHeight < 0 || pixelsHeight > MAX_HEIGHT) { + throw new IllegalArgumentException("Image height value must be between 0 and 65536."); + } + if (pixelsWidth < 0 || pixelsWidth > MAX_WIDTH) { + throw new IllegalArgumentException("Image width value must be between 0 and 65536."); + } + this.height = UInt16.from(pixelsHeight); + this.width = UInt16.from(pixelsWidth); + } + + /** + * Get the id. + * + * @return the id + */ + public String getId() { + return id; + } + + /** + * Get the url of the avatar image. + * + * @return the url + */ + public URL getUrl() { + return url; + } + + /** + * Get the amount of bytes. + * + * @return the amount of bytes + */ + public UInt32 getBytes() { + return bytes; + } + + /** + * Get the type. + * + * @return the type + */ + public String getType() { + return type; + } + + /** + * Get the height in pixels. + * + * @return the height in pixels + */ + public UInt16 getHeight() { + return height; + } + + /** + * Get the width in pixels. + * + * @return the width in pixels + */ + public UInt16 getWidth() { + return width; + } + +} diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/MetadataPointer.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/MetadataPointer.java new file mode 100644 index 000000000..6f2cff974 --- /dev/null +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/MetadataPointer.java @@ -0,0 +1,86 @@ +/** + * + * Copyright 2017 Fernando Ramirez + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.avatar; + +import java.util.Map; + +import org.jivesoftware.smack.util.StringUtils; + +/** + * User Avatar metadata pointer model class. + * A pointer element is used to point to an avatar which is not published via PubSub or HTTP, but provided by a + * third-party service. + * + * @author Fernando Ramirez + * @see XEP-0084: User Avatar + */ +public class MetadataPointer { + + private final String namespace; + private final Map fields; + + /** + * Metadata Pointer constructor. + * + * The following example + *
+     * {@code
+     * 
+     *     
+     *         Ancapistan
+     *         Kropotkin
+     *     
+     * 
+     * }
+     * 
+ * can be created by constructing the object like this: + *
+     * {@code
+     *     Map fields = new HashMap<>();
+     *     fields.add("game", "Ancapistan");
+     *     fields.add("character", "Kropotkin");
+     *     MetadataPointer pointer = new MetadataPointer("http://example.com/virtualworlds", fields);
+     * }
+     * 
+ * + * @param namespace namespace of the child element of the metadata pointer. + * @param fields fields of the child element as key, value pairs. + */ + public MetadataPointer(String namespace, Map fields) { + this.namespace = StringUtils.requireNotNullNorEmpty(namespace, "Namespace MUST NOT be null, nor empty."); + this.fields = fields; + } + + /** + * Get the namespace of the pointers child element. + * + * @return the namespace + */ + public String getNamespace() { + return namespace; + } + + /** + * Get the fields of the pointers child element. + * + * @return the fields + */ + public Map getFields() { + return fields; + } + +} diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/UserAvatarException.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/UserAvatarException.java new file mode 100644 index 000000000..314e1ddad --- /dev/null +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/UserAvatarException.java @@ -0,0 +1,66 @@ +/** + * + * Copyright 2019 Paul Schaub + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.avatar; + +public class UserAvatarException extends AssertionError { + + private static final long serialVersionUID = 1L; + + private UserAvatarException(String message) { + super(message); + } + + /** + * Exception that gets thrown, when the {@link org.jivesoftware.smackx.avatar.element.DataExtension DataExtensions} + * byte count does not match the {@link org.jivesoftware.smackx.avatar.element.MetadataExtension MetadataExtensions} + * 'bytes' value. + */ + public static class AvatarMetadataMismatchException extends UserAvatarException { + + private static final long serialVersionUID = 1L; + + public AvatarMetadataMismatchException(String message) { + super(message); + } + } + + /** + * Exception that gets thrown when the user tries to publish a {@link org.jivesoftware.smackx.avatar.element.MetadataExtension} + * that is missing a required {@link MetadataInfo} for an image of type {@link UserAvatarManager#TYPE_PNG}. + */ + public static class AvatarMetadataMissingPNGInfoException extends UserAvatarException { + + private static final long serialVersionUID = 1L; + + public AvatarMetadataMissingPNGInfoException(String message) { + super(message); + } + } + + /** + * Exception that gets thrown when the user tries to fetch a {@link org.jivesoftware.smackx.avatar.element.DataExtension} + * from PubSub using a {@link MetadataInfo} that does point to a HTTP resource. + */ + public static class NotAPubSubAvatarInfoElementException extends UserAvatarException { + + private static final long serialVersionUID = 1L; + + public NotAPubSubAvatarInfoElementException(String message) { + super(message); + } + } +} diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/UserAvatarManager.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/UserAvatarManager.java new file mode 100644 index 000000000..a240000ea --- /dev/null +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/UserAvatarManager.java @@ -0,0 +1,504 @@ +/** + * + * Copyright 2017 Fernando Ramirez, 2019 Paul Schaub + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.avatar; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; + +import org.jivesoftware.smack.Manager; +import org.jivesoftware.smack.SmackException.NoResponseException; +import org.jivesoftware.smack.SmackException.NotConnectedException; +import org.jivesoftware.smack.XMPPConnection; +import org.jivesoftware.smack.XMPPException.XMPPErrorException; +import org.jivesoftware.smack.packet.Message; +import org.jivesoftware.smack.util.SHA1; +import org.jivesoftware.smack.util.stringencoder.Base64; +import org.jivesoftware.smackx.avatar.element.DataExtension; +import org.jivesoftware.smackx.avatar.element.MetadataExtension; +import org.jivesoftware.smackx.avatar.listener.AvatarListener; +import org.jivesoftware.smackx.disco.ServiceDiscoveryManager; +import org.jivesoftware.smackx.pep.PepEventListener; +import org.jivesoftware.smackx.pep.PepManager; +import org.jivesoftware.smackx.pubsub.LeafNode; +import org.jivesoftware.smackx.pubsub.PayloadItem; +import org.jivesoftware.smackx.pubsub.PubSubException; +import org.jivesoftware.smackx.pubsub.PubSubManager; + +import org.jxmpp.jid.EntityBareJid; + +/** + *

User Avatar manager class.

+ * This manager allows publication of user avatar images via PubSub, as well as publication of + * {@link MetadataExtension MetadataExtensions} containing {@link MetadataInfo} elements for avatars + * available via PubSub, HTTP or external third-party services via {@link MetadataPointer} elements. + *

+ * The easiest way to publish a PNG avatar (support for PNG files is REQUIRED), is to use + * {@link #publishPNGAvatar(File, int, int)} which will publish the image data to PubSub and inform + * any subscribers via a metadata update. + *

+ * Uploading avatars via HTTP is not in the scope of this manager (you could use Smacks + * HTTPFileUploadManager from smack-experimental for that), but publishing metadata updates pointing + * to HTTP resources is supported. Use {@link #publishHttpPNGAvatarMetadata(String, URL, long, int, int)} for that. + *

+ * By calling {@link #enable()}, the {@link UserAvatarManager} will start receiving metadata updates from + * contacts and other entities. If you want to get informed about those updates, you can register listeners + * by calling {@link #addAvatarListener(AvatarListener)}. + *

+ * If you store avatars locally, it is recommended to also set an {@link AvatarMetadataStore}, which is responsible + * for keeping track of which avatar files are available locally. If you register such a store via + * {@link #setAvatarMetadataStore(AvatarMetadataStore)}, your registered {@link AvatarListener AvatarListeners} + * will only inform you about those avatars that are not yet locally available. + *

+ * To fetch an avatar from PubSub, use {@link #fetchAvatarFromPubSub(EntityBareJid, MetadataInfo)} which will + * retrieve the avatar data from PubSub and mark the avatar as locally available in the {@link AvatarMetadataStore} + * if one is registered. + *

+ * Fetching avatars published via HTTP is out of scope of this manager. If you do implement it, remember to mark the + * avatar as locally available in your {@link AvatarMetadataStore} after you retrieved it. + * + * @author Fernando Ramirez + * @author Paul Schaub + * @see XEP-0084: User + * Avatar + */ +public final class UserAvatarManager extends Manager { + + public static final String DATA_NAMESPACE = "urn:xmpp:avatar:data"; + public static final String METADATA_NAMESPACE = "urn:xmpp:avatar:metadata"; + public static final String FEATURE_METADATA = METADATA_NAMESPACE + "+notify"; + + private static final Map INSTANCES = new WeakHashMap<>(); + + public static final String TYPE_PNG = "image/png"; + public static final String TYPE_GIF = "image/gif"; + public static final String TYPE_JPEG = "image/jpeg"; + + private final PepManager pepManager; + private final ServiceDiscoveryManager serviceDiscoveryManager; + + private AvatarMetadataStore metadataStore; + private final Set avatarListeners = new HashSet<>(); + + /** + * Get the singleton instance of UserAvatarManager. + * + * @param connection {@link XMPPConnection}. + * @return the instance of UserAvatarManager + */ + public static synchronized UserAvatarManager getInstanceFor(XMPPConnection connection) { + UserAvatarManager userAvatarManager = INSTANCES.get(connection); + + if (userAvatarManager == null) { + userAvatarManager = new UserAvatarManager(connection); + INSTANCES.put(connection, userAvatarManager); + } + + return userAvatarManager; + } + + private UserAvatarManager(XMPPConnection connection) { + super(connection); + this.pepManager = PepManager.getInstanceFor(connection); + this.serviceDiscoveryManager = ServiceDiscoveryManager.getInstanceFor(connection); + } + + /** + * Returns true if User Avatar publishing is supported by the server. + * In order to support User Avatars the server must have support for XEP-0163: Personal Eventing Protocol (PEP). + * + * @return true if User Avatar is supported by the server. + * + * @see XEP-0163: Personal Eventing Protocol + * + * @throws NoResponseException if the server does not respond + * @throws XMPPErrorException if a protocol level error occurs + * @throws NotConnectedException if the connection is not connected + * @throws InterruptedException if the thread is interrupted + */ + public boolean isSupportedByServer() + throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { + return pepManager.isSupported(); + } + + /** + * Announce support for User Avatars and start receiving avatar updates. + */ + public void enable() { + pepManager.addPepEventListener(FEATURE_METADATA, MetadataExtension.class, metadataExtensionListener); + serviceDiscoveryManager.addFeature(FEATURE_METADATA); + } + + /** + * Stop receiving avatar updates. + */ + public void disable() { + serviceDiscoveryManager.removeFeature(FEATURE_METADATA); + pepManager.removePepEventListener(metadataExtensionListener); + } + + /** + * Set an {@link AvatarMetadataStore} which is used to store information about the local availability of avatar + * data. + * @param metadataStore metadata store + */ + public void setAvatarMetadataStore(AvatarMetadataStore metadataStore) { + this.metadataStore = metadataStore; + } + + /** + * Register an {@link AvatarListener} in order to be notified about incoming avatar metadata updates. + * + * @param listener listener + * @return true if the set of listeners did not already contain the listener + */ + public synchronized boolean addAvatarListener(AvatarListener listener) { + return avatarListeners.add(listener); + } + + /** + * Unregister an {@link AvatarListener} to stop being notified about incoming avatar metadata updates. + * + * @param listener listener + * @return true if the set of listeners contained the listener + */ + public synchronized boolean removeAvatarListener(AvatarListener listener) { + return avatarListeners.remove(listener); + } + + private LeafNode getOrCreateDataNode() + throws NoResponseException, NotConnectedException, InterruptedException, XMPPErrorException, PubSubException.NotALeafNodeException { + return pepManager.getPepPubSubManager().getOrCreateLeafNode(DATA_NAMESPACE); + } + + private LeafNode getOrCreateMetadataNode() + throws NoResponseException, NotConnectedException, InterruptedException, XMPPErrorException, PubSubException.NotALeafNodeException { + return pepManager.getPepPubSubManager().getOrCreateLeafNode(METADATA_NAMESPACE); + } + + /** + * Publish a PNG avatar and its metadata to PubSub. + * If you know what the dimensions of the image are, use {@link #publishPNGAvatar(byte[], int, int)} instead. + * + * @param data raw bytes of the avatar + * + * @throws XMPPErrorException if a protocol level error occurs + * @throws PubSubException.NotALeafNodeException if either the metadata node or the data node is not a + * {@link LeafNode} + * @throws NotConnectedException if the connection is not connected + * @throws InterruptedException if the thread is interrupted + * @throws NoResponseException if the server does not respond + */ + public void publishPNGAvatar(byte[] data) throws XMPPErrorException, PubSubException.NotALeafNodeException, + NotConnectedException, InterruptedException, NoResponseException { + publishPNGAvatar(data, 0, 0); + } + + /** + * Publish a PNG Avatar and its metadata to PubSub. + * + * @param data raw bytes of the avatar + * @param height height of the image in pixels + * @param width width of the image in pixels + * + * @throws XMPPErrorException if a protocol level error occurs + * @throws PubSubException.NotALeafNodeException if either the metadata node or the data node is not a + * {@link LeafNode} + * @throws NotConnectedException if the connection is not connected + * @throws InterruptedException if the thread is interrupted + * @throws NoResponseException if the server does not respond + */ + public void publishPNGAvatar(byte[] data, int height, int width) + throws XMPPErrorException, PubSubException.NotALeafNodeException, NotConnectedException, + InterruptedException, NoResponseException { + String id = publishPNGAvatarData(data); + publishPNGAvatarMetadata(id, data.length, height, width); + } + + /** + * Publish a PNG avatar and its metadata to PubSub. + * If you know the dimensions of the image, use {@link #publishPNGAvatar(File, int, int)} instead. + * + * @param pngFile PNG File + * + * @throws IOException if an {@link IOException} occurs while reading the file + * @throws XMPPErrorException if a protocol level error occurs + * @throws PubSubException.NotALeafNodeException if either the metadata node or the data node is not a valid + * {@link LeafNode} + * @throws NotConnectedException if the connection is not connected + * @throws InterruptedException if the thread is interrupted + * @throws NoResponseException if the server does not respond + */ + public void publishPNGAvatar(File pngFile) throws NotConnectedException, InterruptedException, + PubSubException.NotALeafNodeException, NoResponseException, IOException, XMPPErrorException { + publishPNGAvatar(pngFile, 0, 0); + } + + /** + * Publish a PNG avatar and its metadata to PubSub. + * + * @param pngFile PNG File + * @param height height of the image + * @param width width of the image + * + * @throws IOException if an {@link IOException} occurs while reading the file + * @throws XMPPErrorException if a protocol level error occurs + * @throws PubSubException.NotALeafNodeException if either the metadata node or the data node is not a valid + * {@link LeafNode} + * @throws NotConnectedException if the connection is not connected + * @throws InterruptedException if the thread is interrupted + * @throws NoResponseException if the server does not respond + */ + public void publishPNGAvatar(File pngFile, int height, int width) + throws IOException, XMPPErrorException, PubSubException.NotALeafNodeException, NotConnectedException, + InterruptedException, NoResponseException { + try (ByteArrayOutputStream out = new ByteArrayOutputStream((int) pngFile.length()); + InputStream in = new BufferedInputStream(new FileInputStream(pngFile))) { + byte[] buffer = new byte[4096]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + byte[] bytes = out.toByteArray(); + publishPNGAvatar(bytes, height, width); + } + } + + /** + * Fetch a published user avatar from their PubSub service. + * + * @param from {@link EntityBareJid} of the avatars owner + * @param metadataInfo {@link MetadataInfo} of the avatar that shall be fetched + * + * @return bytes of the avatar + * + * @throws InterruptedException if the thread gets interrupted + * @throws PubSubException.NotALeafNodeException if the data node is not a {@link LeafNode} + * @throws NoResponseException if the server does not respond + * @throws NotConnectedException if the connection is not connected + * @throws XMPPErrorException if a protocol level error occurs + * @throws PubSubException.NotAPubSubNodeException if the data node is not a valid PubSub node + * @throws UserAvatarException.AvatarMetadataMismatchException if the data in the data node does not match whats + * promised by the {@link MetadataInfo} element + * @throws UserAvatarException.NotAPubSubAvatarInfoElementException if the user tries to fetch the avatar using an + * info element that points to a HTTP resource + */ + public byte[] fetchAvatarFromPubSub(EntityBareJid from, MetadataInfo metadataInfo) + throws InterruptedException, PubSubException.NotALeafNodeException, NoResponseException, + NotConnectedException, XMPPErrorException, PubSubException.NotAPubSubNodeException, + UserAvatarException.AvatarMetadataMismatchException { + if (metadataInfo.getUrl() != null) { + throw new UserAvatarException.NotAPubSubAvatarInfoElementException("Provided MetadataInfo element points to " + + "a HTTP resource, not to a PubSub item."); + } + LeafNode dataNode = PubSubManager.getInstanceFor(connection(), from) + .getLeafNode(DATA_NAMESPACE); + + List> dataItems = dataNode.getItems(1, metadataInfo.getId()); + DataExtension data = dataItems.get(0).getPayload(); + if (data.getData().length != metadataInfo.getBytes().intValue()) { + throw new UserAvatarException.AvatarMetadataMismatchException("Avatar Data with itemId '" + metadataInfo.getId() + + "' of " + from.asUnescapedString() + " does not match the Metadata (metadata promises " + + metadataInfo.getBytes().intValue() + " bytes, data contains " + data.getData().length + " bytes)"); + } + if (metadataStore != null) { + metadataStore.setAvatarAvailable(from, metadataInfo.getId()); + } + return data.getData(); + } + + private String publishPNGAvatarData(byte[] data) + throws NoResponseException, NotConnectedException, XMPPErrorException, InterruptedException, + PubSubException.NotALeafNodeException { + String itemId = Base64.encodeToString(SHA1.bytes(data)); + publishAvatarData(data, itemId); + return itemId; + } + + /** + * Publish some avatar image data to PubSub. + * Note, that if the image is an image of type {@link #TYPE_PNG}, the itemId MUST be the SHA-1 sum of that image. + * If however the image is not of type {@link #TYPE_PNG}, the itemId MUST be the SHA-1 sum of the PNG encoded + * representation of this image (an avatar can be published in several image formats, but at least one of them + * must be of type {@link #TYPE_PNG}). + * + * @param data raw bytes of the image + * @param itemId SHA-1 sum of the PNG encoded representation of this image. + * + * @throws NoResponseException if the server does not respond + * @throws NotConnectedException if the connection is not connected + * @throws XMPPErrorException if a protocol level error occurs + * @throws InterruptedException if the thread is interrupted + * @throws PubSubException.NotALeafNodeException if the data node is not a {@link LeafNode}. + */ + public void publishAvatarData(byte[] data, String itemId) + throws NoResponseException, NotConnectedException, XMPPErrorException, InterruptedException, PubSubException.NotALeafNodeException { + DataExtension dataExtension = new DataExtension(data); + getOrCreateDataNode().publish(new PayloadItem<>(itemId, dataExtension)); + } + + /** + * Publish metadata about an avatar of type {@link #TYPE_PNG} to the metadata node. + * + * @param itemId SHA-1 sum of the image of type {@link #TYPE_PNG} + * @param info info element containing metadata of the file + * @param pointers optional list of metadata pointer elements + * + * @throws NoResponseException if the server does not respond + * @throws XMPPErrorException if a protocol level error occurs + * @throws NotConnectedException of the connection is not connected + * @throws InterruptedException if the thread is interrupted + * @throws PubSubException.NotALeafNodeException if the metadata node is not a {@link LeafNode} + * @throws UserAvatarException.AvatarMetadataMissingPNGInfoException if the info element does not point to an + * avatar image of type {@link #TYPE_PNG} available in PubSub. + */ + public void publishPNGAvatarMetadata(String itemId, MetadataInfo info, List pointers) + throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException, PubSubException.NotALeafNodeException { + publishAvatarMetadata(itemId, Collections.singletonList(info), pointers); + } + + /** + * Publish avatar metadata. + * The list of {@link MetadataInfo} elements can contain info about several image data types. However, there must + * be at least one {@link MetadataInfo} element about an image of type {@link #TYPE_PNG} which is destined to + * publication in PubSub. Its id MUST equal the itemId parameter. + * + * @param itemId SHA-1 sum of the avatar image representation of type {@link #TYPE_PNG} + * @param infos list of metadata elements + * @param pointers optional list of pointer elements + * + * @throws NoResponseException if the server does not respond + * @throws XMPPErrorException if a protocol level error occurs + * @throws NotConnectedException if the connection is not connected + * @throws InterruptedException if the thread is interrupted + * @throws PubSubException.NotALeafNodeException if the metadata node is not a {@link LeafNode} + * @throws UserAvatarException.AvatarMetadataMissingPNGInfoException if the list of {@link MetadataInfo} elements + * does not contain at least one PNG image + * + * @see + * §4.2.1 Info Element - About the restriction that at least one info element must describe a PNG image. + */ + public void publishAvatarMetadata(String itemId, List infos, List pointers) + throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException, PubSubException.NotALeafNodeException { + // Check if metadata extension contains at least one png image available in PubSub + boolean containsPng = false; + for (MetadataInfo info : infos) { + if (TYPE_PNG.equals(info.getType())) { + containsPng = true; + break; + } + } + if (!containsPng) { + throw new UserAvatarException.AvatarMetadataMissingPNGInfoException( + "The MetadataExtension must contain at least one info element describing an image of type " + + "\"" + TYPE_PNG + "\""); + } + + MetadataExtension metadataExtension = new MetadataExtension(infos, pointers); + getOrCreateMetadataNode().publish(new PayloadItem<>(itemId, metadataExtension)); + + if (metadataStore == null) { + return; + } + // Mark our own avatar as locally available so that we don't get updates for it + metadataStore.setAvatarAvailable(connection().getUser().asEntityBareJidOrThrow(), itemId); + } + + /** + * Publish metadata about a PNG avatar available via HTTP. + * This method can be used together with HTTP File Upload as an alternative to PubSub for avatar publishing. + * + * @param itemId SHA-1 sum of the avatar image file. + * @param url HTTP(S) Url of the image file. + * @param bytes size of the file in bytes + * @param pixelsHeight height of the image file in pixels + * @param pixelsWidth width of the image file in pixels + * + * @throws NoResponseException if the server does not respond + * @throws XMPPErrorException of a protocol level error occurs + * @throws NotConnectedException if the connection is not connected + * @throws InterruptedException if the thread is interrupted + * @throws PubSubException.NotALeafNodeException if the metadata node is not a {@link LeafNode} + */ + public void publishHttpPNGAvatarMetadata(String itemId, URL url, long bytes, + int pixelsHeight, int pixelsWidth) + throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException, PubSubException.NotALeafNodeException { + MetadataInfo info = new MetadataInfo(itemId, url, bytes, TYPE_PNG, pixelsHeight, pixelsWidth); + publishPNGAvatarMetadata(itemId, info, null); + } + + /** + * Publish avatar metadata about a PNG avatar with its size in pixels. + * + * @param itemId SHA-1 hash of the PNG encoded image + * @param bytes number of bytes of this particular image data array + * @param pixelsHeight height of this image in pixels + * @param pixelsWidth width of this image in pixels + * + * @throws NoResponseException if the server does not respond + * @throws XMPPErrorException if a protocol level error occurs + * @throws NotConnectedException if the connection is not connected + * @throws PubSubException.NotALeafNodeException if the metadata node is not a {@link LeafNode} + * @throws InterruptedException if the thread is interrupted + */ + public void publishPNGAvatarMetadata(String itemId, long bytes, int pixelsHeight, int pixelsWidth) + throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException, PubSubException.NotALeafNodeException { + MetadataInfo info = new MetadataInfo(itemId, null, bytes, TYPE_PNG, pixelsHeight, pixelsWidth); + publishPNGAvatarMetadata(itemId, info, null); + } + + /** + * Publish an empty metadata element to disable avatar publishing. + * + * @see §4.2 Metadata Element + * + * @throws NoResponseException if the server does not respond + * @throws XMPPErrorException if a protocol level error occurs + * @throws NotConnectedException if the connection is not connected + * @throws InterruptedException if the thread is interrupted + * @throws PubSubException.NotALeafNodeException if the metadata node is not a {@link LeafNode} + */ + public void unpublishAvatar() + throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException, PubSubException.NotALeafNodeException { + getOrCreateMetadataNode().publish(new PayloadItem<>(new MetadataExtension(null))); + } + + @SuppressWarnings("UnnecessaryAnonymousClass") + private final PepEventListener metadataExtensionListener = new PepEventListener() { + @Override + public void onPepEvent(EntityBareJid from, MetadataExtension event, String id, Message carrierMessage) { + if (metadataStore != null && metadataStore.hasAvatarAvailable(from, id)) { + // The metadata store implies that we have a local copy of the published image already. Skip. + return; + } + + for (AvatarListener listener : avatarListeners) { + listener.onAvatarUpdateReceived(from, event); + } + } + }; +} diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/element/DataExtension.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/element/DataExtension.java new file mode 100644 index 000000000..5456a8fda --- /dev/null +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/element/DataExtension.java @@ -0,0 +1,102 @@ +/** + * + * Copyright 2017 Fernando Ramirez, 2019 Paul Schaub + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.avatar.element; + +import org.jivesoftware.smack.packet.ExtensionElement; +import org.jivesoftware.smack.packet.XmlEnvironment; +import org.jivesoftware.smack.util.StringUtils; +import org.jivesoftware.smack.util.XmlStringBuilder; +import org.jivesoftware.smack.util.stringencoder.Base64; +import org.jivesoftware.smackx.avatar.UserAvatarManager; + +/** + * Data extension element class used to publish avatar image data via PubSub. + * Unlike the {@link MetadataExtension}, this class is dedicated to containing the avatar image data itself. + * + * @author Fernando Ramirez + * @see XEP-0084: User + * Avatar + */ +public class DataExtension implements ExtensionElement { + + public static final String ELEMENT = "data"; + public static final String NAMESPACE = UserAvatarManager.DATA_NAMESPACE; + + private final byte[] data; + private String data_b64; // lazy initialized base64 encoded copy of data + + /** + * Create a {@link DataExtension} from a byte array. + * + * @param data bytes of the image. + */ + public DataExtension(byte[] data) { + this.data = data; + } + + /** + * Create a {@link DataExtension} from a base64 encoded String. + * + * @param base64data bytes of the image as base64 string. + */ + public DataExtension(String base64data) { + this.data_b64 = StringUtils.requireNotNullNorEmpty(base64data, + "Base64 String MUST NOT be null, nor empty."); + this.data = Base64.decode(base64data); + } + + /** + * Get the bytes of the image. + * + * @return an immutable copy of the image data + */ + public byte[] getData() { + return data.clone(); + } + + /** + * Get the image data encoded as a base64 String. + * + * @return the data as String + */ + public String getDataAsString() { + if (data_b64 == null) { + data_b64 = Base64.encodeToString(data); + } + return data_b64; + } + + @Override + public String getElementName() { + return ELEMENT; + } + + @Override + public String getNamespace() { + return NAMESPACE; + } + + @Override + public XmlStringBuilder toXML(XmlEnvironment xmlEnvironment) { + XmlStringBuilder xml = new XmlStringBuilder(this, xmlEnvironment); + xml.rightAngleBracket(); + xml.escape(this.getDataAsString()); + xml.closeElement(this); + return xml; + } + +} diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/element/MetadataExtension.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/element/MetadataExtension.java new file mode 100644 index 000000000..f1fe0d3ac --- /dev/null +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/element/MetadataExtension.java @@ -0,0 +1,175 @@ +/** + * + * Copyright 2017 Fernando Ramirez, 2019 Paul Schaub + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.avatar.element; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.jivesoftware.smack.packet.ExtensionElement; +import org.jivesoftware.smack.packet.XmlEnvironment; +import org.jivesoftware.smack.util.XmlStringBuilder; +import org.jivesoftware.smackx.avatar.MetadataInfo; +import org.jivesoftware.smackx.avatar.MetadataPointer; +import org.jivesoftware.smackx.avatar.UserAvatarManager; + +/** + * Metadata extension element class. + * This class contains metadata about published avatars. + * + * @author Fernando Ramirez + * @author Paul Schaub + * @see XEP-0084: User + * Avatar + */ +public class MetadataExtension implements ExtensionElement { + + public static final String ELEMENT = "metadata"; + public static final String NAMESPACE = UserAvatarManager.METADATA_NAMESPACE; + + private final List infos; + private final List pointers; + + /** + * Metadata Extension constructor. + * + * @param infos list of {@link MetadataInfo} elements. + */ + public MetadataExtension(List infos) { + this(infos, null); + } + + /** + * Metadata Extension constructor. + * + * @param infos list of {@link MetadataInfo} elements + * @param pointers optional list of {@link MetadataPointer} elements + */ + public MetadataExtension(List infos, List pointers) { + this.infos = infos; + this.pointers = pointers; + } + + /** + * Get the info elements list. + * + * @return the info elements list + */ + public List getInfoElements() { + return Collections.unmodifiableList(infos); + } + + /** + * Get the pointer elements list. + * + * @return the pointer elements list + */ + public List getPointerElements() { + return (pointers == null) ? null : Collections.unmodifiableList(pointers); + } + + /** + * Return true, if this {@link MetadataExtension} is to be interpreted as Avatar unpublishing. + * + * @return true if unpublishing, false otherwise + */ + public boolean isDisablingPublishing() { + return getInfoElements().isEmpty(); + } + + @Override + public String getElementName() { + return ELEMENT; + } + + @Override + public String getNamespace() { + return NAMESPACE; + } + + @Override + public XmlStringBuilder toXML(XmlEnvironment xmlEnvironment) { + XmlStringBuilder xml = new XmlStringBuilder(this, xmlEnvironment); + appendInfoElements(xml); + appendPointerElements(xml); + closeElement(xml); + return xml; + } + + private void appendInfoElements(XmlStringBuilder xml) { + if (infos == null) { + return; + } + + xml.rightAngleBracket(); + + for (MetadataInfo info : infos) { + xml.halfOpenElement("info"); + xml.attribute("id", info.getId()); + xml.attribute("bytes", info.getBytes().longValue()); + xml.attribute("type", info.getType()); + xml.optAttribute("url", info.getUrl()); + + if (info.getHeight().nativeRepresentation() > 0) { + xml.attribute("height", info.getHeight().nativeRepresentation()); + } + + if (info.getWidth().nativeRepresentation() > 0) { + xml.attribute("width", info.getWidth().nativeRepresentation()); + } + + xml.closeEmptyElement(); + } + } + + private void appendPointerElements(XmlStringBuilder xml) { + if (pointers == null) { + return; + } + + for (MetadataPointer pointer : pointers) { + xml.openElement("pointer"); + xml.halfOpenElement("x"); + + String namespace = pointer.getNamespace(); + if (namespace != null) { + xml.xmlnsAttribute(namespace); + } + + xml.rightAngleBracket(); + + Map fields = pointer.getFields(); + if (fields != null) { + for (Map.Entry pair : fields.entrySet()) { + xml.escapedElement(pair.getKey(), String.valueOf(pair.getValue())); + } + } + + xml.closeElement("x"); + xml.closeElement("pointer"); + } + + } + + private void closeElement(XmlStringBuilder xml) { + if (infos != null || pointers != null) { + xml.closeElement(this); + } else { + xml.closeEmptyElement(); + } + } +} diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/element/package-info.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/element/package-info.java new file mode 100644 index 000000000..b55f1aa91 --- /dev/null +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/element/package-info.java @@ -0,0 +1,24 @@ +/** + * + * Copyright 2017 Fernando Ramirez + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * User Avatar elements. + * + * @author Fernando Ramirez + * @see XEP-0084: User + * Avatar + */ +package org.jivesoftware.smackx.avatar.element; diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/listener/AvatarListener.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/listener/AvatarListener.java new file mode 100644 index 000000000..bd657a3e5 --- /dev/null +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/listener/AvatarListener.java @@ -0,0 +1,31 @@ +/** + * + * Copyright 2019 Paul Schaub + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.avatar.listener; + +import org.jivesoftware.smackx.avatar.element.MetadataExtension; + +import org.jxmpp.jid.EntityBareJid; + +/** + * Listener that can notify the user about User Avatar updates. + * + * @author Paul Schaub + */ +public interface AvatarListener { + + void onAvatarUpdateReceived(EntityBareJid user, MetadataExtension metadata); +} diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/listener/package-info.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/listener/package-info.java new file mode 100644 index 000000000..160328bb4 --- /dev/null +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/listener/package-info.java @@ -0,0 +1,24 @@ +/** + * + * Copyright 2019 Paul Schaub + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Classes and interfaces of User Avatar. + * + * @author Paul Schaub + * @see XEP-0084: User + * Avatar + */ +package org.jivesoftware.smackx.avatar.listener; diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/package-info.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/package-info.java new file mode 100644 index 000000000..67f3ef5c6 --- /dev/null +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/package-info.java @@ -0,0 +1,24 @@ +/** + * + * Copyright 2017 Fernando Ramirez + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Classes and interfaces of User Avatar. + * + * @author Fernando Ramirez + * @see XEP-0084: User + * Avatar + */ +package org.jivesoftware.smackx.avatar; diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/provider/DataProvider.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/provider/DataProvider.java new file mode 100644 index 000000000..69de5221d --- /dev/null +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/provider/DataProvider.java @@ -0,0 +1,44 @@ +/** + * + * Copyright 2017 Fernando Ramirez + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.avatar.provider; + +import java.io.IOException; + +import org.jivesoftware.smack.packet.XmlEnvironment; +import org.jivesoftware.smack.provider.ExtensionElementProvider; +import org.jivesoftware.smack.util.stringencoder.Base64; +import org.jivesoftware.smack.xml.XmlPullParser; +import org.jivesoftware.smack.xml.XmlPullParserException; +import org.jivesoftware.smackx.avatar.element.DataExtension; + +/** + * User Avatar data provider class. + * + * @author Fernando Ramirez + * @see XEP-0084: User + * Avatar + */ +public class DataProvider extends ExtensionElementProvider { + + @Override + public DataExtension parse(XmlPullParser parser, int initialDepth, XmlEnvironment environment) + throws IOException, XmlPullParserException { + byte[] data = Base64.decode(parser.nextText()); + return new DataExtension(data); + } + +} diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/provider/MetadataProvider.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/provider/MetadataProvider.java new file mode 100644 index 000000000..80f4b6b01 --- /dev/null +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/provider/MetadataProvider.java @@ -0,0 +1,156 @@ +/** + * + * Copyright 2017 Fernando Ramirez, 2019 Paul Schaub + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.avatar.provider; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +import org.jivesoftware.smack.packet.XmlEnvironment; +import org.jivesoftware.smack.provider.ExtensionElementProvider; +import org.jivesoftware.smack.xml.XmlPullParser; +import org.jivesoftware.smack.xml.XmlPullParserException; +import org.jivesoftware.smackx.avatar.MetadataInfo; +import org.jivesoftware.smackx.avatar.MetadataPointer; +import org.jivesoftware.smackx.avatar.element.MetadataExtension; + +/** + * User Avatar metadata provider class. + * + * @author Fernando Ramirez + * @author Paul Schaub + * @see XEP-0084: User + * Avatar + */ +public class MetadataProvider extends ExtensionElementProvider { + + @Override + public MetadataExtension parse(XmlPullParser parser, int initialDepth, XmlEnvironment xmlEnvironment) throws IOException, XmlPullParserException { + List metadataInfos = null; + List pointers = null; + + outerloop: while (true) { + XmlPullParser.TagEvent eventType = parser.nextTag(); + + switch (eventType) { + case START_ELEMENT: + if (parser.getName().equals("info")) { + if (metadataInfos == null) { + metadataInfos = new ArrayList<>(); + } + + MetadataInfo info = parseInfo(parser); + if (info.getId() != null) { + metadataInfos.add(info); + } + } + + if (parser.getName().equals("pointer")) { + if (pointers == null) { + pointers = new ArrayList<>(); + } + + pointers.add(parsePointer(parser)); + } + break; + case END_ELEMENT: + if (parser.getDepth() == initialDepth) { + break outerloop; + } + } + } + return new MetadataExtension(metadataInfos, pointers); + } + + private static MetadataInfo parseInfo(XmlPullParser parser) throws XmlPullParserException { + String id; + URL url = null; + long bytes = 0; + String type; + int pixelsHeight = 0; + int pixelsWidth = 0; + + id = parser.getAttributeValue("", "id"); + type = parser.getAttributeValue("", "type"); + String urlString = parser.getAttributeValue("", "url"); + if (urlString != null && !urlString.isEmpty()) { + try { + url = new URL(urlString); + } catch (MalformedURLException e) { + throw new XmlPullParserException("Cannot parse URL '" + urlString + "'"); + } + } + + String bytesString = parser.getAttributeValue("", "bytes"); + if (bytesString != null) { + bytes = Long.parseLong(bytesString); + } + + String widthString = parser.getAttributeValue("", "width"); + if (widthString != null) { + pixelsWidth = Integer.parseInt(widthString); + } + + String heightString = parser.getAttributeValue("", "height"); + if (heightString != null) { + pixelsHeight = Integer.parseInt(heightString); + } + + try { + return new MetadataInfo(id, url, bytes, type, pixelsHeight, pixelsWidth); + } catch (IllegalArgumentException e) { + throw new XmlPullParserException(e); + } + } + + private static MetadataPointer parsePointer(XmlPullParser parser) throws XmlPullParserException, IOException { + int pointerDepth = parser.getDepth(); + String namespace = null; + HashMap fields = null; + + outperloop: while (true) { + XmlPullParser.TagEvent tag = parser.nextTag(); + + switch (tag) { + case START_ELEMENT: + if (parser.getName().equals("x")) { + namespace = parser.getNamespace(); + } else { + if (fields == null) { + fields = new HashMap<>(); + } + + String name = parser.getName(); + Object value = parser.nextText(); + fields.put(name, value); + } + break; + + case END_ELEMENT: + if (parser.getDepth() == pointerDepth) { + break outperloop; + } + } + } + + return new MetadataPointer(namespace, fields); + } + +} diff --git a/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/provider/package-info.java b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/provider/package-info.java new file mode 100644 index 000000000..38f659ac9 --- /dev/null +++ b/smack-extensions/src/main/java/org/jivesoftware/smackx/avatar/provider/package-info.java @@ -0,0 +1,24 @@ +/** + * + * Copyright 2017 Fernando Ramirez + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * User Avatar providers. + * + * @author Fernando Ramirez + * @see XEP-0084: User + * Avatar + */ +package org.jivesoftware.smackx.avatar.provider; diff --git a/smack-extensions/src/main/resources/org.jivesoftware.smack.extensions/extensions.providers b/smack-extensions/src/main/resources/org.jivesoftware.smack.extensions/extensions.providers index 19c19a305..f2fabb4be 100644 --- a/smack-extensions/src/main/resources/org.jivesoftware.smack.extensions/extensions.providers +++ b/smack-extensions/src/main/resources/org.jivesoftware.smack.extensions/extensions.providers @@ -317,6 +317,18 @@ org.jivesoftware.smackx.geoloc.provider.GeoLocationProvider + + + data + urn:xmpp:avatar:data + org.jivesoftware.smackx.avatar.provider.DataProvider + + + metadata + urn:xmpp:avatar:metadata + org.jivesoftware.smackx.avatar.provider.MetadataProvider + + active diff --git a/smack-extensions/src/test/java/org/jivesoftware/smackx/avatar/AvatarMetadataStoreTest.java b/smack-extensions/src/test/java/org/jivesoftware/smackx/avatar/AvatarMetadataStoreTest.java new file mode 100644 index 000000000..eb3fa0506 --- /dev/null +++ b/smack-extensions/src/test/java/org/jivesoftware/smackx/avatar/AvatarMetadataStoreTest.java @@ -0,0 +1,54 @@ +/** + * + * Copyright 2021 Paul Schaub + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.avatar; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.jivesoftware.smack.util.StringUtils; + +import org.junit.jupiter.api.Test; +import org.jxmpp.jid.EntityBareJid; +import org.jxmpp.jid.JidTestUtil; + +public class AvatarMetadataStoreTest { + + @Test + public void testStoreHasAvatarAvailable() { + AvatarMetadataStore store = new MemoryAvatarMetadataStore(); + EntityBareJid exist = JidTestUtil.BARE_JID_1; + EntityBareJid notExist = JidTestUtil.BARE_JID_2; + + for (String itemId : getRandomIds(10)) { + assertFalse(store.hasAvatarAvailable(exist, itemId)); + store.setAvatarAvailable(exist, itemId); + assertTrue(store.hasAvatarAvailable(exist, itemId)); + assertFalse(store.hasAvatarAvailable(notExist, itemId)); + } + } + + private static List getRandomIds(int len) { + List ids = new ArrayList<>(len); + for (int i = 0; i < len; i++) { + ids.add(StringUtils.randomString(14)); + } + return ids; + } +} diff --git a/smack-extensions/src/test/java/org/jivesoftware/smackx/avatar/DataExtensionTest.java b/smack-extensions/src/test/java/org/jivesoftware/smackx/avatar/DataExtensionTest.java new file mode 100644 index 000000000..e46073aab --- /dev/null +++ b/smack-extensions/src/test/java/org/jivesoftware/smackx/avatar/DataExtensionTest.java @@ -0,0 +1,49 @@ +/** + * + * Copyright 2017 Fernando Ramirez, 2021 Paul Schaub + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.avatar; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.jivesoftware.smack.test.util.SmackTestSuite; +import org.jivesoftware.smack.test.util.SmackTestUtil; +import org.jivesoftware.smack.util.stringencoder.Base64; +import org.jivesoftware.smackx.avatar.element.DataExtension; +import org.jivesoftware.smackx.avatar.provider.DataProvider; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +public class DataExtensionTest extends SmackTestSuite { + + // @formatter:off + String dataExtensionExample = "" + + "qANQR1DBwU4DX7jmYZnnfe32" + + ""; + // @formatter:on + + @ParameterizedTest + @EnumSource(SmackTestUtil.XmlPullParserKind.class) + public void checkDataExtensionParse(SmackTestUtil.XmlPullParserKind parserKind) throws Exception { + byte[] data = Base64.decode("qANQR1DBwU4DX7jmYZnnfe32"); + DataExtension dataExtension = new DataExtension(data); + assertEquals(dataExtensionExample, dataExtension.toXML().toString()); + + DataExtension dataExtensionFromProvider = SmackTestUtil.parse(dataExtensionExample, DataProvider.class, parserKind); + assertEquals(Base64.encodeToString(data), Base64.encodeToString(dataExtensionFromProvider.getData())); + } + +} diff --git a/smack-extensions/src/test/java/org/jivesoftware/smackx/avatar/MetadataExtensionTest.java b/smack-extensions/src/test/java/org/jivesoftware/smackx/avatar/MetadataExtensionTest.java new file mode 100644 index 000000000..a1cf7ed29 --- /dev/null +++ b/smack-extensions/src/test/java/org/jivesoftware/smackx/avatar/MetadataExtensionTest.java @@ -0,0 +1,214 @@ +/** + * + * Copyright 2017 Fernando Ramirez, 2021 Paul Schaub + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.avatar; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.net.URL; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.jivesoftware.smack.test.util.SmackTestUtil; +import org.jivesoftware.smackx.avatar.element.MetadataExtension; +import org.jivesoftware.smackx.avatar.provider.MetadataProvider; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +public class MetadataExtensionTest { + + private static final String metadataExtensionExample = "" + + "" + + ""; + + private static final String emptyMetadataExtensionExample = ""; + + private static final String metadataWithSeveralInfos = "" + + "" + + "" + + "" + + ""; + + private static final String metadataWithInfoAndPointers = "" + + "" + + "" + + "" + + "Ancapistan" + + "Kropotkin" + + "" + + "" + + "" + + "" + + "hard" + + "2" + + "" + + "" + + ""; + + @ParameterizedTest + @EnumSource(SmackTestUtil.XmlPullParserKind.class) + public void checkMetadataExtensionParse(SmackTestUtil.XmlPullParserKind parserKind) throws Exception { + String id = "357a8123a30844a3aa99861b6349264ba67a5694"; + URL url = new URL("http://avatars.example.org/happy.gif"); + long bytes = 23456; + String type = "image/gif"; + int pixelsHeight = 64; + int pixelsWidth = 128; + + MetadataInfo info = new MetadataInfo(id, url, bytes, type, pixelsHeight, pixelsWidth); + List infos = new ArrayList<>(); + infos.add(info); + + MetadataExtension metadataExtension = new MetadataExtension(infos); + assertEquals(metadataExtensionExample, metadataExtension.toXML().toString()); + + MetadataExtension metadataExtensionFromProvider = SmackTestUtil + .parse(metadataExtensionExample, MetadataProvider.class, parserKind); + + assertEquals(id, metadataExtensionFromProvider.getInfoElements().get(0).getId()); + assertEquals(url, metadataExtensionFromProvider.getInfoElements().get(0).getUrl()); + assertEquals(bytes, metadataExtensionFromProvider.getInfoElements().get(0).getBytes().intValue()); + assertEquals(type, metadataExtensionFromProvider.getInfoElements().get(0).getType()); + assertEquals(pixelsHeight, metadataExtensionFromProvider.getInfoElements().get(0).getHeight().intValue()); + assertEquals(pixelsWidth, metadataExtensionFromProvider.getInfoElements().get(0).getWidth().intValue()); + } + + @Test + public void checkEmptyMetadataExtensionParse() { + MetadataExtension metadataExtension = new MetadataExtension(null); + assertEquals(emptyMetadataExtensionExample, metadataExtension.toXML().toString()); + } + + @ParameterizedTest + @EnumSource(SmackTestUtil.XmlPullParserKind.class) + public void checkSeveralInfosInMetadataExtension(SmackTestUtil.XmlPullParserKind parserKind) throws Exception { + MetadataExtension metadataExtensionFromProvider = SmackTestUtil + .parse(metadataWithSeveralInfos, MetadataProvider.class, parserKind); + + MetadataInfo info1 = metadataExtensionFromProvider.getInfoElements().get(0); + MetadataInfo info2 = metadataExtensionFromProvider.getInfoElements().get(1); + MetadataInfo info3 = metadataExtensionFromProvider.getInfoElements().get(2); + + assertEquals("111f4b3c50d7b0df729d299bc6f8e9ef9066971f", info1.getId()); + assertNull(info1.getUrl()); + assertEquals(12345, info1.getBytes().intValue()); + assertEquals("image/png", info1.getType()); + assertEquals(64, info1.getHeight().intValue()); + assertEquals(64, info1.getWidth().intValue()); + + assertEquals("e279f80c38f99c1e7e53e262b440993b2f7eea57", info2.getId()); + assertEquals(new URL("http://avatars.example.org/happy.png"), info2.getUrl()); + assertEquals(12345, info2.getBytes().intValue()); + assertEquals("image/png", info2.getType()); + assertEquals(64, info2.getHeight().intValue()); + assertEquals(128, info2.getWidth().intValue()); + + assertEquals("357a8123a30844a3aa99861b6349264ba67a5694", info3.getId()); + assertEquals(new URL("http://avatars.example.org/happy.gif"), info3.getUrl()); + assertEquals(23456, info3.getBytes().intValue()); + assertEquals("image/gif", info3.getType()); + assertEquals(64, info3.getHeight().intValue()); + assertEquals(64, info3.getWidth().intValue()); + } + + @ParameterizedTest + @EnumSource(SmackTestUtil.XmlPullParserKind.class) + public void checkInfosAndPointersParse(SmackTestUtil.XmlPullParserKind parserKind) throws Exception { + MetadataExtension metadataExtensionFromProvider = SmackTestUtil + .parse(metadataWithInfoAndPointers, MetadataProvider.class, parserKind); + + MetadataInfo info = metadataExtensionFromProvider.getInfoElements().get(0); + assertEquals("111f4b3c50d7b0df729d299bc6f8e9ef9066971f", info.getId()); + assertNull(info.getUrl()); + assertEquals(12345, info.getBytes().intValue()); + assertEquals("image/png", info.getType()); + assertEquals(64, info.getHeight().intValue()); + assertEquals(64, info.getWidth().intValue()); + + MetadataPointer pointer1 = metadataExtensionFromProvider.getPointerElements().get(0); + Map fields1 = pointer1.getFields(); + assertEquals("http://example.com/virtualworlds", pointer1.getNamespace()); + assertEquals("Ancapistan", fields1.get("game")); + assertEquals("Kropotkin", fields1.get("character")); + + MetadataPointer pointer2 = metadataExtensionFromProvider.getPointerElements().get(1); + Map fields2 = pointer2.getFields(); + assertEquals("http://sample.com/game", pointer2.getNamespace()); + assertEquals("hard", fields2.get("level")); + assertEquals("2", fields2.get("players")); + } + + @Test + public void createMetadataExtensionWithInfoAndPointer() { + String id = "111f4b3c50d7b0df729d299bc6f8e9ef9066971f"; + long bytes = 12345; + String type = "image/png"; + int pixelsHeight = 64; + int pixelsWidth = 64; + MetadataInfo info = new MetadataInfo(id, null, bytes, type, pixelsHeight, pixelsWidth); + + HashMap fields1 = new HashMap<>(); + fields1.put("game", "Ancapistan"); + fields1.put("character", "Kropotkin"); + MetadataPointer pointer1 = new MetadataPointer("http://example.com/virtualworlds", fields1); + + HashMap fields2 = new HashMap<>(); + fields2.put("level", "hard"); + fields2.put("players", 2); + MetadataPointer pointer2 = new MetadataPointer("http://sample.com/game", fields2); + + List infos = new ArrayList<>(); + infos.add(info); + + List pointers = new ArrayList<>(); + pointers.add(pointer1); + pointers.add(pointer2); + + MetadataExtension metadataExtension = new MetadataExtension(infos, pointers); + assertEquals(metadataWithInfoAndPointers, metadataExtension.toXML().toString()); + } + +} diff --git a/smack-extensions/src/test/java/org/jivesoftware/smackx/avatar/MetadataInfoTest.java b/smack-extensions/src/test/java/org/jivesoftware/smackx/avatar/MetadataInfoTest.java new file mode 100644 index 000000000..c005d572a --- /dev/null +++ b/smack-extensions/src/test/java/org/jivesoftware/smackx/avatar/MetadataInfoTest.java @@ -0,0 +1,63 @@ +/** + * + * Copyright 2021 Paul Schaub + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jivesoftware.smackx.avatar; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.MalformedURLException; +import java.net.URL; + +import org.junit.jupiter.api.Test; + +public class MetadataInfoTest { + + /** + * Negative number. + */ + private static final int NEGATIVE = -13; + + /** + * Greater than {@link MetadataInfo#MAX_HEIGHT} and {@link MetadataInfo#MAX_WIDTH}. + */ + private static final int TOO_LARGE = 70000; + + /** + * Allowed dimension. + */ + private static final int VALID = 512; + + @Test + public void throwsForNegativeBytes() { + assertThrows(IllegalArgumentException.class, () -> + new MetadataInfo("test", new URL("https://example.org/image"), + NEGATIVE, "image/png", VALID, VALID)); + } + + @Test + public void throwsForHeightWidthOutOfBounds() throws MalformedURLException { + URL url = new URL("https://example.org/image"); + + assertThrows(IllegalArgumentException.class, () -> + new MetadataInfo("test", url, 1234, "image/png", NEGATIVE, VALID)); + assertThrows(IllegalArgumentException.class, () -> + new MetadataInfo("test", url, 1234, "image/png", TOO_LARGE, VALID)); + assertThrows(IllegalArgumentException.class, () -> + new MetadataInfo("test", url, 1234, "image/png", VALID, NEGATIVE)); + assertThrows(IllegalArgumentException.class, () -> + new MetadataInfo("test", url, 1234, "image/png", VALID, TOO_LARGE)); + } +} diff --git a/smack-repl/src/main/java/org/igniterealtime/smack/smackrepl/Avatar.java b/smack-repl/src/main/java/org/igniterealtime/smack/smackrepl/Avatar.java new file mode 100644 index 000000000..f73585f42 --- /dev/null +++ b/smack-repl/src/main/java/org/igniterealtime/smack/smackrepl/Avatar.java @@ -0,0 +1,127 @@ +/** + * + * Copyright 2020 Paul Schaub + * + * This file is part of smack-repl. + * + * smack-repl is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.igniterealtime.smack.smackrepl; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Scanner; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.jivesoftware.smack.SmackException; +import org.jivesoftware.smack.XMPPException; +import org.jivesoftware.smack.tcp.XMPPTCPConnection; +import org.jivesoftware.smack.util.Async; +import org.jivesoftware.smack.util.StringUtils; +import org.jivesoftware.smackx.avatar.MemoryAvatarMetadataStore; +import org.jivesoftware.smackx.avatar.MetadataInfo; +import org.jivesoftware.smackx.avatar.UserAvatarManager; +import org.jivesoftware.smackx.avatar.element.MetadataExtension; +import org.jivesoftware.smackx.avatar.listener.AvatarListener; + +import org.apache.commons.io.FileUtils; +import org.bouncycastle.util.io.Streams; +import org.jxmpp.jid.EntityBareJid; + +/** + * Connect to an XMPP account and download the avatars of all contacts. + * Shutdown with "/quit". + */ +public class Avatar { + + private static final Logger LOGGER = Logger.getLogger("Avatar"); + + public static void main(String[] args) throws IOException, InterruptedException, XMPPException, SmackException { + if (args.length != 2) { + throw new IllegalArgumentException("Usage: java Avatar "); + } + + XMPPTCPConnection connection = new XMPPTCPConnection(args[0], args[1]); + + UserAvatarManager avatarManager = UserAvatarManager.getInstanceFor(connection); + avatarManager.setAvatarMetadataStore(new MemoryAvatarMetadataStore()); + + File avatarDownloadDirectory = new File(FileUtils.getTempDirectory(), "avatarTest" + StringUtils.randomString(6)); + createDownloadDirectory(avatarDownloadDirectory); + + avatarManager.addAvatarListener(new AvatarListener() { + @Override + public void onAvatarUpdateReceived(EntityBareJid user, MetadataExtension metadata) { + Async.go(() -> { + File userDirectory = new File(avatarDownloadDirectory, user.asUrlEncodedString()); + userDirectory.mkdirs(); + MetadataInfo avatarInfo = metadata.getInfoElements().get(0); + File avatarFile = new File(userDirectory, avatarInfo.getId()); + + try { + if (avatarInfo.getUrl() == null) { + LOGGER.log(Level.INFO, "Fetch avatar from pubsub for " + user.toString()); + byte[] bytes = avatarManager.fetchAvatarFromPubSub(user, avatarInfo); + writeAvatar(avatarFile, new ByteArrayInputStream(bytes)); + } else { + LOGGER.log(Level.INFO, "Fetch avatar from " + avatarInfo.getUrl().toString() + " for " + user.toString()); + writeAvatar(avatarFile, avatarInfo.getUrl().openStream()); + } + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Error downloading avatar", e); + } + }); + } + }); + + avatarManager.enable(); + + connection.connect().login(); + + Scanner input = new Scanner(System.in, StandardCharsets.UTF_8.name()); + while (true) { + String line = input.nextLine(); + + if (line.equals("/quit")) { + connection.disconnect(); + System.exit(0); + break; + } + } + } + + private static void createDownloadDirectory(File avatarDownloadDirectory) throws IOException { + if (!avatarDownloadDirectory.mkdirs()) { + throw new IOException("Cannot create temp directory '" + avatarDownloadDirectory.getAbsolutePath() + "'"); + } else { + LOGGER.info("Created temporary avatar download directory '" + avatarDownloadDirectory.getAbsolutePath() + "'"); + } + } + + private static void writeAvatar(File file, InputStream inputStream) throws IOException { + file.createNewFile(); + OutputStream outputStream = new FileOutputStream(file); + Streams.pipeAll(inputStream, outputStream); + + inputStream.close(); + outputStream.close(); + } +}