1 /*
2  * Copyright (C) 2011 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.inputmethod.latin.suggestions;
18 
19 import android.content.Context;
20 import android.content.res.Resources;
21 import android.content.res.TypedArray;
22 import android.graphics.Color;
23 import android.graphics.drawable.Drawable;
24 import androidx.core.view.ViewCompat;
25 import android.text.TextUtils;
26 import android.util.AttributeSet;
27 import android.util.TypedValue;
28 import android.view.GestureDetector;
29 import android.view.LayoutInflater;
30 import android.view.MotionEvent;
31 import android.view.View;
32 import android.view.View.OnClickListener;
33 import android.view.View.OnLongClickListener;
34 import android.view.ViewGroup;
35 import android.view.ViewParent;
36 import android.view.accessibility.AccessibilityEvent;
37 import android.widget.ImageButton;
38 import android.widget.RelativeLayout;
39 import android.widget.TextView;
40 
41 import com.android.inputmethod.accessibility.AccessibilityUtils;
42 import com.android.inputmethod.keyboard.Keyboard;
43 import com.android.inputmethod.keyboard.MainKeyboardView;
44 import com.android.inputmethod.keyboard.MoreKeysPanel;
45 import com.android.inputmethod.latin.AudioAndHapticFeedbackManager;
46 import com.android.inputmethod.latin.R;
47 import com.android.inputmethod.latin.SuggestedWords;
48 import com.android.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
49 import com.android.inputmethod.latin.common.Constants;
50 import com.android.inputmethod.latin.define.DebugFlags;
51 import com.android.inputmethod.latin.settings.Settings;
52 import com.android.inputmethod.latin.settings.SettingsValues;
53 import com.android.inputmethod.latin.suggestions.MoreSuggestionsView.MoreSuggestionsListener;
54 import com.android.inputmethod.latin.utils.ImportantNoticeUtils;
55 
56 import java.util.ArrayList;
57 
58 public final class SuggestionStripView extends RelativeLayout implements OnClickListener,
59         OnLongClickListener {
60     public interface Listener {
showImportantNoticeContents()61         public void showImportantNoticeContents();
pickSuggestionManually(SuggestedWordInfo word)62         public void pickSuggestionManually(SuggestedWordInfo word);
onCodeInput(int primaryCode, int x, int y, boolean isKeyRepeat)63         public void onCodeInput(int primaryCode, int x, int y, boolean isKeyRepeat);
64     }
65 
66     static final boolean DBG = DebugFlags.DEBUG_ENABLED;
67     private static final float DEBUG_INFO_TEXT_SIZE_IN_DIP = 6.0f;
68 
69     private final ViewGroup mSuggestionsStrip;
70     private final ImageButton mVoiceKey;
71     private final View mImportantNoticeStrip;
72     MainKeyboardView mMainKeyboardView;
73 
74     private final View mMoreSuggestionsContainer;
75     private final MoreSuggestionsView mMoreSuggestionsView;
76     private final MoreSuggestions.Builder mMoreSuggestionsBuilder;
77 
78     private final ArrayList<TextView> mWordViews = new ArrayList<>();
79     private final ArrayList<TextView> mDebugInfoViews = new ArrayList<>();
80     private final ArrayList<View> mDividerViews = new ArrayList<>();
81 
82     Listener mListener;
83     private SuggestedWords mSuggestedWords = SuggestedWords.getEmptyInstance();
84     private int mStartIndexOfMoreSuggestions;
85 
86     private final SuggestionStripLayoutHelper mLayoutHelper;
87     private final StripVisibilityGroup mStripVisibilityGroup;
88 
89     private static class StripVisibilityGroup {
90         private final View mSuggestionStripView;
91         private final View mSuggestionsStrip;
92         private final View mImportantNoticeStrip;
93 
StripVisibilityGroup(final View suggestionStripView, final ViewGroup suggestionsStrip, final View importantNoticeStrip)94         public StripVisibilityGroup(final View suggestionStripView,
95                 final ViewGroup suggestionsStrip, final View importantNoticeStrip) {
96             mSuggestionStripView = suggestionStripView;
97             mSuggestionsStrip = suggestionsStrip;
98             mImportantNoticeStrip = importantNoticeStrip;
99             showSuggestionsStrip();
100         }
101 
setLayoutDirection(final boolean isRtlLanguage)102         public void setLayoutDirection(final boolean isRtlLanguage) {
103             final int layoutDirection = isRtlLanguage ? ViewCompat.LAYOUT_DIRECTION_RTL
104                     : ViewCompat.LAYOUT_DIRECTION_LTR;
105             ViewCompat.setLayoutDirection(mSuggestionStripView, layoutDirection);
106             ViewCompat.setLayoutDirection(mSuggestionsStrip, layoutDirection);
107             ViewCompat.setLayoutDirection(mImportantNoticeStrip, layoutDirection);
108         }
109 
showSuggestionsStrip()110         public void showSuggestionsStrip() {
111             mSuggestionsStrip.setVisibility(VISIBLE);
112             mImportantNoticeStrip.setVisibility(INVISIBLE);
113         }
114 
showImportantNoticeStrip()115         public void showImportantNoticeStrip() {
116             mSuggestionsStrip.setVisibility(INVISIBLE);
117             mImportantNoticeStrip.setVisibility(VISIBLE);
118         }
119 
isShowingImportantNoticeStrip()120         public boolean isShowingImportantNoticeStrip() {
121             return mImportantNoticeStrip.getVisibility() == VISIBLE;
122         }
123     }
124 
125     /**
126      * Construct a {@link SuggestionStripView} for showing suggestions to be picked by the user.
127      * @param context
128      * @param attrs
129      */
SuggestionStripView(final Context context, final AttributeSet attrs)130     public SuggestionStripView(final Context context, final AttributeSet attrs) {
131         this(context, attrs, R.attr.suggestionStripViewStyle);
132     }
133 
SuggestionStripView(final Context context, final AttributeSet attrs, final int defStyle)134     public SuggestionStripView(final Context context, final AttributeSet attrs,
135             final int defStyle) {
136         super(context, attrs, defStyle);
137 
138         final LayoutInflater inflater = LayoutInflater.from(context);
139         inflater.inflate(R.layout.suggestions_strip, this);
140 
141         mSuggestionsStrip = (ViewGroup)findViewById(R.id.suggestions_strip);
142         mVoiceKey = (ImageButton)findViewById(R.id.suggestions_strip_voice_key);
143         mImportantNoticeStrip = findViewById(R.id.important_notice_strip);
144         mStripVisibilityGroup = new StripVisibilityGroup(this, mSuggestionsStrip,
145                 mImportantNoticeStrip);
146 
147         for (int pos = 0; pos < SuggestedWords.MAX_SUGGESTIONS; pos++) {
148             final TextView word = new TextView(context, null, R.attr.suggestionWordStyle);
149             word.setContentDescription(getResources().getString(R.string.spoken_empty_suggestion));
150             word.setOnClickListener(this);
151             word.setOnLongClickListener(this);
152             mWordViews.add(word);
153             final View divider = inflater.inflate(R.layout.suggestion_divider, null);
154             mDividerViews.add(divider);
155             final TextView info = new TextView(context, null, R.attr.suggestionWordStyle);
156             info.setTextColor(Color.WHITE);
157             info.setTextSize(TypedValue.COMPLEX_UNIT_DIP, DEBUG_INFO_TEXT_SIZE_IN_DIP);
158             mDebugInfoViews.add(info);
159         }
160 
161         mLayoutHelper = new SuggestionStripLayoutHelper(
162                 context, attrs, defStyle, mWordViews, mDividerViews, mDebugInfoViews);
163 
164         mMoreSuggestionsContainer = inflater.inflate(R.layout.more_suggestions, null);
165         mMoreSuggestionsView = (MoreSuggestionsView)mMoreSuggestionsContainer
166                 .findViewById(R.id.more_suggestions_view);
167         mMoreSuggestionsBuilder = new MoreSuggestions.Builder(context, mMoreSuggestionsView);
168 
169         final Resources res = context.getResources();
170         mMoreSuggestionsModalTolerance = res.getDimensionPixelOffset(
171                 R.dimen.config_more_suggestions_modal_tolerance);
172         mMoreSuggestionsSlidingDetector = new GestureDetector(
173                 context, mMoreSuggestionsSlidingListener);
174 
175         final TypedArray keyboardAttr = context.obtainStyledAttributes(attrs,
176                 R.styleable.Keyboard, defStyle, R.style.SuggestionStripView);
177         final Drawable iconVoice = keyboardAttr.getDrawable(R.styleable.Keyboard_iconShortcutKey);
178         keyboardAttr.recycle();
179         mVoiceKey.setImageDrawable(iconVoice);
180         mVoiceKey.setOnClickListener(this);
181     }
182 
183     /**
184      * A connection back to the input method.
185      * @param listener
186      */
setListener(final Listener listener, final View inputView)187     public void setListener(final Listener listener, final View inputView) {
188         mListener = listener;
189         mMainKeyboardView = (MainKeyboardView)inputView.findViewById(R.id.keyboard_view);
190     }
191 
updateVisibility(final boolean shouldBeVisible, final boolean isFullscreenMode)192     public void updateVisibility(final boolean shouldBeVisible, final boolean isFullscreenMode) {
193         final int visibility = shouldBeVisible ? VISIBLE : (isFullscreenMode ? GONE : INVISIBLE);
194         setVisibility(visibility);
195         final SettingsValues currentSettingsValues = Settings.getInstance().getCurrent();
196         mVoiceKey.setVisibility(currentSettingsValues.mShowsVoiceInputKey ? VISIBLE : INVISIBLE);
197     }
198 
setSuggestions(final SuggestedWords suggestedWords, final boolean isRtlLanguage)199     public void setSuggestions(final SuggestedWords suggestedWords, final boolean isRtlLanguage) {
200         clear();
201         mStripVisibilityGroup.setLayoutDirection(isRtlLanguage);
202         mSuggestedWords = suggestedWords;
203         mStartIndexOfMoreSuggestions = mLayoutHelper.layoutAndReturnStartIndexOfMoreSuggestions(
204                 getContext(), mSuggestedWords, mSuggestionsStrip, this);
205         mStripVisibilityGroup.showSuggestionsStrip();
206     }
207 
setMoreSuggestionsHeight(final int remainingHeight)208     public void setMoreSuggestionsHeight(final int remainingHeight) {
209         mLayoutHelper.setMoreSuggestionsHeight(remainingHeight);
210     }
211 
212     // This method checks if we should show the important notice (checks on permanent storage if
213     // it has been shown once already or not, and if in the setup wizard). If applicable, it shows
214     // the notice. In all cases, it returns true if it was shown, false otherwise.
maybeShowImportantNoticeTitle()215     public boolean maybeShowImportantNoticeTitle() {
216         final SettingsValues currentSettingsValues = Settings.getInstance().getCurrent();
217         if (!ImportantNoticeUtils.shouldShowImportantNotice(getContext(), currentSettingsValues)) {
218             return false;
219         }
220         if (getWidth() <= 0) {
221             return false;
222         }
223         final String importantNoticeTitle = ImportantNoticeUtils.getSuggestContactsNoticeTitle(
224                 getContext());
225         if (TextUtils.isEmpty(importantNoticeTitle)) {
226             return false;
227         }
228         if (isShowingMoreSuggestionPanel()) {
229             dismissMoreSuggestionsPanel();
230         }
231         mLayoutHelper.layoutImportantNotice(mImportantNoticeStrip, importantNoticeTitle);
232         mStripVisibilityGroup.showImportantNoticeStrip();
233         mImportantNoticeStrip.setOnClickListener(this);
234         return true;
235     }
236 
clear()237     public void clear() {
238         mSuggestionsStrip.removeAllViews();
239         removeAllDebugInfoViews();
240         mStripVisibilityGroup.showSuggestionsStrip();
241         dismissMoreSuggestionsPanel();
242     }
243 
removeAllDebugInfoViews()244     private void removeAllDebugInfoViews() {
245         // The debug info views may be placed as children views of this {@link SuggestionStripView}.
246         for (final View debugInfoView : mDebugInfoViews) {
247             final ViewParent parent = debugInfoView.getParent();
248             if (parent instanceof ViewGroup) {
249                 ((ViewGroup)parent).removeView(debugInfoView);
250             }
251         }
252     }
253 
254     private final MoreSuggestionsListener mMoreSuggestionsListener = new MoreSuggestionsListener() {
255         @Override
256         public void onSuggestionSelected(final SuggestedWordInfo wordInfo) {
257             mListener.pickSuggestionManually(wordInfo);
258             dismissMoreSuggestionsPanel();
259         }
260 
261         @Override
262         public void onCancelInput() {
263             dismissMoreSuggestionsPanel();
264         }
265     };
266 
267     private final MoreKeysPanel.Controller mMoreSuggestionsController =
268             new MoreKeysPanel.Controller() {
269         @Override
270         public void onDismissMoreKeysPanel() {
271             mMainKeyboardView.onDismissMoreKeysPanel();
272         }
273 
274         @Override
275         public void onShowMoreKeysPanel(final MoreKeysPanel panel) {
276             mMainKeyboardView.onShowMoreKeysPanel(panel);
277         }
278 
279         @Override
280         public void onCancelMoreKeysPanel() {
281             dismissMoreSuggestionsPanel();
282         }
283     };
284 
isShowingMoreSuggestionPanel()285     public boolean isShowingMoreSuggestionPanel() {
286         return mMoreSuggestionsView.isShowingInParent();
287     }
288 
dismissMoreSuggestionsPanel()289     public void dismissMoreSuggestionsPanel() {
290         mMoreSuggestionsView.dismissMoreKeysPanel();
291     }
292 
293     @Override
onLongClick(final View view)294     public boolean onLongClick(final View view) {
295         AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback(
296                 Constants.NOT_A_CODE, this);
297         return showMoreSuggestions();
298     }
299 
showMoreSuggestions()300     boolean showMoreSuggestions() {
301         final Keyboard parentKeyboard = mMainKeyboardView.getKeyboard();
302         if (parentKeyboard == null) {
303             return false;
304         }
305         final SuggestionStripLayoutHelper layoutHelper = mLayoutHelper;
306         if (mSuggestedWords.size() <= mStartIndexOfMoreSuggestions) {
307             return false;
308         }
309         final int stripWidth = getWidth();
310         final View container = mMoreSuggestionsContainer;
311         final int maxWidth = stripWidth - container.getPaddingLeft() - container.getPaddingRight();
312         final MoreSuggestions.Builder builder = mMoreSuggestionsBuilder;
313         builder.layout(mSuggestedWords, mStartIndexOfMoreSuggestions, maxWidth,
314                 (int)(maxWidth * layoutHelper.mMinMoreSuggestionsWidth),
315                 layoutHelper.getMaxMoreSuggestionsRow(), parentKeyboard);
316         mMoreSuggestionsView.setKeyboard(builder.build());
317         container.measure(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
318 
319         final MoreKeysPanel moreKeysPanel = mMoreSuggestionsView;
320         final int pointX = stripWidth / 2;
321         final int pointY = -layoutHelper.mMoreSuggestionsBottomGap;
322         moreKeysPanel.showMoreKeysPanel(this, mMoreSuggestionsController, pointX, pointY,
323                 mMoreSuggestionsListener);
324         mOriginX = mLastX;
325         mOriginY = mLastY;
326         for (int i = 0; i < mStartIndexOfMoreSuggestions; i++) {
327             mWordViews.get(i).setPressed(false);
328         }
329         return true;
330     }
331 
332     // Working variables for {@link onInterceptTouchEvent(MotionEvent)} and
333     // {@link onTouchEvent(MotionEvent)}.
334     private int mLastX;
335     private int mLastY;
336     private int mOriginX;
337     private int mOriginY;
338     private final int mMoreSuggestionsModalTolerance;
339     private boolean mNeedsToTransformTouchEventToHoverEvent;
340     private boolean mIsDispatchingHoverEventToMoreSuggestions;
341     private final GestureDetector mMoreSuggestionsSlidingDetector;
342     private final GestureDetector.OnGestureListener mMoreSuggestionsSlidingListener =
343             new GestureDetector.SimpleOnGestureListener() {
344         @Override
345         public boolean onScroll(MotionEvent down, MotionEvent me, float deltaX, float deltaY) {
346             final float dy = me.getY() - down.getY();
347             if (deltaY > 0 && dy < 0) {
348                 return showMoreSuggestions();
349             }
350             return false;
351         }
352     };
353 
354     @Override
onInterceptTouchEvent(final MotionEvent me)355     public boolean onInterceptTouchEvent(final MotionEvent me) {
356         if (mStripVisibilityGroup.isShowingImportantNoticeStrip()) {
357             return false;
358         }
359         // Detecting sliding up finger to show {@link MoreSuggestionsView}.
360         if (!mMoreSuggestionsView.isShowingInParent()) {
361             mLastX = (int)me.getX();
362             mLastY = (int)me.getY();
363             return mMoreSuggestionsSlidingDetector.onTouchEvent(me);
364         }
365         if (mMoreSuggestionsView.isInModalMode()) {
366             return false;
367         }
368 
369         final int action = me.getAction();
370         final int index = me.getActionIndex();
371         final int x = (int)me.getX(index);
372         final int y = (int)me.getY(index);
373         if (Math.abs(x - mOriginX) >= mMoreSuggestionsModalTolerance
374                 || mOriginY - y >= mMoreSuggestionsModalTolerance) {
375             // Decided to be in the sliding suggestion mode only when the touch point has been moved
376             // upward. Further {@link MotionEvent}s will be delivered to
377             // {@link #onTouchEvent(MotionEvent)}.
378             mNeedsToTransformTouchEventToHoverEvent =
379                     AccessibilityUtils.getInstance().isTouchExplorationEnabled();
380             mIsDispatchingHoverEventToMoreSuggestions = false;
381             return true;
382         }
383 
384         if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) {
385             // Decided to be in the modal input mode.
386             mMoreSuggestionsView.setModalMode();
387         }
388         return false;
389     }
390 
391     @Override
dispatchPopulateAccessibilityEvent(final AccessibilityEvent event)392     public boolean dispatchPopulateAccessibilityEvent(final AccessibilityEvent event) {
393         // Don't populate accessibility event with suggested words and voice key.
394         return true;
395     }
396 
397     @Override
onTouchEvent(final MotionEvent me)398     public boolean onTouchEvent(final MotionEvent me) {
399         if (!mMoreSuggestionsView.isShowingInParent()) {
400             // Ignore any touch event while more suggestions panel hasn't been shown.
401             // Detecting sliding up is done at {@link #onInterceptTouchEvent}.
402             return true;
403         }
404         // In the sliding input mode. {@link MotionEvent} should be forwarded to
405         // {@link MoreSuggestionsView}.
406         final int index = me.getActionIndex();
407         final int x = mMoreSuggestionsView.translateX((int)me.getX(index));
408         final int y = mMoreSuggestionsView.translateY((int)me.getY(index));
409         me.setLocation(x, y);
410         if (!mNeedsToTransformTouchEventToHoverEvent) {
411             mMoreSuggestionsView.onTouchEvent(me);
412             return true;
413         }
414         // In sliding suggestion mode with accessibility mode on, a touch event should be
415         // transformed to a hover event.
416         final int width = mMoreSuggestionsView.getWidth();
417         final int height = mMoreSuggestionsView.getHeight();
418         final boolean onMoreSuggestions = (x >= 0 && x < width && y >= 0 && y < height);
419         if (!onMoreSuggestions && !mIsDispatchingHoverEventToMoreSuggestions) {
420             // Just drop this touch event because dispatching hover event isn't started yet and
421             // the touch event isn't on {@link MoreSuggestionsView}.
422             return true;
423         }
424         final int hoverAction;
425         if (onMoreSuggestions && !mIsDispatchingHoverEventToMoreSuggestions) {
426             // Transform this touch event to a hover enter event and start dispatching a hover
427             // event to {@link MoreSuggestionsView}.
428             mIsDispatchingHoverEventToMoreSuggestions = true;
429             hoverAction = MotionEvent.ACTION_HOVER_ENTER;
430         } else if (me.getActionMasked() == MotionEvent.ACTION_UP) {
431             // Transform this touch event to a hover exit event and stop dispatching a hover event
432             // after this.
433             mIsDispatchingHoverEventToMoreSuggestions = false;
434             mNeedsToTransformTouchEventToHoverEvent = false;
435             hoverAction = MotionEvent.ACTION_HOVER_EXIT;
436         } else {
437             // Transform this touch event to a hover move event.
438             hoverAction = MotionEvent.ACTION_HOVER_MOVE;
439         }
440         me.setAction(hoverAction);
441         mMoreSuggestionsView.onHoverEvent(me);
442         return true;
443     }
444 
445     @Override
onClick(final View view)446     public void onClick(final View view) {
447         AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback(
448                 Constants.CODE_UNSPECIFIED, this);
449         if (view == mImportantNoticeStrip) {
450             mListener.showImportantNoticeContents();
451             return;
452         }
453         if (view == mVoiceKey) {
454             mListener.onCodeInput(Constants.CODE_SHORTCUT,
455                     Constants.SUGGESTION_STRIP_COORDINATE, Constants.SUGGESTION_STRIP_COORDINATE,
456                     false /* isKeyRepeat */);
457             return;
458         }
459 
460         final Object tag = view.getTag();
461         // {@link Integer} tag is set at
462         // {@link SuggestionStripLayoutHelper#setupWordViewsTextAndColor(SuggestedWords,int)} and
463         // {@link SuggestionStripLayoutHelper#layoutPunctuationSuggestions(SuggestedWords,ViewGroup}
464         if (tag instanceof Integer) {
465             final int index = (Integer) tag;
466             if (index >= mSuggestedWords.size()) {
467                 return;
468             }
469             final SuggestedWordInfo wordInfo = mSuggestedWords.getInfo(index);
470             mListener.pickSuggestionManually(wordInfo);
471         }
472     }
473 
474     @Override
onDetachedFromWindow()475     protected void onDetachedFromWindow() {
476         super.onDetachedFromWindow();
477         dismissMoreSuggestionsPanel();
478     }
479 
480     @Override
onSizeChanged(final int w, final int h, final int oldw, final int oldh)481     protected void onSizeChanged(final int w, final int h, final int oldw, final int oldh) {
482         // Called by the framework when the size is known. Show the important notice if applicable.
483         // This may be overriden by showing suggestions later, if applicable.
484         if (oldw <= 0 && w > 0) {
485             maybeShowImportantNoticeTitle();
486         }
487     }
488 }
489