HttpUploadManager.java

  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}