1package eu.siacs.conversations.xmpp.manager;
2
3import android.content.Context;
4import android.util.Base64;
5import com.google.common.collect.ImmutableMap;
6import com.google.common.util.concurrent.Futures;
7import com.google.common.util.concurrent.ListenableFuture;
8import com.google.common.util.concurrent.MoreExecutors;
9import eu.siacs.conversations.entities.DownloadableFile;
10import eu.siacs.conversations.xml.Namespace;
11import eu.siacs.conversations.xmpp.Jid;
12import eu.siacs.conversations.xmpp.XmppConnection;
13import im.conversations.android.xmpp.model.stanza.Iq;
14import im.conversations.android.xmpp.model.upload.Request;
15import java.nio.ByteBuffer;
16import java.util.Map;
17import java.util.UUID;
18import okhttp3.Headers;
19import okhttp3.HttpUrl;
20
21public class HttpUploadManager extends AbstractManager {
22
23 public HttpUploadManager(final Context context, final XmppConnection connection) {
24 super(context, connection);
25 }
26
27 public ListenableFuture<Slot> request(final DownloadableFile file, final String mime) {
28 final var result =
29 getManager(DiscoManager.class).findDiscoItemByFeature(Namespace.HTTP_UPLOAD);
30 if (result == null) {
31 return Futures.immediateFailedFuture(
32 new IllegalStateException("No HTTP upload host found"));
33 }
34 return requestHttpUpload(result.getKey(), file, mime);
35 }
36
37 private ListenableFuture<Slot> requestHttpUpload(
38 final Jid host, final DownloadableFile file, final String mime) {
39 final Iq iq = new Iq(Iq.Type.GET);
40 iq.setTo(host);
41 final var request = iq.addExtension(new Request());
42 request.setFilename(convertFilename(file.getName()));
43 request.setSize(file.getExpectedSize());
44 request.setContentType(mime);
45 final var iqFuture = this.connection.sendIqPacket(iq);
46 return Futures.transform(
47 iqFuture,
48 response -> {
49 final var slot =
50 response.getExtension(
51 im.conversations.android.xmpp.model.upload.Slot.class);
52 if (slot == null) {
53 throw new IllegalStateException("Slot not found in IQ response");
54 }
55 final var getUrl = slot.getGetUrl();
56 final var put = slot.getPut();
57 if (getUrl == null || put == null) {
58 throw new IllegalStateException("Missing get or put in slot response");
59 }
60 final var putUrl = put.getUrl();
61 if (putUrl == null) {
62 throw new IllegalStateException("Missing put url");
63 }
64 final var contentType = mime == null ? "application/octet-stream" : mime;
65 final var headers =
66 new ImmutableMap.Builder<String, String>()
67 .putAll(put.getHeadersAllowList())
68 .put("Content-Type", contentType)
69 .buildKeepingLast();
70 return new Slot(putUrl, getUrl, headers);
71 },
72 MoreExecutors.directExecutor());
73 }
74
75 private static String convertFilename(final String name) {
76 int pos = name.indexOf('.');
77 if (pos < 0) {
78 return name;
79 }
80 try {
81 UUID uuid = UUID.fromString(name.substring(0, pos));
82 ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
83 bb.putLong(uuid.getMostSignificantBits());
84 bb.putLong(uuid.getLeastSignificantBits());
85 return Base64.encodeToString(
86 bb.array(), Base64.URL_SAFE | Base64.NO_PADDING | Base64.NO_WRAP)
87 + name.substring(pos);
88 } catch (final Exception e) {
89 return name;
90 }
91 }
92
93 public static class Slot {
94 public final HttpUrl put;
95 public final HttpUrl get;
96 public final Headers headers;
97
98 private Slot(final HttpUrl put, final HttpUrl get, final Headers headers) {
99 this.put = put;
100 this.get = get;
101 this.headers = headers;
102 }
103
104 private Slot(final HttpUrl put, final HttpUrl get, final Map<String, String> headers) {
105 this(put, get, Headers.of(headers));
106 }
107 }
108}