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.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}