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

Side by Side Diff: content/public/android/java/src/org/chromium/content/browser/input/ThreadedInputConnection.java

Issue 1278593004: Introduce ThreadedInputConnection behind a switch (Closed) Base URL: https://chromium.googlesource.com/chromium/src.git@master
Patch Set: adding missing ImeTestUtils.java 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 2016 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.content.browser.input;
6
7 import android.os.Bundle;
8 import android.os.Handler;
9 import android.os.Looper;
10 import android.view.KeyCharacterMap;
11 import android.view.KeyEvent;
12 import android.view.inputmethod.CompletionInfo;
13 import android.view.inputmethod.CorrectionInfo;
14 import android.view.inputmethod.EditorInfo;
15 import android.view.inputmethod.ExtractedText;
16 import android.view.inputmethod.ExtractedTextRequest;
17 import android.view.inputmethod.InputConnection;
18
19 import org.chromium.base.Log;
20 import org.chromium.base.ThreadUtils;
21 import org.chromium.base.VisibleForTesting;
22
23 import java.util.concurrent.BlockingQueue;
24 import java.util.concurrent.LinkedBlockingQueue;
25
26 /**
27 * An implementation of {@link InputConnection} to communicate with external inp ut method
28 * apps. Note that it is running on IME thread (except for constructor and calls from ImeAdapter)
29 * such that it does not block UI thread and returns text values immediately aft er any change
30 * to them.
31 */
32 public class ThreadedInputConnection implements ChromiumBaseInputConnection {
33 private static final String TAG = "cr_Ime";
34 private static final boolean DEBUG_LOGS = false;
35
36 private static final TextInputState UNBLOCKER = new TextInputState(
37 "", new Range(0, 0), new Range(-1, -1), false, false /* notFromIme * /) {
38
39 @Override
40 public boolean shouldUnblock() {
41 return true;
42 }
43 };
44
45 private final ImeAdapter mImeAdapter;
46 private final Handler mHandler;
47 private int mNumNestedBatchEdits;
48
49 // TODO(changwan): check if we can keep a pool of TextInputState to avoid cr eating
50 // a bunch of new objects for each key stroke.
51 private final BlockingQueue<TextInputState> mQueue = new LinkedBlockingQueue <>();
52 private int mPendingAccent;
53 // This should be accessed on UI thread.
54 private TextInputState mLastUpdatedTextInputState;
55
56 ThreadedInputConnection(ImeAdapter imeAdapter, Handler handler) {
57 if (DEBUG_LOGS) Log.w(TAG, "constructor");
58 ImeUtils.checkOnUiThread();
59 mImeAdapter = imeAdapter;
60 mHandler = handler;
Ted C 2016/02/17 19:09:34 should we add an assert that handler.getLooper() !
Changwan Ryu 2016/02/18 06:03:27 Done.
Changwan Ryu 2016/02/18 10:38:42 Hmmm.. Actually ThreadedInputConnectionTest is usi
61 }
62
63 void initializeOutAttrsOnUiThread(int inputType, int inputFlags, EditorInfo outAttrs) {
64 if (DEBUG_LOGS) Log.w(TAG, "initializeOutAttrs");
65 ImeUtils.checkOnUiThread();
66 int initialSelStart = 0;
67 int initialSelEnd = 0;
68 mNumNestedBatchEdits = 0;
69 mPendingAccent = 0;
70 if (mLastUpdatedTextInputState != null) {
71 initialSelStart = mLastUpdatedTextInputState.selection().start();
72 initialSelEnd = mLastUpdatedTextInputState.selection().end();
73 }
74 ImeUtils.computeEditorInfo(inputType, inputFlags, initialSelStart, initi alSelEnd, outAttrs);
75 }
76
77 @Override
78 public void updateStateOnUiThread(final String text, final int selectionStar t,
79 final int selectionEnd, final int compositionStart, final int compos itionEnd,
80 boolean singleLine, final boolean isNonImeChange) {
81 ImeUtils.checkOnUiThread();
82
83 final TextInputState newState =
84 new TextInputState(text, new Range(selectionStart, selectionEnd) ,
85 new Range(compositionStart, compositionEnd), singleLine, !isNonImeChange);
86 if (DEBUG_LOGS) Log.w(TAG, "updateState: %s", newState);
87 mLastUpdatedTextInputState = newState;
88
89 addToQueueOnUiThread(newState);
90 if (isNonImeChange) {
91 mHandler.post(new Runnable() {
Ted C 2016/02/17 19:09:34 You can probably keep a single one of these runnab
Changwan Ryu 2016/02/18 06:03:26 Done.
92 @Override
93 public void run() {
94 checkQueue();
95 }
96 });
97 }
98 }
99
100 /**
101 * @see ChromiumBaseInputConnection#getHandler()
102 */
103 @Override
104 public Handler getHandler() {
105 return mHandler;
106 }
107
108 /**
109 * @see ChromiumBaseInputConnection#onRestartInputOnUiThread()
110 */
111 @Override
112 public void onRestartInputOnUiThread() {}
113
114 /**
115 * @see ChromiumBaseInputConnection#sendKeyEventOnUiThread(KeyEvent)
116 */
117 @Override
118 public boolean sendKeyEventOnUiThread(final KeyEvent event) {
119 ImeUtils.checkOnUiThread();
120 mHandler.post(new Runnable() {
121 @Override
122 public void run() {
123 sendKeyEvent(event);
124 }
125 });
126 return true;
127 }
128
129 /**
130 * @see ChromiumBaseInputConnection#moveCursorToSelectionEndOnUiThread()
131 */
132 @Override
133 public void moveCursorToSelectionEndOnUiThread() {
134 mHandler.post(new Runnable() {
135 @Override
136 public void run() {
137 TextInputState textInputState = requestTextInputState();
138 if (textInputState == null) return;
139 Range selection = textInputState.selection();
140 setSelection(selection.end(), selection.end());
141 }
142 });
143 }
144
145 @Override
146 @VisibleForTesting
147 public void unblockOnUiThread() {
148 if (DEBUG_LOGS) Log.w(TAG, "unblockOnUiThread");
149 ImeUtils.checkOnUiThread();
150 addToQueueOnUiThread(UNBLOCKER);
151 mHandler.post(new Runnable() {
152 @Override
153 public void run() {
154 checkQueue();
155 }
156 });
157 }
158
159 private void checkQueue() {
Ted C 2016/02/17 19:09:34 this is more about emptying the queue than checkin
Changwan Ryu 2016/02/18 06:03:27 Done.
160 if (DEBUG_LOGS) Log.w(TAG, "checkQueue");
161 assertOnImeThread();
162 // Handle all the remaining states in the queue.
163 while (true) {
164 TextInputState state = mQueue.peek();
Ted C 2016/02/17 19:09:34 just use poll() here and delete the line below. p
Changwan Ryu 2016/02/18 06:03:26 Done.
165 if (state == null) {
166 if (DEBUG_LOGS) Log.w(TAG, "checkQueue - finished");
167 return;
168 }
169 mQueue.poll();
170 if (DEBUG_LOGS) Log.w(TAG, "checkQueue: " + state);
171 // Unblocker was not used. Ignore.
172 if (state.shouldUnblock()) {
173 if (DEBUG_LOGS) Log.w(TAG, "checkQueue - ignoring one unblocker" );
174 continue;
175 }
176 ImeUtils.checkCondition(!state.fromIme());
177 updateSelection(state);
178 }
179 }
180
181 private void updateSelection(TextInputState textInputState) {
182 if (textInputState == null) return;
183 assertOnImeThread();
184 if (mNumNestedBatchEdits != 0) return;
185 ImeUtils.checkCondition(textInputState != null);
Ted C 2016/02/17 19:09:34 this seems unnecessary w/ regards to line 182
Changwan Ryu 2016/02/18 06:03:26 Done.
186 Range selection = textInputState.selection();
187 Range composition = textInputState.composition();
188 mImeAdapter.updateSelection(
189 selection.start(), selection.end(), composition.start(), composi tion.end());
190 }
191
192 private TextInputState requestTextInputState() {
Ted C 2016/02/17 19:09:34 I "might" suffix this with Blocking as it will wai
Changwan Ryu 2016/02/18 06:03:26 Changed name as requestAndWaitForTextInputState.
193 if (DEBUG_LOGS) Log.w(TAG, "requestTextInputState");
194 ThreadUtils.postOnUiThread(new Runnable() {
195 @Override
196 public void run() {
197 boolean result = mImeAdapter.requestTextInputStateUpdate();
198 if (!result) unblockOnUiThread();
199 }
200 });
201 return blockAndGetStateUpdate();
202 }
203
204 private void addToQueueOnUiThread(TextInputState textInputState) {
205 ImeUtils.checkOnUiThread();
206 try {
207 mQueue.put(textInputState);
208 } catch (InterruptedException e) {
209 Log.e(TAG, "addToQueueOnUiThread interrupted", e);
210 }
211 if (DEBUG_LOGS) Log.w(TAG, "addToQueueOnUiThread finished: %d", mQueue.s ize());
212 }
213
214 /**
215 * @return BlockingQueue for white box unit testing.
216 */
217 BlockingQueue<TextInputState> getQueueForTest() {
218 return mQueue;
219 }
220
221 private void assertOnImeThread() {
222 ImeUtils.checkCondition(mHandler.getLooper() == Looper.myLooper());
223 }
224
225 /**
226 * Block until we get the expected state update.
227 * @return TextInputState if we get it successfully. null otherwise.
228 */
229 private TextInputState blockAndGetStateUpdate() {
230 if (DEBUG_LOGS) Log.w(TAG, "blockAndGetStateUpdate");
231 assertOnImeThread();
232 boolean shouldUpdateSelection = false;
233 while (true) {
234 TextInputState state;
235 try {
236 state = mQueue.take();
237 } catch (InterruptedException e) {
238 e.printStackTrace();
239 return null;
Ted C 2016/02/17 19:09:34 I don't think you should quietly ignore this. If
Changwan Ryu 2016/02/18 06:03:26 Hmmm... IME thread is an artificial thread and cur
240 }
241 if (state == null) return null;
Ted C 2016/02/17 19:09:34 can this be null? "seems" like take take() doesn'
Changwan Ryu 2016/02/18 06:03:26 No, it can't. Removed the line.
242 if (state.shouldUnblock()) {
243 if (DEBUG_LOGS) Log.w(TAG, "blockAndGetStateUpdate: unblocked");
244 return null;
245 } else if (state.fromIme()) {
246 if (shouldUpdateSelection) updateSelection(state);
247 if (DEBUG_LOGS) Log.w(TAG, "blockAndGetStateUpdate done: %d", mQ ueue.size());
248 return state;
249 }
250 // Ignore when state is not from IME, but make sure to update state when we handle
251 // state from IME.
252 shouldUpdateSelection = true;
Ted C 2016/02/17 19:09:34 What happens if there are no pending events from t
Changwan Ryu 2016/02/18 06:03:27 This should never happen in theory, but when it ha
253 }
254 }
255
256 private void notifyUserAction() {
257 ThreadUtils.postOnUiThread(new Runnable() {
258 @Override
259 public void run() {
260 mImeAdapter.notifyUserAction();
261 }
262 });
263 }
264
265 /**
266 * @see InputConnection#setComposingText(java.lang.CharSequence, int)
267 */
268 @Override
269 public boolean setComposingText(final CharSequence text, final int newCursor Position) {
270 if (DEBUG_LOGS) Log.w(TAG, "setComposingText [%s] [%d]", text, newCursor Position);
271 assertOnImeThread();
272 cancelCombiningAccent();
273 ThreadUtils.postOnUiThread(new Runnable() {
274 @Override
275 public void run() {
276 mImeAdapter.sendCompositionToNative(text, newCursorPosition, fal se);
277 }
278 });
279 notifyUserAction();
280 return true;
281 }
282
283 /**
284 * @see InputConnection#commitText(java.lang.CharSequence, int)
285 */
286 @Override
287 public boolean commitText(final CharSequence text, final int newCursorPositi on) {
288 if (DEBUG_LOGS) Log.w(TAG, "commitText [%s] [%d]", text, newCursorPositi on);
289 assertOnImeThread();
290 cancelCombiningAccent();
291 ThreadUtils.postOnUiThread(new Runnable() {
292 @Override
293 public void run() {
294 mImeAdapter.sendCompositionToNative(text, newCursorPosition, tex t.length() > 0);
295 }
296 });
297 notifyUserAction();
298 return true;
299 }
300
301 /**
302 * @see InputConnection#performEditorAction(int)
303 */
304 @Override
305 public boolean performEditorAction(final int actionCode) {
306 if (DEBUG_LOGS) Log.w(TAG, "performEditorAction [%d]", actionCode);
307 assertOnImeThread();
308 ThreadUtils.postOnUiThread(new Runnable() {
309 @Override
310 public void run() {
311 mImeAdapter.performEditorAction(actionCode);
312 }
313 });
314 return true;
315 }
316
317 /**
318 * @see InputConnection#performContextMenuAction(int)
319 */
320 @Override
321 public boolean performContextMenuAction(final int id) {
322 if (DEBUG_LOGS) Log.w(TAG, "performContextMenuAction [%d]", id);
323 assertOnImeThread();
324 ThreadUtils.postOnUiThread(new Runnable() {
325 @Override
326 public void run() {
327 mImeAdapter.performContextMenuAction(id);
328 }
329 });
330 return true;
331 }
332
333 /**
334 * @see InputConnection#getExtractedText(android.view.inputmethod.ExtractedT extRequest,
335 * int)
Ted C 2016/02/17 19:09:34 this indenting looks wonky
Changwan Ryu 2016/02/18 06:03:27 Fixed
336 */
337 @Override
338 public ExtractedText getExtractedText(ExtractedTextRequest request, int flag s) {
339 if (DEBUG_LOGS) Log.w(TAG, "getExtractedText");
340 assertOnImeThread();
341 TextInputState textInputState = requestTextInputState();
342 if (textInputState == null) return null;
343 ExtractedText extractedText = new ExtractedText();
344 extractedText.text = textInputState.text();
345 extractedText.partialEndOffset = textInputState.text().length();
346 extractedText.selectionStart = textInputState.selection().start();
347 extractedText.selectionEnd = textInputState.selection().end();
348 extractedText.flags = textInputState.singleLine() ? ExtractedText.FLAG_S INGLE_LINE : 0;
349 return extractedText;
350 }
351
352 /**
353 * @see InputConnection#beginBatchEdit()
354 */
355 @Override
356 public boolean beginBatchEdit() {
357 if (DEBUG_LOGS) Log.w(TAG, "beginBatchEdit [%b]", (mNumNestedBatchEdits == 0));
358 assertOnImeThread();
359 mNumNestedBatchEdits++;
360 return true;
361 }
362
363 /**
364 * @see InputConnection#endBatchEdit()
365 */
366 @Override
367 public boolean endBatchEdit() {
368 assertOnImeThread();
369 if (mNumNestedBatchEdits == 0) return false;
370 --mNumNestedBatchEdits;
371 if (DEBUG_LOGS) Log.w(TAG, "endBatchEdit [%b]", (mNumNestedBatchEdits == 0));
372 if (mNumNestedBatchEdits == 0) {
373 updateSelection(requestTextInputState());
374 }
375 return mNumNestedBatchEdits != 0;
376 }
377
378 /**
379 * @see InputConnection#deleteSurroundingText(int, int)
380 */
381 @Override
382 public boolean deleteSurroundingText(final int beforeLength, final int after Length) {
383 if (DEBUG_LOGS) Log.w(TAG, "deleteSurroundingText [%d %d]", beforeLength , afterLength);
384 assertOnImeThread();
385 if (mPendingAccent != 0) {
386 finishComposingText();
387 }
388 ThreadUtils.postOnUiThread(new Runnable() {
389 @Override
390 public void run() {
391 mImeAdapter.deleteSurroundingText(beforeLength, afterLength);
392 }
393 });
394 return true;
395 }
396
397 /**
398 * @see InputConnection#sendKeyEvent(android.view.KeyEvent)
399 */
400 @Override
401 public boolean sendKeyEvent(final KeyEvent event) {
402 if (DEBUG_LOGS) Log.w(TAG, "sendKeyEvent [%d %d]", event.getAction(), ev ent.getKeyCode());
403 assertOnImeThread();
404
405 if (handleCombiningAccent(event)) return true;
406
407 ThreadUtils.postOnUiThread(new Runnable() {
408 @Override
409 public void run() {
410 mImeAdapter.sendKeyEvent(event);
411 }
412 });
413 notifyUserAction();
414 return true;
415 }
416
417 private boolean handleCombiningAccent(final KeyEvent event) {
418 // TODO(changwan): this will break the current composition. check if we can
419 // implement it in the renderer instead.
420 int action = event.getAction();
421 int unicodeChar = event.getUnicodeChar();
422
423 if (action != KeyEvent.ACTION_DOWN) return false;
424 if ((unicodeChar & KeyCharacterMap.COMBINING_ACCENT) != 0) {
425 int pendingAccent = unicodeChar & KeyCharacterMap.COMBINING_ACCENT_M ASK;
426 StringBuilder builder = new StringBuilder();
427 builder.appendCodePoint(pendingAccent);
428 setComposingText(builder.toString(), 1);
429 mPendingAccent = pendingAccent;
430 return true;
431 } else if (mPendingAccent != 0 && unicodeChar != 0) {
432 int combined = KeyEvent.getDeadChar(mPendingAccent, unicodeChar);
433 if (combined != 0) {
434 StringBuilder builder = new StringBuilder();
435 builder.appendCodePoint(combined);
436 commitText(builder.toString(), 1);
437 return true;
438 }
439 // Noncombinable character; commit the accent character and fall thr ough to sending
440 // the key event for the character afterwards.
441 finishComposingText();
442 }
443 return false;
444 }
445
446 private void cancelCombiningAccent() {
447 mPendingAccent = 0;
448 }
449
450 /**
451 * Call finishComposingText on UI thread.
452 */
453 public void finishComposingTextOnUiThread() {
454 if (DEBUG_LOGS) Log.w(TAG, "finishComposingTextOnUiThread");
455 ThreadUtils.postOnUiThread(new Runnable() {
456 @Override
457 public void run() {
458 finishComposingText();
459 }
460 });
461 }
462
463 /**
464 * @see InputConnection#finishComposingText()
465 */
466 @Override
467 public boolean finishComposingText() {
468 if (DEBUG_LOGS) Log.w(TAG, "finishComposingText");
469 Log.w(TAG, "called from: " + Thread.currentThread().getStackTrace()[3].g etMethodName());
Ted C 2016/02/17 19:09:34 remove?
Changwan Ryu 2016/02/18 06:03:27 Done.
470 cancelCombiningAccent();
471 // This is the only function that may be called on UI thread because
472 // of direct calls from InputMethodManager.
473 ThreadUtils.postOnUiThread(new Runnable() {
474 @Override
475 public void run() {
476 mImeAdapter.finishComposingText();
477 }
478 });
479 return true;
480 }
481
482 /**
483 * @see InputConnection#setSelection(int, int)
484 */
485 @Override
486 public boolean setSelection(final int start, final int end) {
487 if (DEBUG_LOGS) Log.w(TAG, "setSelection [%d %d]", start, end);
488 assertOnImeThread();
489 ThreadUtils.postOnUiThread(new Runnable() {
490 @Override
491 public void run() {
492 mImeAdapter.setEditableSelectionOffsets(start, end);
493 }
494 });
495 return true;
496 }
497
498 /**
499 * @see InputConnection#setComposingRegion(int, int)
500 */
501 @Override
502 public boolean setComposingRegion(final int start, final int end) {
503 if (DEBUG_LOGS) Log.w(TAG, "setComposingRegion [%d %d]", start, end);
504 assertOnImeThread();
505 ThreadUtils.postOnUiThread(new Runnable() {
506 @Override
507 public void run() {
508 mImeAdapter.setComposingRegion(start, end);
509 }
510 });
511 return true;
512 }
513
514 /**
515 * @see InputConnection#getTextBeforeCursor(int, int)
516 */
517 @Override
518 public CharSequence getTextBeforeCursor(int maxChars, int flags) {
519 if (DEBUG_LOGS) Log.w(TAG, "getTextBeforeCursor [%d %x]", maxChars, flag s);
520 assertOnImeThread();
521 TextInputState textInputState = requestTextInputState();
522 if (textInputState == null) return null;
523 return textInputState.getTextBeforeSelection(maxChars);
524 }
525
526 /**
527 * @see InputConnection#getTextAfterCursor(int, int)
528 */
529 @Override
530 public CharSequence getTextAfterCursor(int maxChars, int flags) {
531 if (DEBUG_LOGS) Log.w(TAG, "getTextAfterCursor [%d %x]", maxChars, flags );
532 assertOnImeThread();
533 TextInputState textInputState = requestTextInputState();
534 if (textInputState == null) return null;
535 return textInputState.getTextAfterSelection(maxChars);
536 }
537
538 /**
539 * @see InputConnection#getSelectedText(int)
540 */
541 @Override
542 public CharSequence getSelectedText(int flags) {
543 if (DEBUG_LOGS) Log.w(TAG, "getSelectedText [%x]", flags);
544 assertOnImeThread();
545 TextInputState textInputState = requestTextInputState();
546 if (textInputState == null) return null;
547 return textInputState.getSelectedText();
548 }
549
550 /**
551 * @see InputConnection#getCursorCapsMode(int)
552 */
553 @Override
554 public int getCursorCapsMode(int reqModes) {
555 if (DEBUG_LOGS) Log.w(TAG, "getCursorCapsMode [%x]", reqModes);
556 assertOnImeThread();
557 // TODO(changwan): Auto-generated method stub
Ted C 2016/02/17 19:09:34 remvoe these TODOs
Changwan Ryu 2016/02/18 06:03:26 Removed them and added a real TODO for getCursorCa
558 return 0;
559 }
560
561 /**
562 * @see InputConnection#commitCompletion(android.view.inputmethod.Completion Info)
563 */
564 @Override
565 public boolean commitCompletion(CompletionInfo text) {
566 if (DEBUG_LOGS) Log.w(TAG, "commitCompletion [%s]", text);
567 assertOnImeThread();
568 // TODO(changwan): Auto-generated method stub
569 return false;
570 }
571
572 /**
573 * @see InputConnection#commitCorrection(android.view.inputmethod.Correction Info)
574 */
575 @Override
576 public boolean commitCorrection(CorrectionInfo correctionInfo) {
577 if (DEBUG_LOGS) {
578 Log.w(TAG, "commitCorrection [%s]", ImeUtils.getCorrectInfoDebugStri ng(correctionInfo));
579 }
580 assertOnImeThread();
581 // TODO(changwan): Auto-generated method stub
582 return false;
583 }
584
585 /**
586 * @see InputConnection#clearMetaKeyStates(int)
587 */
588 @Override
589 public boolean clearMetaKeyStates(int states) {
590 if (DEBUG_LOGS) Log.w(TAG, "clearMetaKeyStates [%x]", states);
591 assertOnImeThread();
592 // TODO(changwan): Auto-generated method stub
593 return false;
594 }
595
596 /**
597 * @see InputConnection#reportFullscreenMode(boolean)
598 */
599 @Override
600 public boolean reportFullscreenMode(boolean enabled) {
601 if (DEBUG_LOGS) Log.w(TAG, "reportFullscreenMode [%b]", enabled);
602 // We ignore fullscreen mode for now. That's why we set
603 // EditorInfo.IME_FLAG_NO_FULLSCREEN in constructor.
604 // Note that this may be called on UI thread.
605 return false;
606 }
607
608 /**
609 * @see InputConnection#performPrivateCommand(java.lang.String, android.os.B undle)
610 */
611 @Override
612 public boolean performPrivateCommand(String action, Bundle data) {
613 if (DEBUG_LOGS) Log.w(TAG, "performPrivateCommand [%s]", action);
614 assertOnImeThread();
615 // TODO(changwan): Auto-generated method stub
616 return false;
617 }
618
619 /**
620 * @see InputConnection#requestCursorUpdates(int)
621 */
622 @Override
623 public boolean requestCursorUpdates(int cursorUpdateMode) {
624 if (DEBUG_LOGS) Log.w(TAG, "requestCursorUpdates [%x]", cursorUpdateMode );
625 assertOnImeThread();
626 // TODO(changwan): Auto-generated method stub
627 return false;
628 }
629 }
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698