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.primitives.Longs;
10import com.google.common.util.concurrent.Futures;
11import com.google.common.util.concurrent.ListenableFuture;
12import com.google.common.util.concurrent.MoreExecutors;
13import com.google.common.util.concurrent.SettableFuture;
14import eu.siacs.conversations.Config;
15import eu.siacs.conversations.entities.DownloadableFile;
16import eu.siacs.conversations.services.XmppConnectionService;
17import eu.siacs.conversations.xml.Namespace;
18import eu.siacs.conversations.xmpp.Jid;
19import eu.siacs.conversations.xmpp.XmppConnection;
20import im.conversations.android.xmpp.ExtensionFactory;
21import im.conversations.android.xmpp.model.disco.info.InfoQuery;
22import im.conversations.android.xmpp.model.stanza.Iq;
23import im.conversations.android.xmpp.model.upload.Request;
24import im.conversations.android.xmpp.model.upload.purpose.Purpose;
25import java.io.File;
26import java.io.IOException;
27import java.nio.ByteBuffer;
28import java.util.Map;
29import java.util.UUID;
30import okhttp3.Call;
31import okhttp3.Callback;
32import okhttp3.Headers;
33import okhttp3.HttpUrl;
34import okhttp3.MediaType;
35import okhttp3.OkHttpClient;
36import okhttp3.RequestBody;
37import okhttp3.Response;
38
39public class HttpUploadManager extends AbstractManager {
40
41 private final XmppConnectionService service;
42
43 public HttpUploadManager(final XmppConnectionService service, final XmppConnection connection) {
44 super(service, connection);
45 this.service = service;
46 }
47
48 public ListenableFuture<Slot> request(final DownloadableFile file, final String mime) {
49 return request(file.getName(), mime, file.getExpectedSize(), null);
50 }
51
52 public ListenableFuture<Slot> request(
53 final String filename,
54 final String mime,
55 final long size,
56 @Nullable final Purpose purpose) {
57 final var result =
58 getManager(DiscoManager.class).findDiscoItemByFeature(Namespace.HTTP_UPLOAD);
59 if (result == null) {
60 return Futures.immediateFailedFuture(
61 new IllegalStateException("No HTTP upload host found"));
62 }
63 return requestHttpUpload(result.getKey(), filename, mime, size, purpose);
64 }
65
66 public ListenableFuture<HttpUrl> upload(
67 final File file, final String mime, final Purpose purpose) {
68 final var filename = file.getName();
69 final var size = file.length();
70 final var slotFuture = request(filename, mime, size, purpose);
71 return Futures.transformAsync(
72 slotFuture, slot -> upload(file, mime, slot), MoreExecutors.directExecutor());
73 }
74
75 private ListenableFuture<HttpUrl> upload(final File file, final String mime, final Slot slot) {
76 final SettableFuture<HttpUrl> future = SettableFuture.create();
77 final OkHttpClient client =
78 service.getHttpConnectionManager()
79 .buildHttpClient(slot.put, getAccount(), 0, false);
80 final var body = RequestBody.create(MediaType.parse(mime), file);
81 final okhttp3.Request request =
82 new okhttp3.Request.Builder().url(slot.put).put(body).headers(slot.headers).build();
83 client.newCall(request)
84 .enqueue(
85 new Callback() {
86 @Override
87 public void onFailure(@NonNull Call call, @NonNull IOException e) {
88 future.setException(e);
89 }
90
91 @Override
92 public void onResponse(@NonNull Call call, @NonNull Response response) {
93 if (response.isSuccessful()) {
94 future.set(slot.get);
95 } else {
96 future.setException(
97 new IllegalStateException(
98 String.format(
99 "Response code was %s",
100 response.code())));
101 }
102 }
103 });
104 return future;
105 }
106
107 private ListenableFuture<Slot> requestHttpUpload(
108 final Jid host,
109 final String filename,
110 final String mime,
111 final long size,
112 @Nullable final Purpose purpose) {
113 final Iq iq = new Iq(Iq.Type.GET);
114 iq.setTo(host);
115 final var request = iq.addExtension(new Request());
116 request.setFilename(convertFilename(filename));
117 request.setSize(size);
118 request.setContentType(mime);
119 if (purpose != null) {
120 request.addExtension(purpose);
121 }
122 Log.d(Config.LOGTAG, "-->" + iq);
123 final var iqFuture = this.connection.sendIqPacket(iq);
124 return Futures.transform(
125 iqFuture,
126 response -> {
127 final var slot =
128 response.getExtension(
129 im.conversations.android.xmpp.model.upload.Slot.class);
130 if (slot == null) {
131 throw new IllegalStateException("Slot not found in IQ response");
132 }
133 final var getUrl = slot.getGetUrl();
134 final var put = slot.getPut();
135 if (getUrl == null || put == null) {
136 throw new IllegalStateException("Missing get or put in slot response");
137 }
138 final var putUrl = put.getUrl();
139 if (putUrl == null) {
140 throw new IllegalStateException("Missing put url");
141 }
142 final var contentType = mime == null ? "application/octet-stream" : mime;
143 final var headers =
144 new ImmutableMap.Builder<String, String>()
145 .putAll(put.getHeadersAllowList())
146 .put("Content-Type", contentType)
147 .buildKeepingLast();
148 return new Slot(putUrl, getUrl, headers);
149 },
150 MoreExecutors.directExecutor());
151 }
152
153 public Service getService() {
154 if (Config.ENABLE_HTTP_UPLOAD) {
155 final var entry =
156 getManager(DiscoManager.class).findDiscoItemByFeature(Namespace.HTTP_UPLOAD);
157 return entry == null ? null : new Service(entry);
158 }
159 return null;
160 }
161
162 private static String convertFilename(final String name) {
163 int pos = name.indexOf('.');
164 if (pos < 0) {
165 return name;
166 }
167 try {
168 UUID uuid = UUID.fromString(name.substring(0, pos));
169 ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
170 bb.putLong(uuid.getMostSignificantBits());
171 bb.putLong(uuid.getLeastSignificantBits());
172 return Base64.encodeToString(
173 bb.array(), Base64.URL_SAFE | Base64.NO_PADDING | Base64.NO_WRAP)
174 + name.substring(pos);
175 } catch (final Exception e) {
176 return name;
177 }
178 }
179
180 public boolean isAvailableForSize(final long size) {
181 final var result = getManager(HttpUploadManager.class).getService();
182 if (result == null) {
183 return false;
184 }
185 final Long maxSize = result.getMaxFileSize();
186 if (maxSize == null) {
187 return true;
188 }
189 if (size <= maxSize) {
190 return true;
191 } else {
192 Log.d(
193 Config.LOGTAG,
194 getAccount().getJid().asBareJid()
195 + ": http upload is not available for files with"
196 + " size "
197 + size
198 + " (max is "
199 + maxSize
200 + ")");
201 return false;
202 }
203 }
204
205 public static final class Service {
206 private final Map.Entry<Jid, InfoQuery> addressInfoQuery;
207
208 public Service(final Map.Entry<Jid, InfoQuery> addressInfoQuery) {
209 this.addressInfoQuery = addressInfoQuery;
210 }
211
212 public Jid getAddress() {
213 return this.addressInfoQuery.getKey();
214 }
215
216 public InfoQuery getInfoQuery() {
217 return this.addressInfoQuery.getValue();
218 }
219
220 public boolean supportsPurpose(final Class<? extends Purpose> purpose) {
221 final var id = ExtensionFactory.id(purpose);
222 if (id == null) {
223 throw new IllegalStateException("Purpose has not been annotated as @XmlElement");
224 }
225 final var feature = String.format("%s#%s", id.namespace, id.name);
226 return getInfoQuery().hasFeature(feature);
227 }
228
229 public Long getMaxFileSize() {
230 final var value =
231 getInfoQuery()
232 .getServiceDiscoveryExtension(Namespace.HTTP_UPLOAD, "max-file-size");
233 return value == null ? null : Longs.tryParse(value);
234 }
235 }
236
237 public static class Slot {
238 public final HttpUrl put;
239 public final HttpUrl get;
240 public final Headers headers;
241
242 private Slot(final HttpUrl put, final HttpUrl get, final Headers headers) {
243 this.put = put;
244 this.get = get;
245 this.headers = headers;
246 }
247
248 private Slot(final HttpUrl put, final HttpUrl get, final Map<String, String> headers) {
249 this(put, get, Headers.of(headers));
250 }
251
252 @Override
253 @NonNull
254 public String toString() {
255 return MoreObjects.toStringHelper(this)
256 .add("put", put)
257 .add("get", get)
258 .add("headers", headers)
259 .toString();
260 }
261 }
262}