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