summaryrefslogtreecommitdiffstats
path: root/mobile/android/base/java/org/mozilla/gecko/FormAssistPopup.java
blob: 5c7f932c008858f512786803c21c15d20cd8251d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko;

import org.mozilla.gecko.animation.ViewHelper;
import org.mozilla.gecko.gfx.FloatSize;
import org.mozilla.gecko.gfx.ImmutableViewportMetrics;
import org.mozilla.gecko.util.GeckoEventListener;
import org.mozilla.gecko.util.ThreadUtils;
import org.mozilla.gecko.widget.SwipeDismissListViewTouchListener;
import org.mozilla.gecko.widget.SwipeDismissListViewTouchListener.OnDismissCallback;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.PointF;
import android.util.AttributeSet;
import android.util.Log;
import android.util.Pair;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.view.inputmethod.InputMethodManager;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.RelativeLayout;
import android.widget.RelativeLayout.LayoutParams;
import android.widget.TextView;

import java.util.Arrays;
import java.util.Collection;

public class FormAssistPopup extends RelativeLayout implements GeckoEventListener {
    private final Context mContext;
    private final Animation mAnimation;

    private ListView mAutoCompleteList;
    private RelativeLayout mValidationMessage;
    private TextView mValidationMessageText;
    private ImageView mValidationMessageArrow;
    private ImageView mValidationMessageArrowInverted;

    private double mX;
    private double mY;
    private double mW;
    private double mH;

    private enum PopupType {
        AUTOCOMPLETE,
        VALIDATIONMESSAGE;
    }
    private PopupType mPopupType;

    private static final int MAX_VISIBLE_ROWS = 5;

    private static int sAutoCompleteMinWidth;
    private static int sAutoCompleteRowHeight;
    private static int sValidationMessageHeight;
    private static int sValidationTextMarginTop;
    private static LayoutParams sValidationTextLayoutNormal;
    private static LayoutParams sValidationTextLayoutInverted;

    private static final String LOGTAG = "GeckoFormAssistPopup";

    // The blocklist is so short that ArrayList is probably cheaper than HashSet.
    private static final Collection<String> sInputMethodBlocklist = Arrays.asList(
                                            InputMethods.METHOD_GOOGLE_JAPANESE_INPUT, // bug 775850
                                            InputMethods.METHOD_OPENWNN_PLUS,          // bug 768108
                                            InputMethods.METHOD_SIMEJI,                // bug 768108
                                            InputMethods.METHOD_SWYPE,                 // bug 755909
                                            InputMethods.METHOD_SWYPE_BETA            // bug 755909
                                            );

    public FormAssistPopup(Context context, AttributeSet attrs) {
        super(context, attrs);
        mContext = context;

        mAnimation = AnimationUtils.loadAnimation(context, R.anim.grow_fade_in);
        mAnimation.setDuration(75);

        setFocusable(false);

        GeckoApp.getEventDispatcher().registerGeckoThreadListener(this,
            "FormAssist:AutoComplete",
            "FormAssist:ValidationMessage",
            "FormAssist:Hide");
    }

    void destroy() {
        GeckoApp.getEventDispatcher().unregisterGeckoThreadListener(this,
            "FormAssist:AutoComplete",
            "FormAssist:ValidationMessage",
            "FormAssist:Hide");
    }

    @Override
    public void handleMessage(String event, JSONObject message) {
        try {
            if (event.equals("FormAssist:AutoComplete")) {
                handleAutoCompleteMessage(message);
            } else if (event.equals("FormAssist:ValidationMessage")) {
                handleValidationMessage(message);
            } else if (event.equals("FormAssist:Hide")) {
                handleHideMessage(message);
            }
        } catch (Exception e) {
            Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e);
        }
    }

    private void handleAutoCompleteMessage(JSONObject message) throws JSONException  {
        final JSONArray suggestions = message.getJSONArray("suggestions");
        final JSONObject rect = message.getJSONObject("rect");
        final boolean isEmpty = message.getBoolean("isEmpty");
        ThreadUtils.postToUiThread(new Runnable() {
            @Override
            public void run() {
                showAutoCompleteSuggestions(suggestions, rect, isEmpty);
            }
        });
    }

    private void handleValidationMessage(JSONObject message) throws JSONException {
        final String validationMessage = message.getString("validationMessage");
        final JSONObject rect = message.getJSONObject("rect");
        ThreadUtils.postToUiThread(new Runnable() {
            @Override
            public void run() {
                showValidationMessage(validationMessage, rect);
            }
        });
    }

    private void handleHideMessage(JSONObject message) {
        ThreadUtils.postToUiThread(new Runnable() {
            @Override
            public void run() {
                hide();
            }
        });
    }

    private void showAutoCompleteSuggestions(JSONArray suggestions, JSONObject rect, boolean isEmpty) {
        final String inputMethod = InputMethods.getCurrentInputMethod(mContext);
        if (!isEmpty && sInputMethodBlocklist.contains(inputMethod)) {
            // Don't display the form auto-complete popup after the user starts typing
            // to avoid confusing somes IME. See bug 758820 and bug 632744.
            hide();
            return;
        }

        if (mAutoCompleteList == null) {
            LayoutInflater inflater = LayoutInflater.from(mContext);
            mAutoCompleteList = (ListView) inflater.inflate(R.layout.autocomplete_list, null);

            mAutoCompleteList.setOnItemClickListener(new OnItemClickListener() {
                @Override
                public void onItemClick(AdapterView<?> parentView, View view, int position, long id) {
                    // Use the value stored with the autocomplete view, not the label text,
                    // since they can be different.
                    TextView textView = (TextView) view;
                    String value = (String) textView.getTag();
                    broadcastGeckoEvent("FormAssist:AutoComplete", value);
                    hide();
                }
            });

            // Create a ListView-specific touch listener. ListViews are given special treatment because
            // by default they handle touches for their list items... i.e. they're in charge of drawing
            // the pressed state (the list selector), handling list item clicks, etc.
            final SwipeDismissListViewTouchListener touchListener = new SwipeDismissListViewTouchListener(mAutoCompleteList, new OnDismissCallback() {
                @Override
                public void onDismiss(ListView listView, final int position) {
                    // Use the value stored with the autocomplete view, not the label text,
                    // since they can be different.
                    AutoCompleteListAdapter adapter = (AutoCompleteListAdapter) listView.getAdapter();
                    Pair<String, String> item = adapter.getItem(position);

                    // Remove the item from form history.
                    broadcastGeckoEvent("FormAssist:Remove", item.second);

                    // Update the list
                    adapter.remove(item);
                    adapter.notifyDataSetChanged();
                    positionAndShowPopup();
                }
            });
            mAutoCompleteList.setOnTouchListener(touchListener);

            // Setting this scroll listener is required to ensure that during ListView scrolling,
            // we don't look for swipes.
            mAutoCompleteList.setOnScrollListener(touchListener.makeScrollListener());

            // Setting this recycler listener is required to make sure animated views are reset.
            mAutoCompleteList.setRecyclerListener(touchListener.makeRecyclerListener());

            addView(mAutoCompleteList);
        }

        AutoCompleteListAdapter adapter = new AutoCompleteListAdapter(mContext, R.layout.autocomplete_list_item);
        adapter.populateSuggestionsList(suggestions);
        mAutoCompleteList.setAdapter(adapter);

        if (setGeckoPositionData(rect, true)) {
            positionAndShowPopup();
        }
    }

    private void showValidationMessage(String validationMessage, JSONObject rect) {
        if (mValidationMessage == null) {
            LayoutInflater inflater = LayoutInflater.from(mContext);
            mValidationMessage = (RelativeLayout) inflater.inflate(R.layout.validation_message, null);

            addView(mValidationMessage);
            mValidationMessageText = (TextView) mValidationMessage.findViewById(R.id.validation_message_text);

            sValidationTextMarginTop = (int) (mContext.getResources().getDimension(R.dimen.validation_message_margin_top));

            sValidationTextLayoutNormal = new LayoutParams(mValidationMessageText.getLayoutParams());
            sValidationTextLayoutNormal.setMargins(0, sValidationTextMarginTop, 0, 0);

            sValidationTextLayoutInverted = new LayoutParams((ViewGroup.MarginLayoutParams) sValidationTextLayoutNormal);
            sValidationTextLayoutInverted.setMargins(0, 0, 0, 0);

            mValidationMessageArrow = (ImageView) mValidationMessage.findViewById(R.id.validation_message_arrow);
            mValidationMessageArrowInverted = (ImageView) mValidationMessage.findViewById(R.id.validation_message_arrow_inverted);
        }

        mValidationMessageText.setText(validationMessage);

        // We need to set the text as selected for the marquee text to work.
        mValidationMessageText.setSelected(true);

        if (setGeckoPositionData(rect, false)) {
            positionAndShowPopup();
        }
    }

    private boolean setGeckoPositionData(JSONObject rect, boolean isAutoComplete) {
        try {
            mX = rect.getDouble("x");
            mY = rect.getDouble("y");
            mW = rect.getDouble("w");
            mH = rect.getDouble("h");
        } catch (JSONException e) {
            // Bail if we can't get the correct dimensions for the popup.
            Log.e(LOGTAG, "Error getting FormAssistPopup dimensions", e);
            return false;
        }

        mPopupType = (isAutoComplete ?
                      PopupType.AUTOCOMPLETE : PopupType.VALIDATIONMESSAGE);
        return true;
    }

    private void positionAndShowPopup() {
        positionAndShowPopup(GeckoAppShell.getLayerView().getViewportMetrics());
    }

    private void positionAndShowPopup(ImmutableViewportMetrics aMetrics) {
        ThreadUtils.assertOnUiThread();

        // Don't show the form assist popup when using fullscreen VKB
        InputMethodManager imm =
                (InputMethodManager) mContext.getSystemService(Context.INPUT_METHOD_SERVICE);
        if (imm.isFullscreenMode()) {
            return;
        }

        // Hide/show the appropriate popup contents
        if (mAutoCompleteList != null) {
            mAutoCompleteList.setVisibility((mPopupType == PopupType.AUTOCOMPLETE) ? VISIBLE : GONE);
        }
        if (mValidationMessage != null) {
            mValidationMessage.setVisibility((mPopupType == PopupType.AUTOCOMPLETE) ? GONE : VISIBLE);
        }

        if (sAutoCompleteMinWidth == 0) {
            Resources res = mContext.getResources();
            sAutoCompleteMinWidth = (int) (res.getDimension(R.dimen.autocomplete_min_width));
            sAutoCompleteRowHeight = (int) (res.getDimension(R.dimen.autocomplete_row_height));
            sValidationMessageHeight = (int) (res.getDimension(R.dimen.validation_message_height));
        }

        float zoom = aMetrics.zoomFactor;

        // These values correspond to the input box for which we want to
        // display the FormAssistPopup.
        int left = (int) (mX * zoom - aMetrics.viewportRectLeft);
        int top = (int) (mY * zoom - aMetrics.viewportRectTop + GeckoAppShell.getLayerView().getSurfaceTranslation());
        int width = (int) (mW * zoom);
        int height = (int) (mH * zoom);

        int popupWidth = LayoutParams.MATCH_PARENT;
        int popupLeft = left < 0 ? 0 : left;

        FloatSize viewport = aMetrics.getSize();

        // For autocomplete suggestions, if the input is smaller than the screen-width,
        // shrink the popup's width. Otherwise, keep it as MATCH_PARENT.
        if ((mPopupType == PopupType.AUTOCOMPLETE) && (left + width) < viewport.width) {
            popupWidth = left < 0 ? left + width : width;

            // Ensure the popup has a minimum width.
            if (popupWidth < sAutoCompleteMinWidth) {
                popupWidth = sAutoCompleteMinWidth;

                // Move the popup to the left if there isn't enough room for it.
                if ((popupLeft + popupWidth) > viewport.width) {
                    popupLeft = (int) (viewport.width - popupWidth);
                }
            }
        }

        int popupHeight;
        if (mPopupType == PopupType.AUTOCOMPLETE) {
            // Limit the amount of visible rows.
            int rows = mAutoCompleteList.getAdapter().getCount();
            if (rows > MAX_VISIBLE_ROWS) {
                rows = MAX_VISIBLE_ROWS;
            }

            popupHeight = sAutoCompleteRowHeight * rows;
        } else {
            popupHeight = sValidationMessageHeight;
        }

        int popupTop = top + height;

        if (mPopupType == PopupType.VALIDATIONMESSAGE) {
            mValidationMessageText.setLayoutParams(sValidationTextLayoutNormal);
            mValidationMessageArrow.setVisibility(VISIBLE);
            mValidationMessageArrowInverted.setVisibility(GONE);
        }

        // If the popup doesn't fit below the input box, shrink its height, or
        // see if we can place it above the input instead.
        if ((popupTop + popupHeight) > viewport.height) {
            // Find where the maximum space is, and put the popup there.
            if ((viewport.height - popupTop) > top) {
                // Shrink the height to fit it below the input box.
                popupHeight = (int) (viewport.height - popupTop);
            } else {
                if (popupHeight < top) {
                    // No shrinking needed to fit on top.
                    popupTop = (top - popupHeight);
                } else {
                    // Shrink to available space on top.
                    popupTop = 0;
                    popupHeight = top;
                }

                if (mPopupType == PopupType.VALIDATIONMESSAGE) {
                    mValidationMessageText.setLayoutParams(sValidationTextLayoutInverted);
                    mValidationMessageArrow.setVisibility(GONE);
                    mValidationMessageArrowInverted.setVisibility(VISIBLE);
                }
           }
        }

        LayoutParams layoutParams = new LayoutParams(popupWidth, popupHeight);
        layoutParams.setMargins(popupLeft, popupTop, 0, 0);
        setLayoutParams(layoutParams);
        requestLayout();

        if (!isShown()) {
            setVisibility(VISIBLE);
            startAnimation(mAnimation);
        }
    }

    public void hide() {
        if (isShown()) {
            setVisibility(GONE);
            broadcastGeckoEvent("FormAssist:Hidden", null);
        }
    }

    void onTranslationChanged() {
        ThreadUtils.assertOnUiThread();
        if (!isShown()) {
            return;
        }
        positionAndShowPopup();
    }

    void onMetricsChanged(final ImmutableViewportMetrics aMetrics) {
        if (!isShown()) {
            return;
        }

        ThreadUtils.postToUiThread(new Runnable() {
            @Override
            public void run() {
                positionAndShowPopup(aMetrics);
            }
        });
    }

    private static void broadcastGeckoEvent(String eventName, String eventData) {
        GeckoAppShell.notifyObservers(eventName, eventData);
    }

    private class AutoCompleteListAdapter extends ArrayAdapter<Pair<String, String>> {
        private final LayoutInflater mInflater;
        private final int mTextViewResourceId;

        public AutoCompleteListAdapter(Context context, int textViewResourceId) {
            super(context, textViewResourceId);

            mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
            mTextViewResourceId = textViewResourceId;
        }

        // This method takes an array of autocomplete suggestions with label/value properties
        // and adds label/value Pair objects to the array that backs the adapter.
        public void populateSuggestionsList(JSONArray suggestions) {
            try {
                for (int i = 0; i < suggestions.length(); i++) {
                    JSONObject suggestion = suggestions.getJSONObject(i);
                    String label = suggestion.getString("label");
                    String value = suggestion.getString("value");
                    add(new Pair<String, String>(label, value));
                }
            } catch (JSONException e) {
                Log.e(LOGTAG, "JSONException", e);
            }
        }

        @Override
        public View getView(int position, View convertView, ViewGroup parent) {
            if (convertView == null) {
                convertView = mInflater.inflate(mTextViewResourceId, null);
            }

            Pair<String, String> item = getItem(position);
            TextView itemView = (TextView) convertView;

            // Set the text with the suggestion label
            itemView.setText(item.first);

            // Set a tag with the suggestion value
            itemView.setTag(item.second);

            return convertView;
        }
    }
}