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