1package eu.siacs.conversations.xmpp.manager;
2
3import android.util.Base64;
4import android.util.Log;
5import androidx.annotation.NonNull;
6import androidx.annotation.Nullable;
7import com.google.common.base.MoreObjects;
8import com.google.common.collect.ImmutableMap;
9import com.google.common.util.concurrent.Futures;
10import com.google.common.util.concurrent.ListenableFuture;
11import com.google.common.util.concurrent.MoreExecutors;
12import com.google.common.util.concurrent.SettableFuture;
13import eu.siacs.conversations.Config;
14import eu.siacs.conversations.entities.DownloadableFile;
15import eu.siacs.conversations.services.XmppConnectionService;
16import eu.siacs.conversations.xml.Namespace;
17import eu.siacs.conversations.xmpp.Jid;
18import eu.siacs.conversations.xmpp.XmppConnection;
19import im.conversations.android.xmpp.model.stanza.Iq;
20import im.conversations.android.xmpp.model.upload.Request;
21import im.conversations.android.xmpp.model.upload.purpose.Purpose;
22import java.io.File;
23import java.io.IOException;
24import java.nio.ByteBuffer;
25import java.util.Map;
26import java.util.UUID;
27import okhttp3.Call;
28import okhttp3.Callback;
29import okhttp3.Headers;
30import okhttp3.HttpUrl;
31import okhttp3.MediaType;
32import okhttp3.OkHttpClient;
33import okhttp3.RequestBody;
34import okhttp3.Response;
35
36public class HttpUploadManager extends AbstractManager {
37
38 private final XmppConnectionService service;
39
40 public HttpUploadManager(final XmppConnectionService service, final XmppConnection connection) {
41 super(service.getApplicationContext(), connection);
42 this.service = service;
43 }
44
45 public ListenableFuture<Slot> request(final DownloadableFile file, final String mime) {
46 return request(file.getName(), mime, file.getExpectedSize(), null);
47 }
48
49 public ListenableFuture<Slot> request(
50 final String filename,
51 final String mime,
52 final long size,
53 @Nullable final Purpose purpose) {
54 final var result =
55 getManager(DiscoManager.class).findDiscoItemByFeature(Namespace.HTTP_UPLOAD);
56 if (result == null) {
57 return Futures.immediateFailedFuture(
58 new IllegalStateException("No HTTP upload host found"));
59 }
60 return requestHttpUpload(result.getKey(), filename, mime, size, purpose);
61 }
62
63 public ListenableFuture<HttpUrl> upload(
64 final File file, final String mime, final Purpose purpose) {
65 final var filename = file.getName();
66 final var size = file.length();
67 final var slotFuture = request(filename, mime, size, purpose);
68 return Futures.transformAsync(
69 slotFuture, slot -> upload(file, mime, slot), MoreExecutors.directExecutor());
70 }
71
72 private ListenableFuture<HttpUrl> upload(final File file, final String mime, final Slot slot) {
73 final SettableFuture<HttpUrl> future = SettableFuture.create();
74 final OkHttpClient client =
75 service.getHttpConnectionManager()
76 .buildHttpClient(slot.put, getAccount(), 0, false);
77 final var body = RequestBody.create(MediaType.parse(mime), file);
78 final okhttp3.Request request =
79 new okhttp3.Request.Builder().url(slot.put).put(body).headers(slot.headers).build();
80 client.newCall(request)
81 .enqueue(
82 new Callback() {
83 @Override
84 public void onFailure(@NonNull Call call, @NonNull IOException e) {
85 future.setException(e);
86 }
87
88 @Override
89 public void onResponse(@NonNull Call call, @NonNull Response response) {
90 if (response.isSuccessful()) {
91 future.set(slot.get);
92 } else {
93 future.setException(
94 new IllegalStateException(
95 String.format(
96 "Response code was %s",
97 response.code())));
98 }
99 }
100 });
101 return future;
102 }
103
104 private ListenableFuture<Slot> requestHttpUpload(
105 final Jid host,
106 final String filename,
107 final String mime,
108 final long size,
109 @Nullable final Purpose purpose) {
110 final Iq iq = new Iq(Iq.Type.GET);
111 iq.setTo(host);
112 final var request = iq.addExtension(new Request());
113 request.setFilename(convertFilename(filename));
114 request.setSize(size);
115 request.setContentType(mime);
116 if (purpose != null) {
117 request.addExtension(purpose);
118 }
119 Log.d(Config.LOGTAG, "-->" + iq);
120 final var iqFuture = this.connection.sendIqPacket(iq);
121 return Futures.transform(
122 iqFuture,
123 response -> {
124 final var slot =
125 response.getExtension(
126 im.conversations.android.xmpp.model.upload.Slot.class);
127 if (slot == null) {
128 throw new IllegalStateException("Slot not found in IQ response");
129 }
130 final var getUrl = slot.getGetUrl();
131 final var put = slot.getPut();
132 if (getUrl == null || put == null) {
133 throw new IllegalStateException("Missing get or put in slot response");
134 }
135 final var putUrl = put.getUrl();
136 if (putUrl == null) {
137 throw new IllegalStateException("Missing put url");
138 }
139 final var contentType = mime == null ? "application/octet-stream" : mime;
140 final var headers =
141 new ImmutableMap.Builder<String, String>()
142 .putAll(put.getHeadersAllowList())
143 .put("Content-Type", contentType)
144 .buildKeepingLast();
145 return new Slot(putUrl, getUrl, headers);
146 },
147 MoreExecutors.directExecutor());
148 }
149
150 private static String convertFilename(final String name) {
151 int pos = name.indexOf('.');
152 if (pos < 0) {
153 return name;
154 }
155 try {
156 UUID uuid = UUID.fromString(name.substring(0, pos));
157 ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
158 bb.putLong(uuid.getMostSignificantBits());
159 bb.putLong(uuid.getLeastSignificantBits());
160 return Base64.encodeToString(
161 bb.array(), Base64.URL_SAFE | Base64.NO_PADDING | Base64.NO_WRAP)
162 + name.substring(pos);
163 } catch (final Exception e) {
164 return name;
165 }
166 }
167
168 public static class Slot {
169 public final HttpUrl put;
170 public final HttpUrl get;
171 public final Headers headers;
172
173 private Slot(final HttpUrl put, final HttpUrl get, final Headers headers) {
174 this.put = put;
175 this.get = get;
176 this.headers = headers;
177 }
178
179 private Slot(final HttpUrl put, final HttpUrl get, final Map<String, String> headers) {
180 this(put, get, Headers.of(headers));
181 }
182
183 @Override
184 @NonNull
185 public String toString() {
186 return MoreObjects.toStringHelper(this)
187 .add("put", put)
188 .add("get", get)
189 .add("headers", headers)
190 .toString();
191 }
192 }
193}