/* * Kontalk Java client * Copyright (C) 2016 Kontalk Devteam <devteam@kontalk.org> * * This program 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, see <http://www.gnu.org/licenses/>. */ package org.kontalk.util; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import org.apache.commons.lang.StringUtils; import org.jivesoftware.smack.packet.ExtensionElement; import org.jivesoftware.smack.packet.Message; import org.jivesoftware.smack.roster.packet.RosterPacket; import org.jivesoftware.smackx.chatstates.packet.ChatStateExtension; import org.jivesoftware.smackx.receipts.DeliveryReceipt; import org.jxmpp.jid.Jid; import org.kontalk.client.BitsOfBinary; import org.kontalk.client.E2EEncryption; import org.kontalk.client.GroupExtension; import org.kontalk.client.GroupExtension.Member; import org.kontalk.client.GroupExtension.Type; import org.kontalk.client.OpenPGPExtension; import org.kontalk.client.OpenPGPExtension.BodyElement; import org.kontalk.client.OutOfBandData; import org.kontalk.misc.JID; import org.kontalk.model.Contact; import org.kontalk.model.chat.GroupChat.KonGroupChat; import org.kontalk.model.chat.GroupMetaData.KonGroupData; import org.kontalk.model.message.MessageContent; import org.kontalk.model.message.MessageContent.GroupCommand; import org.kontalk.model.message.MessageContent.InAttachment; import org.kontalk.model.message.MessageContent.Preview; /** * Static utilities as interface between client and control. * * @author Alexander Bikadorov {@literal <bikaejkb@mail.tu-berlin.de>} */ public final class ClientUtils { private static final Logger LOGGER = Logger.getLogger(ClientUtils.class.getName()); private static final List<String> IGNORED_NAMESPACES = Arrays.asList( ChatStateExtension.NAMESPACE, DeliveryReceipt.NAMESPACE); /** * Message attributes for identifying the chat for a message. * KonGroupData is missing here as this could be part of the encrypted content. */ public static class MessageIDs { public final JID jid; public final String xmppID; public final String xmppThreadID; private MessageIDs(JID jid, String xmppID, String threadID) { this.jid = jid; this.xmppID = xmppID; this.xmppThreadID = threadID; } public static MessageIDs from(Message m) { return from(m, ""); } public static MessageIDs from(Message m, String receiptID) { return create(m, m.getFrom(), receiptID); } public static MessageIDs to(Message m) { return create(m, m.getTo(), ""); } private static MessageIDs create(Message m, Jid jid, String receiptID) { return new MessageIDs( JID.fromSmack(jid), !receiptID.isEmpty() ? receiptID : StringUtils.defaultString(m.getStanzaId()), StringUtils.defaultString(m.getThread())); } @Override public String toString() { return "IDs:jid="+jid+",xmpp="+xmppID+",thread="+xmppThreadID; } } public static class KonRosterEntry { public final JID jid; public final String name; public final Contact.Subscription subscription; public KonRosterEntry(JID jid, String name, RosterPacket.ItemType type, boolean subscriptionPending) { this.jid = jid; this.name = name; this.subscription = rosterToModelSubscription(subscriptionPending, type); } private static Contact.Subscription rosterToModelSubscription( boolean subscriptionPending, RosterPacket.ItemType type) { if (type == RosterPacket.ItemType.both || type == RosterPacket.ItemType.to || type == RosterPacket.ItemType.remove) return Contact.Subscription.SUBSCRIBED; if (subscriptionPending) return Contact.Subscription.PENDING; return Contact.Subscription.UNSUBSCRIBED; } } public static MessageContent parseMessageContent(Message m, boolean decrypted) { MessageContent.Builder builder = new MessageContent.Builder(); // parsing only default body String plainText = StringUtils.defaultString(m.getBody()); String encrypted = ""; if (!decrypted) { ExtensionElement e2eExt = m.getExtension(E2EEncryption.ELEMENT_NAME, E2EEncryption.NAMESPACE); if (e2eExt instanceof E2EEncryption) { // encryption extension (RFC 3923), decrypted later encrypted = EncodingUtils.bytesToBase64(((E2EEncryption) e2eExt).getData()); // remove extension before parsing all others m.removeExtension(E2EEncryption.ELEMENT_NAME, E2EEncryption.NAMESPACE); } ExtensionElement openPGPExt = m.getExtension(OpenPGPExtension.ELEMENT_NAME, OpenPGPExtension.NAMESPACE); if (openPGPExt instanceof OpenPGPExtension) { if (!encrypted.isEmpty()) { LOGGER.info("message contains e2e and OpenPGP element, ignoring e2e"); } encrypted = ((OpenPGPExtension) openPGPExt).getData(); // remove extension before parsing all others m.removeExtension(OpenPGPExtension.ELEMENT_NAME, OpenPGPExtension.NAMESPACE); } } if (!encrypted.isEmpty()) { if (!plainText.isEmpty()) { LOGGER.config("message contains encryption and body (ignoring body): " + plainText); plainText = ""; } builder.encrypted(encrypted); } addContent(builder, m.getExtensions(), plainText, decrypted); return builder.build(); } public static MessageContent extensionsToContent(List<ExtensionElement> elements) { MessageContent.Builder builder = new MessageContent.Builder(); addContent(builder, elements, "", true); return builder.build(); } private static void addContent(MessageContent.Builder builder, List<ExtensionElement> elements, String body, boolean decrypted) { String outOfBandURL = null; for (ExtensionElement element : elements) { if (element instanceof BodyElement) { body = ((BodyElement) element).getText(); } else if (element instanceof BitsOfBinary) { // Bits of Binary: preview for file attachment BitsOfBinary bob = (BitsOfBinary) element; String mime = StringUtils.defaultString(bob.getType()); byte[] bits = bob.getContents(); if (bits == null) bits = new byte[0]; if (mime.isEmpty() || bits.length <= 0) LOGGER.warning("invalid BOB data: " + bob.toXML()); else builder.preview(new Preview(bits, mime)); } else if (element instanceof OutOfBandData) { // Out of Band Data: a URI to a file OutOfBandData oobData = (OutOfBandData) element; URI url; try { url = new URI(oobData.getUrl()); } catch (URISyntaxException ex) { LOGGER.log(Level.WARNING, "can't parse URL", ex); url = URI.create(""); } builder.attachment(new InAttachment(url)); outOfBandURL = url.toString(); } else if (element instanceof GroupExtension) { // Kontalk group element and (maybe) command GroupExtension group = (GroupExtension) element; KonGroupData gid = new KonGroupData(JID.bare(group.getOwner()), group.getID()); GroupCommand groupCommand = ClientUtils.groupExtensionToGroupCommand( group.getType(), group.getMembers(), group.getSubject()).orElse(null); builder.groupData(gid); if (groupCommand != null) builder.groupCommand(groupCommand); } else { if (decrypted || !IGNORED_NAMESPACES.contains(element.getNamespace())) LOGGER.warning("unexpected extension: " + (element == null ? element : element.toXML().toString())); } } // body text is maybe URI, for clients that dont understand OOB, // but we do, don't save it twice if (body.equals(outOfBandURL)) body = ""; if (!body.isEmpty()) builder.body(body); } /* Internal to external */ public static GroupExtension groupCommandToGroupExtension(KonGroupChat chat, GroupCommand groupCommand) { assert chat.isGroupChat(); KonGroupData gid = chat.getGroupData(); switch (groupCommand.getOperation()) { case LEAVE: // weare leaving return new GroupExtension(gid.id, gid.owner.string(), Type.PART); case CREATE: { Set<Member> members = new HashSet<>(); groupCommand.getAdded().forEach(added -> members.add(new Member(added.string()))); return new GroupExtension(gid.id, gid.owner.string(), Type.CREATE, groupCommand.getSubject(), members); } case SET: { Set<Member> members = new HashSet<>(); Set<JID> incl = new HashSet<>(); for (JID added : groupCommand.getAdded()) { incl.add(added); members.add(new Member(added.string(), Member.Operation.ADD)); } for (JID removed : groupCommand.getRemoved()) { incl.add(removed); members.add(new Member(removed.string(), Member.Operation.REMOVE)); } if (!groupCommand.getAdded().isEmpty()) { // list all remaining members for the new members for (Contact c : chat.getValidContacts()) { JID old = c.getJID(); if (!incl.contains(old)) members.add(new Member(old.string())); } } return new GroupExtension(gid.id, gid.owner.string(), Type.SET, groupCommand.getSubject(), members); } default: LOGGER.warning("not implemented: "+groupCommand.getOperation()); return new GroupExtension(gid.id, gid.owner.string()); } } /* External to internal */ private static Optional<GroupCommand> groupExtensionToGroupCommand( Type com, List<Member> members, String subject) { switch (com) { case NONE: return Optional.empty(); case CREATE: List<JID> jids = members.stream() .peek(m -> { if (m.operation != Member.Operation.NONE) { LOGGER.warning("unexpected member operation in " + "create command: " + m.operation + " " + m.jid); }}) .map(m -> JID.bare(m.jid)) .collect(Collectors.toList()); return Optional.of(GroupCommand.create(jids, subject)); case PART: return Optional.of(GroupCommand.leave()); case SET: // ignoring duplicate JIDs (no log output) Set<JID> unchanged = new HashSet<>(); Set<JID> added = new HashSet<>(); Set<JID> removed = new HashSet<>(); for (Member m : members) { switch (m.operation) { case NONE: unchanged.add(JID.bare(m.jid)); break; case ADD: added.add(JID.bare(m.jid)); break; case REMOVE: removed.add((JID.bare(m.jid))); break; } } // sanity check; prioritize 'removed' over 'added' removed.stream() .filter(added::contains) .peek(jid -> LOGGER.warning("member added AND removed (removing) " + jid)) .forEach(added::remove); return Optional.of(GroupCommand.set( new ArrayList<>(unchanged), new ArrayList<>(added), new ArrayList<>(removed), subject)); case GET: case RESULT: default: // TODO return Optional.empty(); } } }