Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(5)

Side by Side Diff: components/cronet/android/java/src/org/chromium/net/CronetBidirectionalStream.java

Issue 1412243012: Initial implementation of CronetBidirectionalStream. (Closed) Base URL: https://chromium.googlesource.com/chromium/src.git@master
Patch Set: Introducing RequestResponder! Created 4 years, 10 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
(Empty)
1 // Copyright 2015 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4
5 package org.chromium.net;
6
7 import org.chromium.base.Log;
8 import org.chromium.base.VisibleForTesting;
9 import org.chromium.base.annotations.CalledByNative;
10 import org.chromium.base.annotations.JNINamespace;
11 import org.chromium.base.annotations.NativeClassQualifiedName;
12
13 import java.nio.ByteBuffer;
14 import java.util.AbstractMap;
15 import java.util.ArrayList;
16 import java.util.List;
17 import java.util.Map;
18 import java.util.concurrent.Executor;
19 import java.util.concurrent.RejectedExecutionException;
20
21 import javax.annotation.concurrent.GuardedBy;
22
23 /**
24 * {@link BidirectionalStream} implementation using Chromium network stack.
25 * All @CalledByNative methods are called on the native network thread
26 * and post tasks with callback calls onto Executor. Upon returning from callbac k, the native
27 * stream is called on Executor thread and posts native tasks to the native netw ork thread.
28 */
29 @JNINamespace("cronet")
30 class CronetBidirectionalStream extends BidirectionalStream {
31 /**
32 * States of BidirectionalStream are tracked in mReadState and mWriteState.
33 * The write state is separated out as it changes independently of the read state.
34 * There is one initial state: State.NOT_STARTED. There is one normal final state:
35 * State.SUCCESS, reached after State.READING_DONE and State.WRITING_DONE. T here are two
36 * exceptional final states: State.CANCELED and State.ERROR, which can be re ached from
37 * any other non-final state.
38 */
39 private enum State {
40 /* Initial state, stream not started. */
41 NOT_STARTED,
42 /* Stream started, request headers are being sent. */
43 STARTED,
44 /* Waiting for {@code read()} to be called. */
45 WAITING_FOR_READ,
46 /* Reading from the remote, {@code onReadCompleted()} callback will be c alled when done. */
47 READING,
48 /* There is no more data to read and stream is half-closed by the remote side. */
49 READING_DONE,
50 /* Stream is canceled. */
51 CANCELED,
52 /* Error has occured, stream is closed. */
53 ERROR,
54 /* Reading and writing are done, and the stream is closed successfully. */
55 SUCCESS,
56 /* Waiting for {@code write()} to be called. */
57 WAITING_FOR_WRITE,
58 /* Writing to the remote, {@code onWriteCompleted()} callback will be ca lled when done. */
59 WRITING,
60 /* There is no more data to write and stream is half-closed by the local side. */
61 WRITING_DONE,
62 }
63
64 private final CronetUrlRequestContext mRequestContext;
65 private final Executor mExecutor;
66 private final Callback mCallback;
67 private final String mInitialUrl;
68 private final int mInitialPriority;
69 private final String mInitialMethod;
70 private final String mRequestHeaders[];
71
72 /*
73 * Synchronizes access to mNativeStream, mReadState and mWriteState.
74 */
75 private final Object mNativeStreamLock = new Object();
76
77 /* Native BidirectionalStream object, owned by CronetBidirectionalStream. */
78 @GuardedBy("mNativeStreamLock")
79 private long mNativeStream;
80
81 /**
82 * Read state is tracking reading flow.
83 * / <--- READING <--- \
84 * | |
85 * \ /
86 * NOT_STARTED -> STARTED --> WAITING_FOR_READ -> READING_DONE -> SUCCESS
87 */
88 @GuardedBy("mNativeStreamLock")
89 private State mReadState = State.NOT_STARTED;
90
91 /**
92 * Write state is tracking writing flow.
93 * / <--- WRITING <--- \
94 * | |
95 * \ /
96 * NOT_STARTED -> STARTED --> WAITING_FOR_WRITE -> WRITING_DONE -> SUCCESS
97 */
98 @GuardedBy("mNativeStreamLock")
99 private State mWriteState = State.NOT_STARTED;
100
101 private UrlResponseInfo mResponseInfo;
102
103 /*
104 * OnReadCompleted callback is repeatedly invoked when each read is complete d, so it
105 * is cached as a member variable.
106 */
107 private OnReadCompletedRunnable mOnReadCompletedTask;
108
109 /*
110 * OnWriteCompleted callback is repeatedly invoked when each write is comple ted, so it
111 * is cached as a member variable.
112 */
113 private OnWriteCompletedRunnable mOnWriteCompletedTask;
114
115 private Runnable mOnDestroyedCallbackForTesting;
116
117 private final class OnReadCompletedRunnable implements Runnable {
118 // Buffer passed back from current invocation of onReadCompleted.
119 ByteBuffer mByteBuffer;
120 // End of stream flag from current invocation of onReadCompleted.
121 boolean mEndOfStream;
122
123 @Override
124 public void run() {
125 try {
126 // Null out mByteBuffer, to pass buffer ownership to callback or release if done.
127 ByteBuffer buffer = mByteBuffer;
128 mByteBuffer = null;
129 synchronized (mNativeStreamLock) {
130 if (isDoneLocked()) {
131 return;
132 }
133 if (mEndOfStream) {
134 mReadState = State.READING_DONE;
135 if (maybeSucceedLocked()) {
kapishnikov 2016/01/29 20:08:58 I think the API would be friendlier if we always s
mef 2016/01/29 20:42:19 Acknowledged. We should consider that, currently e
136 return;
137 }
138 } else {
139 mReadState = State.WAITING_FOR_READ;
140 }
141 }
142 mCallback.onReadCompleted(CronetBidirectionalStream.this, mRespo nseInfo, buffer);
143 } catch (Exception e) {
144 onCallbackException(e);
145 }
146 }
147 }
148
149 private final class OnWriteCompletedRunnable implements Runnable {
150 // Buffer passed back from current invocation of onWriteCompleted.
151 ByteBuffer mByteBuffer;
152 // End of stream flag from current call to write.
153 boolean mEndOfStream;
154
155 @Override
156 public void run() {
157 try {
158 // Null out mByteBuffer, to pass buffer ownership to callback or release if done.
159 ByteBuffer buffer = mByteBuffer;
160 mByteBuffer = null;
161 synchronized (mNativeStreamLock) {
162 if (isDoneLocked()) {
163 return;
164 }
165 if (mEndOfStream) {
166 mWriteState = State.WRITING_DONE;
167 if (maybeSucceedLocked()) {
kapishnikov 2016/01/29 20:08:58 Same here. I think that every write() should be fo
mef 2016/01/29 20:42:18 Acknowledged.
168 return;
169 }
170 } else {
171 mWriteState = State.WAITING_FOR_WRITE;
172 }
173 }
174 mCallback.onWriteCompleted(CronetBidirectionalStream.this, mResp onseInfo, buffer);
175 } catch (Exception e) {
176 onCallbackException(e);
177 }
178 }
179 }
180
181 CronetBidirectionalStream(CronetUrlRequestContext requestContext, String url ,
182 @BidirectionalStream.Builder.StreamPriority int priority, Callback c allback,
183 Executor executor, String httpMethod, List<Map.Entry<String, String> > requestHeaders) {
184 mRequestContext = requestContext;
185 mInitialUrl = url;
186 mInitialPriority = convertStreamPriority(priority);
187 mCallback = callback;
188 mExecutor = executor;
189 mInitialMethod = httpMethod;
190 mRequestHeaders = stringsFromHeaderList(requestHeaders);
191 }
192
193 @Override
194 public void start() {
195 synchronized (mNativeStreamLock) {
196 if (mReadState != State.NOT_STARTED) {
197 throw new IllegalStateException("Stream is already started.");
198 }
199 try {
200 mNativeStream = nativeCreateBidirectionalStream(
201 mRequestContext.getUrlRequestContextAdapter());
202 mRequestContext.onRequestStarted();
203 // Non-zero startResult means an argument error.
204 int startResult = nativeStart(mNativeStream, mInitialUrl, mIniti alPriority,
205 mInitialMethod, mRequestHeaders, !doesMethodAllowWriteDa ta(mInitialMethod));
206 if (startResult == -1) {
207 throw new IllegalArgumentException("Invalid http method " + mInitialMethod);
208 }
209 if (startResult > 0) {
210 int headerPos = startResult - 1;
211 throw new IllegalArgumentException("Invalid header "
212 + mRequestHeaders[headerPos] + "=" + mRequestHeaders [headerPos + 1]);
213 }
214 mReadState = mWriteState = State.STARTED;
215 } catch (RuntimeException e) {
216 // If there's an exception, clean up and then throw the
217 // exception to the caller.
218 destroyNativeStreamLocked(false);
219 throw e;
220 }
221 }
222 }
223
224 @Override
225 public void read(ByteBuffer buffer) {
226 synchronized (mNativeStreamLock) {
227 Preconditions.checkHasRemaining(buffer);
228 Preconditions.checkDirect(buffer);
229 if (mReadState != State.WAITING_FOR_READ) {
230 throw new IllegalStateException("Unexpected read attempt.");
231 }
232 if (isDoneLocked()) {
233 return;
234 }
235 if (mOnReadCompletedTask == null) {
236 mOnReadCompletedTask = new OnReadCompletedRunnable();
237 }
238 mReadState = State.READING;
239 if (!nativeReadData(mNativeStream, buffer, buffer.position(), buffer .limit())) {
240 // Still waiting on read. This is just to have consistent
241 // behavior with the other error cases.
242 mReadState = State.WAITING_FOR_READ;
243 throw new IllegalArgumentException("Unable to call native read") ;
244 }
245 }
246 }
247
248 @Override
249 public void write(ByteBuffer buffer, boolean endOfStream) {
250 synchronized (mNativeStreamLock) {
251 Preconditions.checkDirect(buffer);
252 if (!buffer.hasRemaining() && !endOfStream) {
253 throw new IllegalArgumentException("Empty buffer before end of s tream.");
254 }
255 if (mWriteState != State.WAITING_FOR_WRITE) {
256 throw new IllegalStateException("Unexpected write attempt.");
257 }
258 if (isDoneLocked()) {
259 return;
260 }
261 if (mOnWriteCompletedTask == null) {
262 mOnWriteCompletedTask = new OnWriteCompletedRunnable();
263 }
264 mOnWriteCompletedTask.mEndOfStream = endOfStream;
265 mWriteState = State.WRITING;
266 if (!nativeWriteData(
267 mNativeStream, buffer, buffer.position(), buffer.limit() , endOfStream)) {
268 // Still waiting on write. This is just to have consistent
269 // behavior with the other error cases.
270 mWriteState = State.WAITING_FOR_WRITE;
271 throw new IllegalArgumentException("Unable to call native write" );
272 }
273 }
274 }
275
276 @Override
277 public void ping(PingCallback callback, Executor executor) {
278 // TODO(mef): May be last thing to be implemented on Android.
279 throw new UnsupportedOperationException("ping is not supported yet.");
280 }
281
282 @Override
283 public void windowUpdate(int windowSizeIncrement) {
284 // TODO(mef): Understand the needs and semantics of this method.
285 throw new UnsupportedOperationException("windowUpdate is not supported y et.");
286 }
287
288 @Override
289 public void cancel() {
290 synchronized (mNativeStreamLock) {
291 if (isDoneLocked() || mReadState == State.NOT_STARTED) {
292 return;
293 }
294 mReadState = mWriteState = State.CANCELED;
295 destroyNativeStreamLocked(true);
296 }
297 }
298
299 @Override
300 public boolean isDone() {
301 synchronized (mNativeStreamLock) {
302 return isDoneLocked();
303 }
304 }
305
306 @GuardedBy("mNativeStreamLock")
307 private boolean isDoneLocked() {
308 return mReadState != State.NOT_STARTED && mNativeStream == 0;
309 }
310
311 @SuppressWarnings("unused")
312 @CalledByNative
313 private void onRequestHeadersSent() {
314 postTaskToExecutor(new Runnable() {
315 public void run() {
316 synchronized (mNativeStreamLock) {
317 if (isDoneLocked()) {
318 return;
319 }
320 if (doesMethodAllowWriteData(mInitialMethod)) {
321 mWriteState = State.WAITING_FOR_WRITE;
322 } else {
323 mWriteState = State.WRITING_DONE;
324 }
325 }
326
327 try {
328 mCallback.onRequestHeadersSent(CronetBidirectionalStream.thi s);
329 } catch (Exception e) {
330 onCallbackException(e);
331 }
332 }
333 });
334 }
335
336 /**
337 * Called when the final set of headers, after all redirects,
338 * is received. Can only be called once for each stream.
339 */
340 @SuppressWarnings("unused")
341 @CalledByNative
342 private void onResponseHeadersReceived(int httpStatusCode, String negotiated Protocol,
343 String[] headers, long receivedBytesCount) {
344 try {
345 mResponseInfo = prepareResponseInfoOnNetworkThread(
346 httpStatusCode, negotiatedProtocol, headers, receivedBytesCo unt);
347 } catch (Exception e) {
348 failWithException(new CronetException("Cannot prepare ResponseInfo", null));
349 return;
350 }
351 postTaskToExecutor(new Runnable() {
352 public void run() {
353 synchronized (mNativeStreamLock) {
354 if (isDoneLocked()) {
355 return;
356 }
357 mReadState = State.WAITING_FOR_READ;
358 }
359
360 try {
361 mCallback.onResponseHeadersReceived(
362 CronetBidirectionalStream.this, mResponseInfo);
363 } catch (Exception e) {
364 onCallbackException(e);
365 }
366 }
367 });
368 }
369
370 @SuppressWarnings("unused")
371 @CalledByNative
372 private void onReadCompleted(final ByteBuffer byteBuffer, int bytesRead, int initialPosition,
373 int initialLimit, long receivedBytesCount) {
374 mResponseInfo.setReceivedBytesCount(receivedBytesCount);
375 if (byteBuffer.position() != initialPosition || byteBuffer.limit() != in itialLimit) {
376 failWithException(
377 new CronetException("ByteBuffer modified externally during r ead", null));
378 return;
379 }
380 if (bytesRead < 0 || initialPosition + bytesRead > initialLimit) {
381 failWithException(new CronetException("Invalid number of bytes read" , null));
382 return;
383 }
384 byteBuffer.position(initialPosition + bytesRead);
385 assert mOnReadCompletedTask.mByteBuffer == null;
386 mOnReadCompletedTask.mByteBuffer = byteBuffer;
387 mOnReadCompletedTask.mEndOfStream = (bytesRead == 0);
388 postTaskToExecutor(mOnReadCompletedTask);
389 }
390
391 @SuppressWarnings("unused")
392 @CalledByNative
393 private void onWriteCompleted(
394 final ByteBuffer byteBuffer, int initialPosition, int initialLimit) {
395 if (byteBuffer.position() != initialPosition || byteBuffer.limit() != in itialLimit) {
396 failWithException(
397 new CronetException("ByteBuffer modified externally during w rite", null));
398 return;
399 }
400 // Current implementation always writes the complete buffer.
401 byteBuffer.position(byteBuffer.limit());
402 assert mOnWriteCompletedTask.mByteBuffer == null;
403 mOnWriteCompletedTask.mByteBuffer = byteBuffer;
404 postTaskToExecutor(mOnWriteCompletedTask);
405 }
406
407 @SuppressWarnings("unused")
408 @CalledByNative
409 private void onResponseTrailersReceived(String[] trailers) {
410 final UrlResponseInfo.HeaderBlock trailersBlock =
411 new UrlResponseInfo.HeaderBlock(headersListFromStrings(trailers) );
412 postTaskToExecutor(new Runnable() {
413 public void run() {
414 synchronized (mNativeStreamLock) {
415 if (isDoneLocked()) {
416 return;
417 }
418 }
419 try {
420 mCallback.onResponseTrailersReceived(
421 CronetBidirectionalStream.this, mResponseInfo, trail ersBlock);
422 } catch (Exception e) {
423 onCallbackException(e);
424 }
425 }
426 });
427 }
428
429 @SuppressWarnings("unused")
430 @CalledByNative
431 private void onError(final int nativeError, final String errorString, long r eceivedBytesCount) {
432 if (mResponseInfo != null) {
433 mResponseInfo.setReceivedBytesCount(receivedBytesCount);
434 }
435 failWithException(new CronetException(
436 "Exception in BidirectionalStream: " + errorString, nativeError) );
kapishnikov 2016/01/29 20:08:58 The constructor of CronetException ignores the pas
mef 2016/01/29 20:42:18 Whoa! Good catch, Done.
437 }
438
439 /**
440 * Called when request is canceled, no callbacks will be called afterwards.
441 */
442 @SuppressWarnings("unused")
443 @CalledByNative
444 private void onCanceled() {
445 postTaskToExecutor(new Runnable() {
446 public void run() {
447 try {
448 mCallback.onCanceled(CronetBidirectionalStream.this, mRespon seInfo);
449 } catch (Exception e) {
450 Log.e(CronetUrlRequestContext.LOG_TAG, "Exception in onCance led method", e);
451 }
452 }
453 });
454 }
455
456 @VisibleForTesting
457 public void setOnDestroyedCallbackForTesting(Runnable onDestroyedCallbackFor Testing) {
458 mOnDestroyedCallbackForTesting = onDestroyedCallbackForTesting;
459 }
460
461 private static boolean doesMethodAllowWriteData(String methodName) {
462 return !methodName.equals("GET") && !methodName.equals("HEAD");
463 }
464
465 private static ArrayList<Map.Entry<String, String>> headersListFromStrings(S tring[] headers) {
466 ArrayList<Map.Entry<String, String>> headersList = new ArrayList<>(heade rs.length / 2);
467 for (int i = 0; i < headers.length; i += 2) {
468 headersList.add(new AbstractMap.SimpleImmutableEntry<>(headers[i], h eaders[i + 1]));
469 }
470 return headersList;
471 }
472
473 private static String[] stringsFromHeaderList(List<Map.Entry<String, String> > headersList) {
474 String headersArray[] = new String[headersList.size() * 2];
475 int i = 0;
476 for (Map.Entry<String, String> requestHeader : headersList) {
477 headersArray[i++] = requestHeader.getKey();
478 headersArray[i++] = requestHeader.getValue();
479 }
480 return headersArray;
481 }
482
483 private static int convertStreamPriority(
484 @BidirectionalStream.Builder.StreamPriority int priority) {
485 switch (priority) {
486 case Builder.STREAM_PRIORITY_IDLE:
487 return RequestPriority.IDLE;
488 case Builder.STREAM_PRIORITY_LOWEST:
489 return RequestPriority.LOWEST;
490 case Builder.STREAM_PRIORITY_LOW:
491 return RequestPriority.LOW;
492 case Builder.STREAM_PRIORITY_MEDIUM:
493 return RequestPriority.MEDIUM;
494 case Builder.STREAM_PRIORITY_HIGHEST:
495 return RequestPriority.HIGHEST;
496 default:
497 throw new IllegalArgumentException("Invalid stream priority.");
498 }
499 }
500
501 /**
502 * Checks whether reading and writing are done.
503 * @return false if either reading or writing is not done. If both reading a nd writing
504 * are done, then posts cleanup task and returns true.
505 */
506 @GuardedBy("mNativeStreamLock")
507 private boolean maybeSucceedLocked() {
508 if (mReadState != State.READING_DONE || mWriteState != State.WRITING_DON E) {
509 return false;
510 }
511
512 mReadState = mWriteState = State.SUCCESS;
513 postTaskToExecutor(new Runnable() {
514 public void run() {
515 synchronized (mNativeStreamLock) {
516 if (isDoneLocked()) {
517 return;
518 }
519 // Destroy native stream first, so UrlRequestContext could b e shut
520 // down from the listener.
521 destroyNativeStreamLocked(false);
522 }
523 try {
524 mCallback.onSucceeded(CronetBidirectionalStream.this, mRespo nseInfo);
525 } catch (Exception e) {
526 Log.e(CronetUrlRequestContext.LOG_TAG, "Exception in onSucce eded method", e);
527 }
528 }
529 });
530 return true;
531 }
532
533 /**
534 * Posts task to application Executor. Used for callbacks
535 * and other tasks that should not be executed on network thread.
536 */
537 private void postTaskToExecutor(Runnable task) {
538 try {
539 mExecutor.execute(task);
540 } catch (RejectedExecutionException failException) {
541 Log.e(CronetUrlRequestContext.LOG_TAG, "Exception posting task to ex ecutor",
542 failException);
543 // If posting a task throws an exception, then there is no choice
544 // but to destroy the stream without invoking the callback.
545 synchronized (mNativeStreamLock) {
546 mReadState = mWriteState = State.ERROR;
547 destroyNativeStreamLocked(false);
548 }
549 }
550 }
551
552 private UrlResponseInfo prepareResponseInfoOnNetworkThread(int httpStatusCod e,
553 String negotiatedProtocol, String[] headers, long receivedBytesCount ) {
554 synchronized (mNativeStreamLock) {
555 if (mNativeStream == 0) {
556 return null;
557 }
558 }
559
560 ArrayList<String> urlChain = new ArrayList<>();
561 urlChain.add(mInitialUrl);
562
563 UrlResponseInfo responseInfo = new UrlResponseInfo(urlChain, httpStatusC ode, "",
564 headersListFromStrings(headers), false, negotiatedProtocol, null );
565
566 responseInfo.setReceivedBytesCount(receivedBytesCount);
567 return responseInfo;
568 }
569
570 @GuardedBy("mNativeStreamLock")
571 private void destroyNativeStreamLocked(boolean sendOnCanceled) {
572 Log.i(CronetUrlRequestContext.LOG_TAG, "destroyNativeStreamLocked " + th is.toString());
573 if (mNativeStream == 0) {
574 return;
575 }
576 nativeDestroy(mNativeStream, sendOnCanceled);
577 mNativeStream = 0;
578 mRequestContext.onRequestDestroyed();
579 if (mOnDestroyedCallbackForTesting != null) {
580 mOnDestroyedCallbackForTesting.run();
581 }
582 }
583
584 /**
585 * Fails the stream with an exception. Only called on the Executor.
586 */
587 private void failWithExceptionOnExecutor(CronetException e) {
588 // Do not call into listener if request is complete.
589 synchronized (mNativeStreamLock) {
590 if (isDoneLocked()) {
591 return;
592 }
593 mReadState = mWriteState = State.ERROR;
594 destroyNativeStreamLocked(false);
595 }
596 try {
597 mCallback.onFailed(this, mResponseInfo, e);
598 } catch (Exception failException) {
599 Log.e(CronetUrlRequestContext.LOG_TAG, "Exception notifying of faile d request",
600 failException);
601 }
602 }
603
604 /**
605 * If callback method throws an exception, stream gets canceled
606 * and exception is reported via onFailed callback.
607 * Only called on the Executor.
608 */
609 private void onCallbackException(Exception e) {
610 CronetException streamError =
611 new CronetException("CalledByNative method has thrown an excepti on", e);
612 Log.e(CronetUrlRequestContext.LOG_TAG, "Exception in CalledByNative meth od", e);
613 failWithExceptionOnExecutor(streamError);
614 }
615
616 /**
617 * Fails the stream with an exception. Can be called on any thread.
618 */
619 private void failWithException(final CronetException exception) {
620 postTaskToExecutor(new Runnable() {
621 public void run() {
622 failWithExceptionOnExecutor(exception);
623 }
624 });
625 }
626
627 // Native methods are implemented in cronet_bidirectional_stream_adapter.cc.
628 private native long nativeCreateBidirectionalStream(long urlRequestContextAd apter);
629
630 @NativeClassQualifiedName("CronetBidirectionalStreamAdapter")
631 private native int nativeStart(long nativePtr, String url, int priority, Str ing method,
632 String[] headers, boolean endOfStream);
633
634 @NativeClassQualifiedName("CronetBidirectionalStreamAdapter")
635 private native boolean nativeReadData(
636 long nativePtr, ByteBuffer byteBuffer, int position, int limit);
637
638 @NativeClassQualifiedName("CronetBidirectionalStreamAdapter")
639 private native boolean nativeWriteData(
640 long nativePtr, ByteBuffer byteBuffer, int position, int limit, bool ean endOfStream);
641
642 @NativeClassQualifiedName("CronetBidirectionalStreamAdapter")
643 private native void nativeDestroy(long nativePtr, boolean sendOnCanceled);
644 }
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698