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) {
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 final var quote = QuoteHelper.quote(MessageUtils.prepareQuote(inReplyTo)) + "\n";
218 packet.setBody(quote + String.join(" ", ourReactions));
219
220 packet.addChild("reply", "urn:xmpp:reply:0")
221 .setAttribute("to", inReplyTo.getCounterpart())
222 .setAttribute("id", reactingTo);
223 final var replyFallback = packet.addChild("fallback", "urn:xmpp:fallback:0").setAttribute("for", "urn:xmpp:reply:0");
224 replyFallback.addChild("body", "urn:xmpp:fallback:0")
225 .setAttribute("start", "0")
226 .setAttribute("end", "" + quote.codePointCount(0, quote.length()));
227
228
229 final var fallback = packet.addChild("fallback", "urn:xmpp:fallback:0").setAttribute("for", "urn:xmpp:reactions:0");
230 fallback.addChild("body", "urn:xmpp:fallback:0");
231
232 final var thread = inReplyTo.getThread();
233 if (thread != null) packet.addChild(thread);
234
235 packet.addChild("store", "urn:xmpp:hints");
236 return packet;
237 }
238
239 public im.conversations.android.xmpp.model.stanza.Message conferenceSubject(Conversation conversation, String subject) {
240 im.conversations.android.xmpp.model.stanza.Message packet = new im.conversations.android.xmpp.model.stanza.Message();
241 packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.GROUPCHAT);
242 packet.setTo(conversation.getJid().asBareJid());
243 packet.addChild("subject").setContent(subject);
244 packet.setFrom(conversation.getAccount().getJid().asBareJid());
245 return packet;
246 }
247
248 public im.conversations.android.xmpp.model.stanza.Message requestVoice(Jid jid) {
249 final var packet = new im.conversations.android.xmpp.model.stanza.Message();
250 packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.NORMAL);
251 packet.setTo(jid.asBareJid());
252 final var form = new Data();
253 form.setFormType("http://jabber.org/protocol/muc#request");
254 form.put("muc#role", "participant");
255 form.submit();
256 packet.addChild(form);
257 return packet;
258 }
259
260 public im.conversations.android.xmpp.model.stanza.Message directInvite(final Conversation conversation, final Jid contact) {
261 im.conversations.android.xmpp.model.stanza.Message packet = new im.conversations.android.xmpp.model.stanza.Message();
262 packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.NORMAL);
263 packet.setTo(contact);
264 packet.setFrom(conversation.getAccount().getJid());
265 Element x = packet.addChild("x", "jabber:x:conference");
266 x.setAttribute("jid", conversation.getJid().asBareJid());
267 String password = conversation.getMucOptions().getPassword();
268 if (password != null) {
269 x.setAttribute("password", password);
270 }
271 if (contact.isFullJid()) {
272 packet.addChild("no-store", "urn:xmpp:hints");
273 packet.addChild("no-copy", "urn:xmpp:hints");
274 }
275 return packet;
276 }
277
278 public im.conversations.android.xmpp.model.stanza.Message invite(final Conversation conversation, final Jid contact) {
279 final var packet = new im.conversations.android.xmpp.model.stanza.Message();
280 packet.setTo(conversation.getJid().asBareJid());
281 packet.setFrom(conversation.getAccount().getJid());
282 Element x = new Element("x");
283 x.setAttribute("xmlns", "http://jabber.org/protocol/muc#user");
284 Element invite = new Element("invite");
285 invite.setAttribute("to", contact.asBareJid());
286 x.addChild(invite);
287 packet.addChild(x);
288 return packet;
289 }
290
291 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) {
292 final var receivedPacket =
293 new im.conversations.android.xmpp.model.stanza.Message();
294 receivedPacket.setType(type);
295 receivedPacket.setTo(from);
296 receivedPacket.setFrom(account.getJid());
297 for (final String namespace : namespaces) {
298 receivedPacket.addChild("received", namespace).setAttribute("id", id);
299 }
300 receivedPacket.addChild("store", "urn:xmpp:hints");
301 return receivedPacket;
302 }
303
304 public im.conversations.android.xmpp.model.stanza.Message received(Account account, Jid to, String id) {
305 im.conversations.android.xmpp.model.stanza.Message packet = new im.conversations.android.xmpp.model.stanza.Message();
306 packet.setFrom(account.getJid());
307 packet.setTo(to);
308 packet.addChild("received", "urn:xmpp:receipts").setAttribute("id", id);
309 packet.addChild("store", "urn:xmpp:hints");
310 return packet;
311 }
312
313 public im.conversations.android.xmpp.model.stanza.Message sessionFinish(
314 final Jid with, final String sessionId, final Reason reason) {
315 final im.conversations.android.xmpp.model.stanza.Message packet = new im.conversations.android.xmpp.model.stanza.Message();
316 packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.CHAT);
317 packet.setTo(with);
318 final Element finish = packet.addChild("finish", Namespace.JINGLE_MESSAGE);
319 finish.setAttribute("id", sessionId);
320 final Element reasonElement = finish.addChild("reason", Namespace.JINGLE);
321 reasonElement.addChild(reason.toString());
322 packet.addChild("store", "urn:xmpp:hints");
323 return packet;
324 }
325
326 public im.conversations.android.xmpp.model.stanza.Message sessionProposal(final JingleConnectionManager.RtpSessionProposal proposal) {
327 final im.conversations.android.xmpp.model.stanza.Message packet = new im.conversations.android.xmpp.model.stanza.Message();
328 packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.CHAT); //we want to carbon copy those
329 packet.setTo(proposal.with);
330 packet.setId(JingleRtpConnection.JINGLE_MESSAGE_PROPOSE_ID_PREFIX + proposal.sessionId);
331 final Element propose = packet.addChild("propose", Namespace.JINGLE_MESSAGE);
332 propose.setAttribute("id", proposal.sessionId);
333 for (final Media media : proposal.media) {
334 propose.addChild("description", Namespace.JINGLE_APPS_RTP).setAttribute("media", media.toString());
335 }
336 packet.addChild("request", "urn:xmpp:receipts");
337 packet.addChild("store", "urn:xmpp:hints");
338 return packet;
339 }
340
341 public im.conversations.android.xmpp.model.stanza.Message sessionRetract(final JingleConnectionManager.RtpSessionProposal proposal) {
342 final im.conversations.android.xmpp.model.stanza.Message packet = new im.conversations.android.xmpp.model.stanza.Message();
343 packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.CHAT); //we want to carbon copy those
344 packet.setTo(proposal.with);
345 final Element propose = packet.addChild("retract", Namespace.JINGLE_MESSAGE);
346 propose.setAttribute("id", proposal.sessionId);
347 propose.addChild("description", Namespace.JINGLE_APPS_RTP);
348 packet.addChild("store", "urn:xmpp:hints");
349 return packet;
350 }
351
352 public im.conversations.android.xmpp.model.stanza.Message sessionReject(final Jid with, final String sessionId) {
353 final im.conversations.android.xmpp.model.stanza.Message packet = new im.conversations.android.xmpp.model.stanza.Message();
354 packet.setType(im.conversations.android.xmpp.model.stanza.Message.Type.CHAT); //we want to carbon copy those
355 packet.setTo(with);
356 final Element propose = packet.addChild("reject", Namespace.JINGLE_MESSAGE);
357 propose.setAttribute("id", sessionId);
358 propose.addChild("description", Namespace.JINGLE_APPS_RTP);
359 packet.addChild("store", "urn:xmpp:hints");
360 return packet;
361 }
362}