Skip to content

Commit ba99582

Browse files
sjuddglide-copybara-robot
authored andcommittedJan 13, 2020
Internal change
PiperOrigin-RevId: 289164359
1 parent cbbb254 commit ba99582

14 files changed

+1160
-0
lines changed
 

‎integration/cronet/build.gradle

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.bumptech.glide.integration.cronet
2+
3+
apply plugin: 'com.android.library'
4+
5+
dependencies {
6+
implementation project(':library')
7+
annotationProcessor project(':annotation:compiler')
8+
9+
api "androidx.annotation:annotation:${ANDROID_X_VERSION}"
10+
}
11+
12+
android {
13+
compileSdkVersion COMPILE_SDK_VERSION as int
14+
15+
defaultConfig {
16+
minSdkVersion MIN_SDK_VERSION as int
17+
targetSdkVersion TARGET_SDK_VERSION as int
18+
19+
versionName VERSION_NAME as String
20+
}
21+
22+
compileOptions {
23+
sourceCompatibility JavaVersion.VERSION_1_7
24+
targetCompatibility JavaVersion.VERSION_1_7
25+
}
26+
}
27+
28+
apply from: "${rootProject.projectDir}/scripts/upload.gradle"

‎integration/cronet/gradle.properties

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
POM_NAME=Glide Cronet Integration
2+
POM_ARTIFACT_ID=cronet-integration
3+
POM_PACKAGING=aar
4+
POM_DESCRIPTION=An integration library to use Cronet to fetch data over http/https in Glide

‎integration/cronet/lint.xml

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<lint>
3+
<issue id="AllowBackup" severity="ignore"/>
4+
</lint>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<manifest package="com.bumptech.glide.integration.cronet">
2+
<application />
3+
</manifest>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package com.bumptech.glide.integration.cronet;
2+
3+
import java.nio.ByteBuffer;
4+
import java.util.ArrayDeque;
5+
import java.util.List;
6+
import java.util.Map;
7+
import java.util.Queue;
8+
import java.util.concurrent.atomic.AtomicBoolean;
9+
import org.chromium.net.UrlResponseInfo;
10+
11+
/**
12+
* A utility for processing response bodies, as one contiguous buffer rather than an asynchronous
13+
* stream.
14+
*/
15+
final class BufferQueue {
16+
public static final String CONTENT_LENGTH = "content-length";
17+
public static final String CONTENT_ENCODING = "content-encoding";
18+
private final Queue<ByteBuffer> mBuffers;
19+
private final AtomicBoolean mIsCoalesced = new AtomicBoolean(false);
20+
21+
public static Builder builder() {
22+
return new Builder();
23+
}
24+
25+
/**
26+
* Use this class during a request, to combine streamed buffers of a response into a single final
27+
* buffer.
28+
*
29+
* <p>For example: {@code @Override public void onResponseStarted(UrlRequest request,
30+
* UrlResponseInfo info) { request.read(builder.getFirstBuffer(info)); } @Override public void
31+
* onReadCompleted(UrlRequest request, UrlResponseInfo info, ByteBuffer buffer) {
32+
* request.read(builder.getNextBuffer(buffer)); } }
33+
*/
34+
public static final class Builder {
35+
private ArrayDeque<ByteBuffer> mBuffers = new ArrayDeque<>();
36+
private RuntimeException whenClosed;
37+
38+
private Builder() {}
39+
40+
/** Returns the next buffer to write data into. */
41+
public ByteBuffer getNextBuffer(ByteBuffer lastBuffer) {
42+
if (mBuffers == null) {
43+
throw new RuntimeException(whenClosed);
44+
}
45+
if (lastBuffer != mBuffers.peekLast()) {
46+
mBuffers.addLast(lastBuffer);
47+
}
48+
if (lastBuffer.hasRemaining()) {
49+
return lastBuffer;
50+
} else {
51+
return ByteBuffer.allocateDirect(8096);
52+
}
53+
}
54+
55+
/** Returns a ByteBuffer heuristically sized to hold the whole response body. */
56+
public ByteBuffer getFirstBuffer(UrlResponseInfo info) {
57+
// Security note - a malicious server could attempt to exhaust client memory by sending
58+
// down a Content-Length of a very large size, which we would eagerly allocate without
59+
// the server having to actually send those bytes. This isn't considered to be an
60+
// issue, because that same malicious server could use our transparent gzip to force us
61+
// to allocate 1032 bytes per byte sent by the server.
62+
return ByteBuffer.allocateDirect((int) Math.min(bufferSizeHeuristic(info), 524288));
63+
}
64+
65+
private static long bufferSizeHeuristic(UrlResponseInfo info) {
66+
final Map<String, List<String>> headers = info.getAllHeaders();
67+
if (headers.containsKey(CONTENT_LENGTH)) {
68+
long contentLength = Long.parseLong(headers.get(CONTENT_LENGTH).get(0));
69+
boolean isCompressed =
70+
!headers.containsKey(CONTENT_ENCODING)
71+
|| (headers.get(CONTENT_ENCODING).size() == 1
72+
&& "identity".equals(headers.get(CONTENT_ENCODING).get(0)));
73+
if (isCompressed) {
74+
// We have to guess at the uncompressed size. In the future, consider guessing a
75+
// compression ratio based on the content-type and content-encoding. For now,
76+
// assume 2.
77+
return 2 * contentLength;
78+
} else {
79+
// In this case, we know exactly how many bytes we're going to get, so we can
80+
// size our buffer perfectly. However, we still have to call read() for the last time,
81+
// even when we know there shouldn't be any more bytes coming. To avoid allocating another
82+
// buffer for that case, add one more byte than we really need.
83+
return contentLength + 1;
84+
}
85+
} else {
86+
// No content-length. This means we're either being sent a chunked response, or the
87+
// java stack stripped content length because of transparent gzip. In either case we really
88+
// have no idea, and so we fall back to a reasonable guess.
89+
return 8192;
90+
}
91+
}
92+
93+
public BufferQueue build() {
94+
whenClosed = new RuntimeException();
95+
final ArrayDeque<ByteBuffer> buffers = mBuffers;
96+
mBuffers = null;
97+
return new BufferQueue(buffers);
98+
}
99+
}
100+
101+
private BufferQueue(Queue<ByteBuffer> buffers) {
102+
mBuffers = buffers;
103+
for (ByteBuffer buffer : mBuffers) {
104+
buffer.flip();
105+
}
106+
}
107+
108+
/** Returns the response body as a single contiguous buffer. */
109+
public ByteBuffer coalesceToBuffer() {
110+
markCoalesced();
111+
if (mBuffers.size() == 0) {
112+
return ByteBuffer.allocateDirect(0);
113+
} else if (mBuffers.size() == 1) {
114+
return mBuffers.remove();
115+
} else {
116+
int size = 0;
117+
for (ByteBuffer buffer : mBuffers) {
118+
size += buffer.remaining();
119+
}
120+
ByteBuffer result = ByteBuffer.allocateDirect(size);
121+
while (!mBuffers.isEmpty()) {
122+
result.put(mBuffers.remove());
123+
}
124+
result.flip();
125+
return result;
126+
}
127+
}
128+
129+
private void markCoalesced() {
130+
if (!mIsCoalesced.compareAndSet(false, true)) {
131+
throw new IllegalStateException("This BufferQueue has already been consumed");
132+
}
133+
}
134+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.bumptech.glide.integration.cronet;
2+
3+
import java.nio.ByteBuffer;
4+
5+
/**
6+
* Parses a {@link java.nio.ByteBuffer} to a particular data type.
7+
*
8+
* @param <T> The type of data to parse the buffer to.
9+
*/
10+
interface ByteBufferParser<T> {
11+
/** Returns the required type of data parsed from the given {@link ByteBuffer}. */
12+
T parse(ByteBuffer byteBuffer);
13+
/** Returns the {@link Class} of the data that will be parsed from {@link ByteBuffer}s. */
14+
Class<T> getDataClass();
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,389 @@
1+
package com.bumptech.glide.integration.cronet;
2+
3+
import android.util.Log;
4+
import androidx.annotation.Nullable;
5+
import com.bumptech.glide.Priority;
6+
import com.bumptech.glide.load.HttpException;
7+
import com.bumptech.glide.load.engine.executor.GlideExecutor;
8+
import com.bumptech.glide.load.engine.executor.GlideExecutor.UncaughtThrowableStrategy;
9+
import com.bumptech.glide.load.model.GlideUrl;
10+
import com.google.common.base.Supplier;
11+
import com.google.common.base.Suppliers;
12+
import java.io.IOException;
13+
import java.net.HttpURLConnection;
14+
import java.nio.ByteBuffer;
15+
import java.util.ArrayDeque;
16+
import java.util.ArrayList;
17+
import java.util.EnumMap;
18+
import java.util.HashMap;
19+
import java.util.List;
20+
import java.util.Map;
21+
import java.util.concurrent.Executor;
22+
import org.chromium.net.CronetException;
23+
import org.chromium.net.UrlRequest;
24+
import org.chromium.net.UrlRequest.Callback;
25+
import org.chromium.net.UrlResponseInfo;
26+
27+
/**
28+
* Ensures that two simultaneous requests for exactly the same url make only a single http request.
29+
*
30+
* <p>Requests are started by Glide on multiple threads in a thread pool. An arbitrary number of
31+
* threads may attempt to start or cancel requests for one or more urls at once. Our goal is to
32+
* ensure:
33+
* <li>
34+
*
35+
* <ul>
36+
* A new request is made to cronet if a url is requested and no cronet request for that url is
37+
* in progress
38+
* <ul>
39+
* Subsequent requests for in progress urls do not make new requests to cronet, but are
40+
* notified when the existing cronet request completes.
41+
* <ul>
42+
* Cancelling a single request does not cancel the cronet request if multiple requests for
43+
* the url have been made, but cancelling all requests for a url does cancel the cronet
44+
* request.
45+
*/
46+
final class ChromiumRequestSerializer {
47+
private static final String TAG = "ChromiumSerializer";
48+
49+
private static final Map<Priority, Integer> GLIDE_TO_CHROMIUM_PRIORITY =
50+
new EnumMap<>(Priority.class);
51+
// Memoized so that all callers can share an instance.
52+
// Suppliers.memoize() is thread safe. See google3/java/com/google/common/base/Suppliers.java
53+
private static final Supplier<Executor> glideExecutorSupplier =
54+
Suppliers.memoize(
55+
new Supplier<Executor>() {
56+
@Override
57+
public GlideExecutor get() {
58+
// Allow network operations, but use a single thread. See b/37684357.
59+
return GlideExecutor.newSourceExecutor(
60+
1 /*threadCount*/, "chromium-serializer", UncaughtThrowableStrategy.DEFAULT);
61+
}
62+
});
63+
64+
private abstract static class PriorityRunnable implements Runnable, Comparable<PriorityRunnable> {
65+
66+
private final int priority;
67+
68+
private PriorityRunnable(Priority priority) {
69+
this.priority = priority.ordinal();
70+
}
71+
72+
@Override
73+
public final int compareTo(PriorityRunnable another) {
74+
if (another.priority > this.priority) {
75+
return -1;
76+
} else if (another.priority < this.priority) {
77+
return 1;
78+
}
79+
return 0;
80+
}
81+
}
82+
83+
static {
84+
GLIDE_TO_CHROMIUM_PRIORITY.put(Priority.IMMEDIATE, UrlRequest.Builder.REQUEST_PRIORITY_HIGHEST);
85+
GLIDE_TO_CHROMIUM_PRIORITY.put(Priority.HIGH, UrlRequest.Builder.REQUEST_PRIORITY_MEDIUM);
86+
GLIDE_TO_CHROMIUM_PRIORITY.put(Priority.NORMAL, UrlRequest.Builder.REQUEST_PRIORITY_LOW);
87+
GLIDE_TO_CHROMIUM_PRIORITY.put(Priority.LOW, UrlRequest.Builder.REQUEST_PRIORITY_LOWEST);
88+
}
89+
90+
private final JobPool jobPool = new JobPool();
91+
private final Map<GlideUrl, Job> jobs = new HashMap<>();
92+
private final CronetRequestFactory requestFactory;
93+
@Nullable private final DataLogger dataLogger;
94+
95+
ChromiumRequestSerializer(CronetRequestFactory requestFactory, @Nullable DataLogger dataLogger) {
96+
this.requestFactory = requestFactory;
97+
this.dataLogger = dataLogger;
98+
}
99+
100+
void startRequest(Priority priority, GlideUrl glideUrl, Listener listener) {
101+
boolean startNewRequest = false;
102+
Job job;
103+
synchronized (this) {
104+
job = jobs.get(glideUrl);
105+
if (job == null) {
106+
startNewRequest = true;
107+
job = jobPool.get(glideUrl);
108+
jobs.put(glideUrl, job);
109+
}
110+
job.addListener(listener);
111+
}
112+
113+
if (startNewRequest) {
114+
if (Log.isLoggable(TAG, Log.VERBOSE)) {
115+
Log.v(TAG, "Fetching image url using cronet" + " url: " + glideUrl);
116+
}
117+
job.priority = priority;
118+
job.request =
119+
requestFactory
120+
.newRequest(
121+
glideUrl.toStringUrl(),
122+
GLIDE_TO_CHROMIUM_PRIORITY.get(priority),
123+
glideUrl.getHeaders(),
124+
job)
125+
.build();
126+
job.request.start();
127+
128+
// It's possible we will be cancelled between adding the job to the job list and starting the
129+
// corresponding request. We don't want to hold a lock while starting the request, because
130+
// starting the request may block for a while and we need cancellation to happen quickly (it
131+
// happens on the main thread).
132+
if (job.isCancelled) {
133+
job.request.cancel();
134+
}
135+
}
136+
}
137+
138+
void cancelRequest(GlideUrl glideUrl, Listener listener) {
139+
final Job job;
140+
synchronized (this) {
141+
job = jobs.get(glideUrl);
142+
}
143+
// Jobs may be cancelled before they are started.
144+
if (job != null) {
145+
job.removeListener(listener);
146+
}
147+
}
148+
149+
private static IOException getExceptionIfFailed(
150+
UrlResponseInfo info, IOException e, boolean wasCancelled) {
151+
if (wasCancelled) {
152+
return null;
153+
} else if (e != null) {
154+
return e;
155+
} else if (info.getHttpStatusCode() != HttpURLConnection.HTTP_OK) {
156+
return new HttpException(info.getHttpStatusCode());
157+
}
158+
return null;
159+
}
160+
161+
/**
162+
* Manages a single cronet request for a single url with one or more active listeners.
163+
*
164+
* <p>Cronet requests are cancelled when all listeners are removed.
165+
*/
166+
private class Job extends Callback {
167+
private final List<Listener> listeners = new ArrayList<>(2);
168+
169+
private GlideUrl glideUrl;
170+
private Priority priority;
171+
private long startTime;
172+
private UrlRequest request;
173+
private long endTimeMs;
174+
private long responseStartTimeMs;
175+
private volatile boolean isCancelled;
176+
private BufferQueue.Builder builder;
177+
178+
void init(GlideUrl glideUrl) {
179+
startTime = System.currentTimeMillis();
180+
this.glideUrl = glideUrl;
181+
}
182+
183+
void addListener(Listener listener) {
184+
synchronized (ChromiumRequestSerializer.this) {
185+
listeners.add(listener);
186+
}
187+
}
188+
189+
void removeListener(Listener listener) {
190+
synchronized (ChromiumRequestSerializer.this) {
191+
// Note: multiple cancellation calls + a subsequent request for a url may mean we fail to
192+
// remove the listener here because that listener is actually for a previous request. Since
193+
// that race is harmless, we simply ignore it.
194+
listeners.remove(listener);
195+
if (listeners.isEmpty()) {
196+
isCancelled = true;
197+
jobs.remove(glideUrl);
198+
}
199+
}
200+
201+
// The request may not have started yet, so request may be null.
202+
if (isCancelled) {
203+
UrlRequest localRequest = request;
204+
if (localRequest != null) {
205+
localRequest.cancel();
206+
}
207+
}
208+
}
209+
210+
@Override
211+
public void onRedirectReceived(UrlRequest urlRequest, UrlResponseInfo urlResponseInfo, String s)
212+
throws Exception {
213+
urlRequest.followRedirect();
214+
}
215+
216+
@Override
217+
public void onResponseStarted(UrlRequest request, UrlResponseInfo info) {
218+
responseStartTimeMs = System.currentTimeMillis();
219+
builder = BufferQueue.builder();
220+
request.read(builder.getFirstBuffer(info));
221+
}
222+
223+
@Override
224+
public void onReadCompleted(
225+
UrlRequest urlRequest, UrlResponseInfo urlResponseInfo, ByteBuffer byteBuffer)
226+
throws Exception {
227+
request.read(builder.getNextBuffer(byteBuffer));
228+
}
229+
230+
@Override
231+
public void onSucceeded(UrlRequest request, final UrlResponseInfo info) {
232+
glideExecutorSupplier
233+
.get()
234+
.execute(
235+
new PriorityRunnable(priority) {
236+
@Override
237+
public void run() {
238+
onRequestFinished(
239+
info,
240+
null /*exception*/,
241+
false /*wasCancelled*/,
242+
builder.build().coalesceToBuffer());
243+
}
244+
});
245+
}
246+
247+
@Override
248+
public void onFailed(
249+
UrlRequest urlRequest, final UrlResponseInfo urlResponseInfo, final CronetException e) {
250+
glideExecutorSupplier
251+
.get()
252+
.execute(
253+
new PriorityRunnable(priority) {
254+
@Override
255+
public void run() {
256+
onRequestFinished(urlResponseInfo, e, false /*wasCancelled*/, null /*buffer*/);
257+
}
258+
});
259+
}
260+
261+
@Override
262+
public void onCanceled(UrlRequest urlRequest, @Nullable final UrlResponseInfo urlResponseInfo) {
263+
glideExecutorSupplier
264+
.get()
265+
.execute(
266+
new PriorityRunnable(priority) {
267+
@Override
268+
public void run() {
269+
onRequestFinished(
270+
urlResponseInfo, null /*exception*/, true /*wasCancelled*/, null /*buffer*/);
271+
}
272+
});
273+
}
274+
275+
private void onRequestFinished(
276+
UrlResponseInfo info,
277+
@Nullable CronetException e,
278+
boolean wasCancelled,
279+
ByteBuffer buffer) {
280+
synchronized (ChromiumRequestSerializer.this) {
281+
jobs.remove(glideUrl);
282+
}
283+
284+
Exception exception = getExceptionIfFailed(info, e, wasCancelled);
285+
boolean isSuccess = exception == null && !wasCancelled;
286+
287+
endTimeMs = System.currentTimeMillis();
288+
289+
maybeLogResult(isSuccess, exception, wasCancelled, buffer);
290+
if (isSuccess) {
291+
notifySuccess(buffer);
292+
} else {
293+
notifyFailure(exception);
294+
}
295+
296+
if (dataLogger != null) {
297+
dataLogger.logNetworkData(info, startTime, responseStartTimeMs, endTimeMs);
298+
}
299+
builder = null;
300+
301+
jobPool.put(this);
302+
}
303+
304+
private void notifySuccess(ByteBuffer buffer) {
305+
ByteBuffer toNotify = buffer;
306+
/* Locking here isn't necessary and is potentially dangerous. There's an optimization in
307+
* Glide that avoids re-posting results if the callback onRequestComplete triggers is called
308+
* on the calling thread. If that were ever to happen here (the request is cached in memory?),
309+
* this might block all requests for a while. Locking isn't necessary because the Job is
310+
* removed from the serializer's job set at the beginning of onRequestFinished. After that
311+
* point, whatever thread we're on is the only one that has access to the Job. Subsequent
312+
* requests for the same image would trigger an additional RPC/Job. */
313+
for (int i = 0, size = listeners.size(); i < size; i++) {
314+
Listener listener = listeners.get(i);
315+
listener.onRequestComplete(toNotify);
316+
toNotify = (ByteBuffer) toNotify.asReadOnlyBuffer().position(0);
317+
}
318+
}
319+
320+
private void notifyFailure(Exception exception) {
321+
/* Locking here isn't necessary and is potentially dangerous. There's an optimization in
322+
* Glide that avoids re-posting results if the callback onRequestComplete triggers is called
323+
* on the calling thread. If that were ever to happen here (the request is cached in memory?),
324+
* this might block all requests for a while. Locking isn't necessary because the Job is
325+
* removed from the serializer's job set at the beginning of onRequestFinished. After that
326+
* point, whatever thread we're on is the only one that has access to the Job. Subsequent
327+
* requests for the same image would trigger an additional RPC/Job. */
328+
for (int i = 0, size = listeners.size(); i < size; i++) {
329+
Listener listener = listeners.get(i);
330+
listener.onRequestFailed(exception);
331+
}
332+
}
333+
334+
private void maybeLogResult(
335+
boolean isSuccess, Exception exception, boolean wasCancelled, ByteBuffer buffer) {
336+
if (isSuccess && Log.isLoggable(TAG, Log.VERBOSE)) {
337+
Log.v(
338+
TAG,
339+
"Successfully completed request"
340+
+ ", url: "
341+
+ glideUrl
342+
+ ", duration: "
343+
+ (System.currentTimeMillis() - startTime)
344+
+ ", file size: "
345+
+ (buffer.limit() / 1024)
346+
+ "kb");
347+
} else if (!isSuccess && Log.isLoggable(TAG, Log.ERROR) && !wasCancelled) {
348+
Log.e(TAG, "Request failed", exception);
349+
}
350+
}
351+
352+
private void clearListeners() {
353+
synchronized (ChromiumRequestSerializer.this) {
354+
listeners.clear();
355+
request = null;
356+
isCancelled = false;
357+
}
358+
}
359+
}
360+
361+
private class JobPool {
362+
private static final int MAX_POOL_SIZE = 50;
363+
private final ArrayDeque<Job> pool = new ArrayDeque<>();
364+
365+
public synchronized Job get(GlideUrl glideUrl) {
366+
Job job = pool.poll();
367+
if (job == null) {
368+
job = new Job();
369+
}
370+
job.init(glideUrl);
371+
return job;
372+
}
373+
374+
public void put(Job job) {
375+
job.clearListeners();
376+
synchronized (this) {
377+
if (pool.size() < MAX_POOL_SIZE) {
378+
pool.offer(job);
379+
}
380+
}
381+
}
382+
}
383+
384+
interface Listener {
385+
void onRequestComplete(ByteBuffer byteBuffer);
386+
387+
void onRequestFailed(@Nullable Exception e);
388+
}
389+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.bumptech.glide.integration.cronet;
2+
3+
import androidx.annotation.Nullable;
4+
import com.bumptech.glide.Priority;
5+
import com.bumptech.glide.load.DataSource;
6+
import com.bumptech.glide.load.data.DataFetcher;
7+
import com.bumptech.glide.load.model.GlideUrl;
8+
import java.nio.ByteBuffer;
9+
10+
/** An {@link DataFetcher} for fetching {@link GlideUrl} using cronet. */
11+
final class ChromiumUrlFetcher<T> implements DataFetcher<T>, ChromiumRequestSerializer.Listener {
12+
13+
private final ChromiumRequestSerializer serializer;
14+
private final ByteBufferParser<T> parser;
15+
private final GlideUrl url;
16+
17+
private DataCallback<? super T> callback;
18+
19+
public ChromiumUrlFetcher(
20+
ChromiumRequestSerializer serializer, ByteBufferParser<T> parser, GlideUrl url) {
21+
this.serializer = serializer;
22+
this.parser = parser;
23+
this.url = url;
24+
}
25+
26+
@Override
27+
public void loadData(Priority priority, DataCallback<? super T> callback) {
28+
this.callback = callback;
29+
serializer.startRequest(priority, url, this);
30+
}
31+
32+
@Override
33+
public void cleanup() {
34+
// Nothing to cleanup.
35+
}
36+
37+
@Override
38+
public void cancel() {
39+
serializer.cancelRequest(url, this);
40+
}
41+
42+
@Override
43+
public Class<T> getDataClass() {
44+
return parser.getDataClass();
45+
}
46+
47+
@Override
48+
public DataSource getDataSource() {
49+
return DataSource.REMOTE;
50+
}
51+
52+
@Override
53+
public void onRequestComplete(ByteBuffer byteBuffer) {
54+
callback.onDataReady(parser.parse(byteBuffer));
55+
}
56+
57+
@Override
58+
public void onRequestFailed(@Nullable Exception e) {
59+
callback.onLoadFailed(e);
60+
}
61+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package com.bumptech.glide.integration.cronet;
2+
3+
import androidx.annotation.Nullable;
4+
import com.bumptech.glide.load.Options;
5+
import com.bumptech.glide.load.data.DataFetcher;
6+
import com.bumptech.glide.load.model.GlideUrl;
7+
import com.bumptech.glide.load.model.ModelLoader;
8+
import com.bumptech.glide.load.model.ModelLoaderFactory;
9+
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
10+
import com.bumptech.glide.util.ByteBufferUtil;
11+
import java.io.InputStream;
12+
import java.nio.ByteBuffer;
13+
14+
/**
15+
* An {@link com.bumptech.glide.load.model.ModelLoader} for loading urls using cronet.
16+
*
17+
* @param <T> The type of data this loader will load.
18+
*/
19+
public final class ChromiumUrlLoader<T> implements ModelLoader<GlideUrl, T> {
20+
private final ChromiumRequestSerializer requestSerializer;
21+
private final ByteBufferParser<T> parser;
22+
23+
ChromiumUrlLoader(CronetRequestFactory requestFactory, ByteBufferParser<T> parser) {
24+
this(parser, requestFactory, null /*dataLogger*/);
25+
}
26+
27+
ChromiumUrlLoader(
28+
ByteBufferParser<T> parser,
29+
CronetRequestFactory requestFactory,
30+
@Nullable DataLogger dataLogger) {
31+
this.parser = parser;
32+
requestSerializer = new ChromiumRequestSerializer(requestFactory, dataLogger);
33+
}
34+
35+
@Override
36+
public LoadData<T> buildLoadData(GlideUrl glideUrl, int width, int height, Options options) {
37+
DataFetcher<T> fetcher = new ChromiumUrlFetcher<>(requestSerializer, parser, glideUrl);
38+
return new LoadData<>(glideUrl, fetcher);
39+
}
40+
41+
@Override
42+
public boolean handles(GlideUrl glideUrl) {
43+
return true;
44+
}
45+
46+
/** Loads {@link InputStream}s for {@link GlideUrl}s using cronet. */
47+
public static final class StreamFactory
48+
implements ModelLoaderFactory<GlideUrl, InputStream>, ByteBufferParser<InputStream> {
49+
50+
private CronetRequestFactory requestFactory;
51+
@Nullable private final DataLogger dataLogger;
52+
53+
public StreamFactory(CronetRequestFactory requestFactory, @Nullable DataLogger dataLogger) {
54+
this.requestFactory = requestFactory;
55+
this.dataLogger = dataLogger;
56+
}
57+
58+
@Override
59+
public ModelLoader<GlideUrl, InputStream> build(MultiModelLoaderFactory multiFactory) {
60+
return new ChromiumUrlLoader<>(this /*parser*/, requestFactory, dataLogger);
61+
}
62+
63+
@Override
64+
public void teardown() {}
65+
66+
@Override
67+
public InputStream parse(ByteBuffer byteBuffer) {
68+
return ByteBufferUtil.toStream(byteBuffer);
69+
}
70+
71+
@Override
72+
public Class<InputStream> getDataClass() {
73+
return InputStream.class;
74+
}
75+
}
76+
77+
/** Loads {@link ByteBuffer}s for {@link GlideUrl}s using cronet. */
78+
public static final class ByteBufferFactory
79+
implements ModelLoaderFactory<GlideUrl, ByteBuffer>, ByteBufferParser<ByteBuffer> {
80+
81+
private CronetRequestFactory requestFactory;
82+
@Nullable private final DataLogger dataLogger;
83+
84+
public ByteBufferFactory(CronetRequestFactory requestFactory, @Nullable DataLogger dataLogger) {
85+
this.requestFactory = requestFactory;
86+
this.dataLogger = dataLogger;
87+
}
88+
89+
@Override
90+
public ModelLoader<GlideUrl, ByteBuffer> build(MultiModelLoaderFactory multiFactory) {
91+
return new ChromiumUrlLoader<>(this /*parser*/, requestFactory, dataLogger);
92+
}
93+
94+
@Override
95+
public void teardown() {
96+
// Do nothing.
97+
}
98+
99+
@Override
100+
public ByteBuffer parse(ByteBuffer byteBuffer) {
101+
return byteBuffer;
102+
}
103+
104+
@Override
105+
public Class<ByteBuffer> getDataClass() {
106+
return ByteBuffer.class;
107+
}
108+
}
109+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.bumptech.glide.integration.cronet;
2+
3+
import java.util.Map;
4+
import org.chromium.net.UrlRequest;
5+
6+
/** Factory to build custom cronet requests. */
7+
public interface CronetRequestFactory {
8+
9+
UrlRequest.Builder newRequest(
10+
String url, int requestPriority, Map<String, String> headers, UrlRequest.Callback listener);
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.bumptech.glide.integration.cronet;
2+
3+
import com.google.common.base.Supplier;
4+
import java.util.Map;
5+
import java.util.concurrent.Executor;
6+
import org.chromium.net.CronetEngine;
7+
import org.chromium.net.UrlRequest;
8+
9+
/** Default implementation for building cronet requests. */
10+
public final class CronetRequestFactoryImpl implements CronetRequestFactory {
11+
12+
private final Supplier<CronetEngine> cronetEngineGetter;
13+
14+
public CronetRequestFactoryImpl(Supplier<CronetEngine> cronetEngineGetter) {
15+
this.cronetEngineGetter = cronetEngineGetter;
16+
}
17+
18+
@Override
19+
public UrlRequest.Builder newRequest(
20+
String url, int requestPriority, Map<String, String> headers, UrlRequest.Callback listener) {
21+
CronetEngine engine = cronetEngineGetter.get();
22+
UrlRequest.Builder builder =
23+
engine.newUrlRequestBuilder(
24+
url,
25+
listener,
26+
new Executor() {
27+
@Override
28+
public void execute(Runnable runnable) {
29+
runnable.run();
30+
}
31+
});
32+
builder.allowDirectExecutor();
33+
builder.setPriority(requestPriority);
34+
for (Map.Entry<String, String> header : headers.entrySet()) {
35+
// Cronet owns the Accept-Encoding header and user agent
36+
String key = header.getKey();
37+
if ("Accept-Encoding".equalsIgnoreCase(key) || "User-Agent".equalsIgnoreCase(key)) {
38+
continue;
39+
}
40+
builder.addHeader(key, header.getValue());
41+
}
42+
return builder;
43+
}
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.bumptech.glide.integration.cronet;
2+
3+
import androidx.annotation.Nullable;
4+
import org.chromium.net.UrlResponseInfo;
5+
6+
/** A interface for logging data information related to loading the data. */
7+
public interface DataLogger {
8+
9+
/**
10+
* Logs the related network information.
11+
*
12+
* @param httpUrlRequest HttpUrlRequest that contains information on the request. May be {@code
13+
* null} if the request was cancelled.
14+
* @param startTimeMs Timestamp (ms) that the request started.
15+
* @param responseStartTimeMs Timestamp (ms) when the first header byte was received.
16+
* @param endTimeMs Timestamp (ms) that the request ended.
17+
*/
18+
void logNetworkData(
19+
@Nullable UrlResponseInfo httpUrlRequest,
20+
long startTimeMs,
21+
long responseStartTimeMs,
22+
long endTimeMs);
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3+
package="com.bumptech.glide.integration.cronet">
4+
<application />
5+
<uses-sdk android:minSdkVersion="14" android:targetSdkVersion="26"/>
6+
</manifest>
7+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
package com.bumptech.glide.integration.cronet;
2+
3+
import static com.google.common.truth.Truth.assertThat;
4+
import static org.mockito.ArgumentMatchers.any;
5+
import static org.mockito.ArgumentMatchers.anyInt;
6+
import static org.mockito.ArgumentMatchers.anyMap;
7+
import static org.mockito.ArgumentMatchers.anyString;
8+
import static org.mockito.ArgumentMatchers.isA;
9+
import static org.mockito.Matchers.eq;
10+
import static org.mockito.Matchers.isNull;
11+
import static org.mockito.Mockito.mock;
12+
import static org.mockito.Mockito.never;
13+
import static org.mockito.Mockito.timeout;
14+
import static org.mockito.Mockito.times;
15+
import static org.mockito.Mockito.verify;
16+
import static org.mockito.Mockito.when;
17+
18+
import androidx.annotation.NonNull;
19+
import com.bumptech.glide.Priority;
20+
import com.bumptech.glide.load.HttpException;
21+
import com.bumptech.glide.load.data.DataFetcher.DataCallback;
22+
import com.bumptech.glide.load.model.GlideUrl;
23+
import com.bumptech.glide.load.model.LazyHeaders;
24+
import com.bumptech.glide.load.model.LazyHeaders.Builder;
25+
import com.google.common.collect.ImmutableList;
26+
import com.google.common.util.concurrent.MoreExecutors;
27+
import java.net.HttpURLConnection;
28+
import java.nio.ByteBuffer;
29+
import java.util.AbstractMap.SimpleImmutableEntry;
30+
import java.util.Map;
31+
import java.util.Map.Entry;
32+
import java.util.concurrent.Executor;
33+
import org.chromium.net.CronetEngine;
34+
import org.chromium.net.CronetException;
35+
import org.chromium.net.UrlRequest;
36+
import org.chromium.net.UrlRequest.Callback;
37+
import org.chromium.net.UrlResponseInfo;
38+
import org.chromium.net.impl.UrlResponseInfoImpl;
39+
import org.junit.Before;
40+
import org.junit.Test;
41+
import org.junit.runner.RunWith;
42+
import org.mockito.ArgumentCaptor;
43+
import org.mockito.Matchers;
44+
import org.mockito.Mock;
45+
import org.mockito.MockitoAnnotations;
46+
import org.mockito.invocation.InvocationOnMock;
47+
import org.mockito.stubbing.Answer;
48+
import org.robolectric.RobolectricTestRunner;
49+
50+
/** Tests for {@link ChromiumUrlFetcher}. */
51+
@RunWith(RobolectricTestRunner.class)
52+
public class ChromiumUrlFetcherTest {
53+
@Mock private DataCallback<ByteBuffer> callback;
54+
@Mock private CronetEngine cronetEngine;
55+
@Mock private UrlRequest request;
56+
@Mock private UrlRequest.Builder mockUrlRequestBuilder;
57+
@Mock private ByteBufferParser<ByteBuffer> parser;
58+
@Mock private CronetRequestFactory cronetRequestFactory;
59+
60+
private UrlRequest.Builder builder;
61+
private GlideUrl glideUrl;
62+
private ChromiumUrlFetcher<ByteBuffer> fetcher;
63+
private ChromiumRequestSerializer serializer;
64+
private ArgumentCaptor<UrlRequest.Callback> urlRequestListenerCaptor;
65+
66+
@Before
67+
public void setUp() {
68+
MockitoAnnotations.initMocks(this);
69+
when(parser.getDataClass()).thenReturn(ByteBuffer.class);
70+
when(parser.parse(any(ByteBuffer.class)))
71+
.thenAnswer(
72+
new Answer<ByteBuffer>() {
73+
@Override
74+
public ByteBuffer answer(InvocationOnMock invocation) throws Throwable {
75+
return (ByteBuffer) invocation.getArguments()[0];
76+
}
77+
});
78+
when(cronetEngine.newUrlRequestBuilder(
79+
anyString(), any(UrlRequest.Callback.class), any(Executor.class)))
80+
.thenReturn(mockUrlRequestBuilder);
81+
when(mockUrlRequestBuilder.build()).thenReturn(request);
82+
83+
glideUrl = new GlideUrl("http://www.google.com");
84+
85+
urlRequestListenerCaptor = ArgumentCaptor.forClass(UrlRequest.Callback.class);
86+
serializer = new ChromiumRequestSerializer(cronetRequestFactory, null /*dataLogger*/);
87+
fetcher = new ChromiumUrlFetcher<>(serializer, parser, glideUrl);
88+
builder =
89+
cronetEngine.newUrlRequestBuilder(
90+
glideUrl.toStringUrl(),
91+
mock(UrlRequest.Callback.class),
92+
MoreExecutors.directExecutor());
93+
when(cronetRequestFactory.newRequest(
94+
anyString(), anyInt(), anyHeaders(), urlRequestListenerCaptor.capture()))
95+
.thenReturn(builder);
96+
when(builder.build()).thenReturn(request);
97+
}
98+
99+
@Test
100+
public void testLoadData_createsAndStartsRequest() {
101+
when(cronetRequestFactory.newRequest(
102+
eq(glideUrl.toStringUrl()),
103+
eq(UrlRequest.Builder.REQUEST_PRIORITY_LOWEST),
104+
anyHeaders(),
105+
any(UrlRequest.Callback.class)))
106+
.thenReturn(builder);
107+
108+
fetcher.loadData(Priority.LOW, callback);
109+
110+
verify(request).start();
111+
}
112+
113+
@Test
114+
public void testLoadData_providesHeadersFromGlideUrl() {
115+
LazyHeaders.Builder headersBuilder = new Builder();
116+
headersBuilder.addHeader("key", "value");
117+
LazyHeaders headers = headersBuilder.build();
118+
119+
glideUrl = new GlideUrl("http://www.google.com", headers);
120+
fetcher = new ChromiumUrlFetcher<>(serializer, parser, glideUrl);
121+
fetcher.loadData(Priority.LOW, callback);
122+
123+
verify(cronetRequestFactory)
124+
.newRequest(
125+
Matchers.eq(glideUrl.toStringUrl()),
126+
anyInt(),
127+
Matchers.eq(headers.getHeaders()),
128+
any(UrlRequest.Callback.class));
129+
130+
verify(request).start();
131+
}
132+
133+
@Test
134+
public void testLoadData_withInProgressRequest_doesNotStartNewRequest() {
135+
ChromiumUrlFetcher<ByteBuffer> firstFetcher =
136+
new ChromiumUrlFetcher<>(serializer, parser, glideUrl);
137+
ChromiumUrlFetcher<ByteBuffer> secondFetcher =
138+
new ChromiumUrlFetcher<>(serializer, parser, glideUrl);
139+
140+
firstFetcher.loadData(Priority.LOW, callback);
141+
secondFetcher.loadData(Priority.HIGH, callback);
142+
143+
verify(cronetRequestFactory, times(1))
144+
.newRequest(
145+
Matchers.eq(glideUrl.toStringUrl()),
146+
anyInt(),
147+
anyMap(),
148+
any(UrlRequest.Callback.class));
149+
}
150+
151+
@Test
152+
public void testLoadData_withInProgressRequest_isNotifiedWhenRequestCompletes() throws Exception {
153+
ChromiumUrlFetcher<ByteBuffer> firstFetcher =
154+
new ChromiumUrlFetcher<>(serializer, parser, glideUrl);
155+
ChromiumUrlFetcher<ByteBuffer> secondFetcher =
156+
new ChromiumUrlFetcher<>(serializer, parser, glideUrl);
157+
158+
DataCallback<ByteBuffer> firstCb = mock(DataCallback.class);
159+
DataCallback<ByteBuffer> secondCb = mock(DataCallback.class);
160+
firstFetcher.loadData(Priority.LOW, firstCb);
161+
secondFetcher.loadData(Priority.HIGH, secondCb);
162+
163+
succeed(getInfo(10, 200), urlRequestListenerCaptor.getValue(), ByteBuffer.allocateDirect(10));
164+
165+
verify(firstCb, timeout(1000)).onDataReady(isA(ByteBuffer.class));
166+
verify(secondCb, timeout(1000)).onDataReady(isA(ByteBuffer.class));
167+
}
168+
169+
@NonNull
170+
private UrlResponseInfo getInfo(int contentLength, int statusCode) {
171+
return new UrlResponseInfoImpl(
172+
ImmutableList.of(glideUrl.toStringUrl()),
173+
statusCode,
174+
"OK",
175+
ImmutableList.<Entry<String, String>>of(
176+
new SimpleImmutableEntry<>("Content-Length", Integer.toString(contentLength))),
177+
false,
178+
"",
179+
"");
180+
}
181+
182+
@Test
183+
public void testCancel_withMultipleInProgressRequests_doesNotCancelChromiumRequest() {
184+
ChromiumUrlFetcher<ByteBuffer> firstFetcher =
185+
new ChromiumUrlFetcher<>(serializer, parser, glideUrl);
186+
ChromiumUrlFetcher<ByteBuffer> secondFetcher =
187+
new ChromiumUrlFetcher<>(serializer, parser, glideUrl);
188+
189+
firstFetcher.loadData(Priority.LOW, callback);
190+
secondFetcher.loadData(Priority.HIGH, callback);
191+
192+
firstFetcher.cancel();
193+
194+
verify(request, never()).cancel();
195+
}
196+
197+
@Test
198+
public void testCancel_afterCancellingAllInProgressRequests_cancelsChromiumRequest() {
199+
ChromiumUrlFetcher<ByteBuffer> firstFetcher =
200+
new ChromiumUrlFetcher<>(serializer, parser, glideUrl);
201+
ChromiumUrlFetcher<ByteBuffer> secondFetcher =
202+
new ChromiumUrlFetcher<>(serializer, parser, glideUrl);
203+
204+
firstFetcher.loadData(Priority.LOW, callback);
205+
secondFetcher.loadData(Priority.HIGH, callback);
206+
207+
firstFetcher.cancel();
208+
secondFetcher.cancel();
209+
210+
verify(request).cancel();
211+
}
212+
213+
@Test
214+
public void testCancel_withNoStartedRequest_doesNothing() {
215+
fetcher.cancel();
216+
}
217+
218+
@Test
219+
public void testCancel_withStartedRequest_cancelsRequest() {
220+
fetcher.loadData(Priority.LOW, callback);
221+
222+
fetcher.cancel();
223+
224+
verify(request).cancel();
225+
}
226+
227+
@Test
228+
public void testRequestComplete_withNonNullException_callsCallbackWithException() {
229+
CronetException expected = new CronetException("test", /*cause=*/ null) {};
230+
fetcher.loadData(Priority.LOW, callback);
231+
urlRequestListenerCaptor.getValue().onFailed(request, null, expected);
232+
233+
verify(callback, timeout(1000)).onLoadFailed(eq(expected));
234+
}
235+
236+
@Test
237+
public void testRequestComplete_withNon200StatusCode_callsCallbackWithException()
238+
throws Exception {
239+
UrlResponseInfo info = getInfo(0, HttpURLConnection.HTTP_INTERNAL_ERROR);
240+
fetcher.loadData(Priority.LOW, callback);
241+
UrlRequest.Callback urlCallback = urlRequestListenerCaptor.getValue();
242+
succeed(info, urlCallback, ByteBuffer.allocateDirect(0));
243+
ArgumentCaptor<HttpException> captor = ArgumentCaptor.forClass(HttpException.class);
244+
verify(callback, timeout(1000)).onLoadFailed(captor.capture());
245+
assertThat(captor.getValue())
246+
.hasMessageThat()
247+
.isEqualTo("Http request failed with status code: 500");
248+
}
249+
250+
private void succeed(UrlResponseInfo info, Callback urlCallback, ByteBuffer byteBuffer)
251+
throws Exception {
252+
byteBuffer.position(byteBuffer.limit());
253+
urlCallback.onResponseStarted(request, info);
254+
urlCallback.onReadCompleted(request, info, byteBuffer);
255+
urlCallback.onSucceeded(request, info);
256+
}
257+
258+
@Test
259+
public void testRequestComplete_withUnauthorizedStatusCode_callsCallbackWithAuthError()
260+
throws Exception {
261+
UrlResponseInfo info = getInfo(0, HttpURLConnection.HTTP_FORBIDDEN);
262+
fetcher.loadData(Priority.LOW, callback);
263+
UrlRequest.Callback urlCallback = urlRequestListenerCaptor.getValue();
264+
succeed(info, urlCallback, ByteBuffer.allocateDirect(0));
265+
266+
verifyAuthError();
267+
}
268+
269+
@Test
270+
public void testRequestComplete_whenCancelledAndUnauthorized_callsCallbackWithNullError()
271+
throws Exception {
272+
UrlResponseInfo info = getInfo(0, HttpURLConnection.HTTP_FORBIDDEN);
273+
fetcher.loadData(Priority.HIGH, callback);
274+
Callback urlCallback = urlRequestListenerCaptor.getValue();
275+
urlCallback.onResponseStarted(request, info);
276+
urlCallback.onCanceled(request, info);
277+
278+
verify(callback, timeout(1000)).onLoadFailed(isNull(Exception.class));
279+
}
280+
281+
private void verifyAuthError() {
282+
ArgumentCaptor<Exception> exceptionArgumentCaptor = ArgumentCaptor.forClass(Exception.class);
283+
verify(callback, timeout(1000)).onLoadFailed(exceptionArgumentCaptor.capture());
284+
HttpException exception = (HttpException) exceptionArgumentCaptor.getValue();
285+
assertThat(exception.getStatusCode()).isEqualTo(HttpURLConnection.HTTP_FORBIDDEN);
286+
}
287+
288+
@Test
289+
public void testRequestComplete_with200AndCancelled_callsCallbackWithNullException()
290+
throws Exception {
291+
UrlResponseInfo info = getInfo(0, 200);
292+
fetcher.loadData(Priority.LOW, callback);
293+
Callback urlCallback = urlRequestListenerCaptor.getValue();
294+
urlCallback.onResponseStarted(request, info);
295+
urlCallback.onCanceled(request, info);
296+
297+
verify(callback, timeout(1000)).onLoadFailed(isNull(Exception.class));
298+
}
299+
300+
@Test
301+
public void testRequestComplete_with200NotCancelledMatchingLength_callsCallbackWithValidData()
302+
throws Exception {
303+
String data = "data";
304+
ByteBuffer expected = ByteBuffer.wrap(data.getBytes());
305+
ArgumentCaptor<ByteBuffer> captor = ArgumentCaptor.forClass(ByteBuffer.class);
306+
307+
fetcher.loadData(Priority.LOW, callback);
308+
succeed(
309+
getInfo(expected.remaining(), 200),
310+
urlRequestListenerCaptor.getValue(),
311+
expected.duplicate());
312+
313+
verify(callback, timeout(1000)).onDataReady(captor.capture());
314+
315+
ByteBuffer received = captor.getValue();
316+
317+
assertThat(
318+
new String(
319+
received.array(),
320+
received.arrayOffset() + received.position(),
321+
received.remaining()))
322+
.isEqualTo(data);
323+
}
324+
325+
private static Map<String, String> anyHeaders() {
326+
return anyMap();
327+
}
328+
}

0 commit comments

Comments
 (0)
Please sign in to comment.