1package eu.siacs.conversations.generator;
  2
  3import eu.siacs.conversations.Config;
  4import eu.siacs.conversations.crypto.axolotl.AxolotlService;
  5import eu.siacs.conversations.crypto.axolotl.XmppAxolotlMessage;
  6import eu.siacs.conversations.entities.Account;
  7import eu.siacs.conversations.entities.Conversation;
  8import eu.siacs.conversations.entities.Conversational;
  9import eu.siacs.conversations.entities.Message;
 10import eu.siacs.conversations.services.XmppConnectionService;
 11import eu.siacs.conversations.xml.Element;
 12import eu.siacs.conversations.xml.Namespace;
 13import eu.siacs.conversations.xmpp.Jid;
 14import eu.siacs.conversations.xmpp.chatstate.ChatState;
 15import eu.siacs.conversations.xmpp.forms.Data;
 16import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
 17import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
 18import eu.siacs.conversations.xmpp.jingle.Media;
 19import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
 20import im.conversations.android.xmpp.model.correction.Replace;
 21import im.conversations.android.xmpp.model.reactions.Reaction;
 22import im.conversations.android.xmpp.model.reactions.Reactions;
 23import im.conversations.android.xmpp.model.unique.OriginId;
 24import java.text.SimpleDateFormat;
 25import java.util.ArrayList;
 26import java.util.Collection;
 27import java.util.Date;
 28import java.util.Locale;
 29import java.util.TimeZone;
 30
 31public class MessageGenerator extends AbstractGenerator {
 32    private static final String OMEMO_FALLBACK_MESSAGE =
 33            "I sent you an OMEMO encrypted message but your client doesn’t seem to support that."
 34                    + " Find more information on https://conversations.im/omemo";
 35    private static final String PGP_FALLBACK_MESSAGE =
 36            "I sent you a PGP encrypted message but your client doesn’t seem to support that.";
 37
 38    public MessageGenerator(XmppConnectionService service) {
 39        super(service);
 40    }
 41
 42    private im.conversations.android.xmpp.model.stanza.Message preparePacket(
 43            final Message message, final boolean legacyEncryption) {
 44        Conversation conversation = (Conversation) message.getConversation();
 45        Account account = conversation.getAccount();
 46        im.conversations.android.xmpp.model.stanza.Message packet =
 47                new im.conversations.android.xmpp.model.stanza.Message();
 48        final boolean isWithSelf = conversation.getContact().isSelf();
 49        if (conversation.getMode() == Conversation.MODE_SINGLE) {
 50            packet.setTo(message.getCounterpart());
 51            packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.CHAT);
 52            if (!isWithSelf) {
 53                packet.addChild("request", "urn:xmpp:receipts");
 54            }
 55        } else if (message.isPrivateMessage()) {
 56            packet.setTo(message.getCounterpart());
 57            packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.CHAT);
 58            packet.addChild("x", "http://jabber.org/protocol/muc#user");
 59            packet.addChild("request", "urn:xmpp:receipts");
 60        } else {
 61            packet.setTo(message.getCounterpart().asBareJid());
 62            packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.GROUPCHAT);
 63        }
 64        if (conversation.isSingleOrPrivateAndNonAnonymous() && !message.isPrivateMessage()) {
 65            packet.addChild("markable", "urn:xmpp:chat-markers:0");
 66        }
 67        packet.setFrom(account.getJid());
 68        packet.setId(message.getUuid());
 69        if (conversation.getMode() == Conversational.MODE_MULTI
 70                && !message.isPrivateMessage()
 71                && !conversation.getMucOptions().stableId()) {
 72            packet.addExtension(new OriginId(message.getUuid()));
 73        }
 74        if (message.edited()) {
 75            packet.addExtension(new Replace(message.getEditedIdWireFormat()));
 76        }
 77        if (!legacyEncryption) {
 78            if (message.getSubject() != null && message.getSubject().length() > 0) packet.addChild("subject").setContent(message.getSubject());
 79            // Legacy encryption can't handle advanced payloads
 80            for (Element el : message.getPayloads()) {
 81                packet.addChild(el);
 82            }
 83        } else {
 84            for (Element el : message.getPayloads()) {
 85                if ("thread".equals(el.getName())) packet.addChild(el);
 86            }
 87        }
 88        return packet;
 89    }
 90
 91    public void addDelay(
 92            im.conversations.android.xmpp.model.stanza.Message packet, long timestamp) {
 93        final SimpleDateFormat mDateFormat =
 94                new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
 95        mDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
 96        Element delay = packet.addChild("delay", "urn:xmpp:delay");
 97        Date date = new Date(timestamp);
 98        delay.setAttribute("stamp", mDateFormat.format(date));
 99    }
100
101    public im.conversations.android.xmpp.model.stanza.Message generateAxolotlChat(
102            Message message, XmppAxolotlMessage axolotlMessage) {
103        im.conversations.android.xmpp.model.stanza.Message packet = preparePacket(message, true);
104        if (axolotlMessage == null) {
105            return null;
106        }
107        packet.setAxolotlMessage(axolotlMessage.toElement());
108        packet.setBody(OMEMO_FALLBACK_MESSAGE);
109        packet.addChild("store", "urn:xmpp:hints");
110        packet.addChild("encryption", "urn:xmpp:eme:0")
111                .setAttribute("name", "OMEMO")
112                .setAttribute("namespace", AxolotlService.PEP_PREFIX);
113        return packet;
114    }
115
116    public im.conversations.android.xmpp.model.stanza.Message generateKeyTransportMessage(
117            Jid to, XmppAxolotlMessage axolotlMessage) {
118        im.conversations.android.xmpp.model.stanza.Message packet =
119                new im.conversations.android.xmpp.model.stanza.Message();
120        packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.CHAT);
121        packet.setTo(to);
122        packet.setAxolotlMessage(axolotlMessage.toElement());
123        packet.addChild("store", "urn:xmpp:hints");
124        return packet;
125    }
126
127    public im.conversations.android.xmpp.model.stanza.Message generateChat(Message message) {
128        im.conversations.android.xmpp.model.stanza.Message packet = preparePacket(message, false);
129        String content;
130        if (message.hasFileOnRemoteHost()) {
131            final Message.FileParams fileParams = message.getFileParams();
132
133            if (message.getFallbacks(Namespace.OOB).isEmpty()) {
134                if (message.getBody().equals("")) {
135                    message.setBody(fileParams.url);
136                    final var fallback = new Element("fallback", "urn:xmpp:fallback:0").setAttribute("for", Namespace.OOB);
137                    fallback.addChild("body", "urn:xmpp:fallback:0");
138                    message.addPayload(fallback);
139                } else {
140                    long start = message.getRawBody().codePointCount(0, message.getRawBody().length());
141                    message.appendBody(fileParams.url);
142                    final var fallback = new Element("fallback", "urn:xmpp:fallback:0").setAttribute("for", Namespace.OOB);
143                    fallback.addChild("body", "urn:xmpp:fallback:0")
144                        .setAttribute("start", String.valueOf(start))
145                        .setAttribute("end", String.valueOf(start + fileParams.url.length()));
146                    message.addPayload(fallback);
147                }
148            }
149
150            packet = preparePacket(message, false);
151            packet.addChild("x", Namespace.OOB).addChild("url").setContent(fileParams.url);
152        }
153        if (message.getRawBody() != null) packet.setBody(message.getRawBody());
154        return packet;
155    }
156
157    public im.conversations.android.xmpp.model.stanza.Message generatePgpChat(Message message) {
158        final im.conversations.android.xmpp.model.stanza.Message packet = preparePacket(message, true);
159        if (message.hasFileOnRemoteHost()) {
160            Message.FileParams fileParams = message.getFileParams();
161            final String url = fileParams.url;
162            packet.setBody(url);
163            packet.addChild("x", Namespace.OOB).addChild("url").setContent(url);
164            packet.addChild("fallback", "urn:xmpp:fallback:0").setAttribute("for", Namespace.OOB)
165                  .addChild("body", "urn:xmpp:fallback:0");
166        } else {
167            if (Config.supportUnencrypted()) {
168                packet.setBody(PGP_FALLBACK_MESSAGE);
169            }
170            if (message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
171                packet.addChild("x", "jabber:x:encrypted").setContent(message.getEncryptedBody());
172            } else if (message.getEncryption() == Message.ENCRYPTION_PGP) {
173                packet.addChild("x", "jabber:x:encrypted").setContent(message.getBody());
174            }
175            packet.addChild("encryption", "urn:xmpp:eme:0")
176                    .setAttribute("namespace", "jabber:x:encrypted");
177        }
178        return packet;
179    }
180
181    public im.conversations.android.xmpp.model.stanza.Message generateChatState(
182            Conversation conversation) {
183        final Account account = conversation.getAccount();
184        final im.conversations.android.xmpp.model.stanza.Message packet =
185                new im.conversations.android.xmpp.model.stanza.Message();
186        packet.setType(
187                conversation.getMode() == Conversation.MODE_MULTI
188                        ? im.conversations.android.xmpp.model.stanza.Message.Type.GROUPCHAT
189                        : im.conversations.android.xmpp.model.stanza.Message.Type.CHAT);
190        packet.setTo(conversation.getJid().asBareJid());
191        packet.setFrom(account.getJid());
192        packet.addChild(ChatState.toElement(conversation.getOutgoingChatState()));
193        packet.addChild("no-store", "urn:xmpp:hints");
194        packet.addChild("no-storage", "urn:xmpp:hints"); // wrong! don't copy this. Its *store*
195        return packet;
196    }
197
198    public im.conversations.android.xmpp.model.stanza.Message confirm(final Message message) {
199        final boolean groupChat = message.getConversation().getMode() == Conversational.MODE_MULTI;
200        final Jid to = message.getCounterpart();
201        final im.conversations.android.xmpp.model.stanza.Message packet =
202                new im.conversations.android.xmpp.model.stanza.Message();
203        packet.setType(
204                groupChat
205                        ? im.conversations.android.xmpp.model.stanza.Message.Type.GROUPCHAT
206                        : im.conversations.android.xmpp.model.stanza.Message.Type.CHAT);
207        packet.setTo(groupChat ? to.asBareJid() : to);
208        final Element displayed = packet.addChild("displayed", "urn:xmpp:chat-markers:0");
209        if (groupChat) {
210            final String stanzaId = message.getServerMsgId();
211            if (stanzaId != null) {
212                displayed.setAttribute("id", stanzaId);
213            } else {
214                displayed.setAttribute("sender", to.toString());
215                displayed.setAttribute("id", message.getRemoteMsgId());
216            }
217        } else {
218            displayed.setAttribute("id", message.getRemoteMsgId());
219        }
220        packet.addChild("store", "urn:xmpp:hints");
221        return packet;
222    }
223
224    public im.conversations.android.xmpp.model.stanza.Message reaction(
225            final Jid to,
226            final boolean groupChat,
227            final Message inReplyTo,
228            final String reactingTo,
229            final Collection<String> ourReactions) {
230        final im.conversations.android.xmpp.model.stanza.Message packet =
231                new im.conversations.android.xmpp.model.stanza.Message();
232        packet.setType(
233                groupChat
234                        ? im.conversations.android.xmpp.model.stanza.Message.Type.GROUPCHAT
235                        : im.conversations.android.xmpp.model.stanza.Message.Type.CHAT);
236        packet.setTo(to);
237        final var reactions = packet.addExtension(new Reactions());
238        reactions.setId(reactingTo);
239        for (final String ourReaction : ourReactions) {
240            reactions.addExtension(new Reaction(ourReaction));
241        }
242
243        final var thread = inReplyTo.getThread();
244        if (thread != null) packet.addChild(thread);
245
246        packet.addChild("store", "urn:xmpp:hints");
247        return packet;
248    }
249
250    public im.conversations.android.xmpp.model.stanza.Message conferenceSubject(
251            Conversation conversation, String subject) {
252        im.conversations.android.xmpp.model.stanza.Message packet =
253                new im.conversations.android.xmpp.model.stanza.Message();
254        packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.GROUPCHAT);
255        packet.setTo(conversation.getJid().asBareJid());
256        packet.addChild("subject").setContent(subject);
257        packet.setFrom(conversation.getAccount().getJid().asBareJid());
258        return packet;
259    }
260
261    public im.conversations.android.xmpp.model.stanza.Message requestVoice(Jid jid) {
262        final var packet = new im.conversations.android.xmpp.model.stanza.Message();
263        packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.NORMAL);
264        packet.setTo(jid.asBareJid());
265        final var form = new Data();
266        form.setFormType("http://jabber.org/protocol/muc#request");
267        form.put("muc#role", "participant");
268        form.submit();
269        packet.addChild(form);
270        return packet;
271    }
272
273    public im.conversations.android.xmpp.model.stanza.Message directInvite(
274            final Conversation conversation, final Jid contact) {
275        im.conversations.android.xmpp.model.stanza.Message packet =
276                new im.conversations.android.xmpp.model.stanza.Message();
277        packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.NORMAL);
278        packet.setTo(contact);
279        packet.setFrom(conversation.getAccount().getJid());
280        Element x = packet.addChild("x", "jabber:x:conference");
281        x.setAttribute("jid", conversation.getJid().asBareJid());
282        String password = conversation.getMucOptions().getPassword();
283        if (password != null) {
284            x.setAttribute("password", password);
285        }
286        if (contact.isFullJid()) {
287            packet.addChild("no-store", "urn:xmpp:hints");
288            packet.addChild("no-copy", "urn:xmpp:hints");
289        }
290        return packet;
291    }
292
293    public im.conversations.android.xmpp.model.stanza.Message invite(
294            final Conversation conversation, final Jid contact) {
295        final var packet = new im.conversations.android.xmpp.model.stanza.Message();
296        packet.setTo(conversation.getJid().asBareJid());
297        packet.setFrom(conversation.getAccount().getJid());
298        Element x = new Element("x");
299        x.setAttribute("xmlns", "http://jabber.org/protocol/muc#user");
300        Element invite = new Element("invite");
301        invite.setAttribute("to", contact.asBareJid());
302        x.addChild(invite);
303        packet.addChild(x);
304        return packet;
305    }
306
307    public im.conversations.android.xmpp.model.stanza.Message received(
308            Account account,
309            final Jid from,
310            final String id,
311            ArrayList<String> namespaces,
312            im.conversations.android.xmpp.model.stanza.Message.Type type) {
313        final var receivedPacket = new im.conversations.android.xmpp.model.stanza.Message();
314        receivedPacket.setType(type);
315        receivedPacket.setTo(from);
316        receivedPacket.setFrom(account.getJid());
317        for (final String namespace : namespaces) {
318            receivedPacket.addChild("received", namespace).setAttribute("id", id);
319        }
320        receivedPacket.addChild("store", "urn:xmpp:hints");
321        return receivedPacket;
322    }
323
324    public im.conversations.android.xmpp.model.stanza.Message received(
325            Account account, Jid to, String id) {
326        im.conversations.android.xmpp.model.stanza.Message packet =
327                new im.conversations.android.xmpp.model.stanza.Message();
328        packet.setFrom(account.getJid());
329        packet.setTo(to);
330        packet.addChild("received", "urn:xmpp:receipts").setAttribute("id", id);
331        packet.addChild("store", "urn:xmpp:hints");
332        return packet;
333    }
334
335    public im.conversations.android.xmpp.model.stanza.Message sessionFinish(
336            final Jid with, final String sessionId, final Reason reason) {
337        final im.conversations.android.xmpp.model.stanza.Message packet =
338                new im.conversations.android.xmpp.model.stanza.Message();
339        packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.CHAT);
340        packet.setTo(with);
341        final Element finish = packet.addChild("finish", Namespace.JINGLE_MESSAGE);
342        finish.setAttribute("id", sessionId);
343        final Element reasonElement = finish.addChild("reason", Namespace.JINGLE);
344        reasonElement.addChild(reason.toString());
345        packet.addChild("store", "urn:xmpp:hints");
346        return packet;
347    }
348
349    public im.conversations.android.xmpp.model.stanza.Message sessionProposal(
350            final JingleConnectionManager.RtpSessionProposal proposal) {
351        final im.conversations.android.xmpp.model.stanza.Message packet =
352                new im.conversations.android.xmpp.model.stanza.Message();
353        packet.setType(
354                im.conversations.android.xmpp.model.stanza.Message.Type
355                        .CHAT); // we want to carbon copy those
356        packet.setTo(proposal.with);
357        packet.setId(JingleRtpConnection.JINGLE_MESSAGE_PROPOSE_ID_PREFIX + proposal.sessionId);
358        final Element propose = packet.addChild("propose", Namespace.JINGLE_MESSAGE);
359        propose.setAttribute("id", proposal.sessionId);
360        for (final Media media : proposal.media) {
361            propose.addChild("description", Namespace.JINGLE_APPS_RTP)
362                    .setAttribute("media", media.toString());
363        }
364        packet.addChild("request", "urn:xmpp:receipts");
365        packet.addChild("store", "urn:xmpp:hints");
366        return packet;
367    }
368
369    public im.conversations.android.xmpp.model.stanza.Message sessionRetract(
370            final JingleConnectionManager.RtpSessionProposal proposal) {
371        final im.conversations.android.xmpp.model.stanza.Message packet =
372                new im.conversations.android.xmpp.model.stanza.Message();
373        packet.setType(
374                im.conversations.android.xmpp.model.stanza.Message.Type
375                        .CHAT); // we want to carbon copy those
376        packet.setTo(proposal.with);
377        final Element propose = packet.addChild("retract", Namespace.JINGLE_MESSAGE);
378        propose.setAttribute("id", proposal.sessionId);
379        propose.addChild("description", Namespace.JINGLE_APPS_RTP);
380        packet.addChild("store", "urn:xmpp:hints");
381        return packet;
382    }
383
384    public im.conversations.android.xmpp.model.stanza.Message sessionReject(
385            final Jid with, final String sessionId) {
386        final im.conversations.android.xmpp.model.stanza.Message packet =
387                new im.conversations.android.xmpp.model.stanza.Message();
388        packet.setType(
389                im.conversations.android.xmpp.model.stanza.Message.Type
390                        .CHAT); // we want to carbon copy those
391        packet.setTo(with);
392        final Element propose = packet.addChild("reject", Namespace.JINGLE_MESSAGE);
393        propose.setAttribute("id", sessionId);
394        propose.addChild("description", Namespace.JINGLE_APPS_RTP);
395        packet.addChild("store", "urn:xmpp:hints");
396        return packet;
397    }
398}