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 android.text.style;
18 
19 import android.annotation.NonNull;
20 import android.annotation.Nullable;
21 import android.content.Context;
22 import android.content.Intent;
23 import android.content.res.TypedArray;
24 import android.graphics.Color;
25 import android.os.Parcel;
26 import android.os.Parcelable;
27 import android.os.SystemClock;
28 import android.text.ParcelableSpan;
29 import android.text.TextPaint;
30 import android.text.TextUtils;
31 import android.util.Log;
32 import android.view.inputmethod.InputMethodManager;
33 import android.widget.TextView;
34 
35 import java.util.Arrays;
36 import java.util.Locale;
37 
38 /**
39  * Holds suggestion candidates for the text enclosed in this span.
40  *
41  * When such a span is edited in an EditText, double tapping on the text enclosed in this span will
42  * display a popup dialog listing suggestion replacement for that text. The user can then replace
43  * the original text by one of the suggestions.
44  *
45  * These spans should typically be created by the input method to provide correction and alternates
46  * for the text.
47  *
48  * @see TextView#isSuggestionsEnabled()
49  */
50 public class SuggestionSpan extends CharacterStyle implements ParcelableSpan {
51 
52     private static final String TAG = "SuggestionSpan";
53 
54     /**
55      * Sets this flag if the suggestions should be easily accessible with few interactions.
56      * This flag should be set for every suggestions that the user is likely to use.
57      */
58     public static final int FLAG_EASY_CORRECT = 0x0001;
59 
60     /**
61      * Sets this flag if the suggestions apply to a misspelled word/text. This type of suggestion is
62      * rendered differently to highlight the error.
63      */
64     public static final int FLAG_MISSPELLED = 0x0002;
65 
66     /**
67      * Sets this flag if the auto correction is about to be applied to a word/text
68      * that the user is typing/composing. This type of suggestion is rendered differently
69      * to indicate the auto correction is happening.
70      */
71     public static final int FLAG_AUTO_CORRECTION = 0x0004;
72 
73     public static final String ACTION_SUGGESTION_PICKED = "android.text.style.SUGGESTION_PICKED";
74     public static final String SUGGESTION_SPAN_PICKED_AFTER = "after";
75     public static final String SUGGESTION_SPAN_PICKED_BEFORE = "before";
76     public static final String SUGGESTION_SPAN_PICKED_HASHCODE = "hashcode";
77 
78     public static final int SUGGESTIONS_MAX_SIZE = 5;
79 
80     /*
81      * TODO: Needs to check the validity and add a feature that TextView will change
82      * the current IME to the other IME which is specified in SuggestionSpan.
83      * An IME needs to set the span by specifying the target IME and Subtype of SuggestionSpan.
84      * And the current IME might want to specify any IME as the target IME including other IMEs.
85      */
86 
87     private int mFlags;
88     private final String[] mSuggestions;
89     /**
90      * Kept for compatibility for apps that rely on invalid locale strings e.g.
91      * {@code new Locale(" an ", " i n v a l i d ", "data")}, which cannot be handled by
92      * {@link #mLanguageTag}.
93      */
94     @NonNull
95     private final String mLocaleStringForCompatibility;
96     @NonNull
97     private final String mLanguageTag;
98     private final String mNotificationTargetClassName;
99     private final String mNotificationTargetPackageName;
100     private final int mHashCode;
101 
102     private float mEasyCorrectUnderlineThickness;
103     private int mEasyCorrectUnderlineColor;
104 
105     private float mMisspelledUnderlineThickness;
106     private int mMisspelledUnderlineColor;
107 
108     private float mAutoCorrectionUnderlineThickness;
109     private int mAutoCorrectionUnderlineColor;
110 
111     /**
112      * @param context Context for the application
113      * @param suggestions Suggestions for the string under the span
114      * @param flags Additional flags indicating how this span is handled in TextView
115      */
SuggestionSpan(Context context, String[] suggestions, int flags)116     public SuggestionSpan(Context context, String[] suggestions, int flags) {
117         this(context, null, suggestions, flags, null);
118     }
119 
120     /**
121      * @param locale Locale of the suggestions
122      * @param suggestions Suggestions for the string under the span
123      * @param flags Additional flags indicating how this span is handled in TextView
124      */
SuggestionSpan(Locale locale, String[] suggestions, int flags)125     public SuggestionSpan(Locale locale, String[] suggestions, int flags) {
126         this(null, locale, suggestions, flags, null);
127     }
128 
129     /**
130      * @param context Context for the application
131      * @param locale locale Locale of the suggestions
132      * @param suggestions Suggestions for the string under the span. Only the first up to
133      * {@link SuggestionSpan#SUGGESTIONS_MAX_SIZE} will be considered. Null values not permitted.
134      * @param flags Additional flags indicating how this span is handled in TextView
135      * @param notificationTargetClass if not null, this class will get notified when the user
136      * selects one of the suggestions.
137      */
SuggestionSpan(Context context, Locale locale, String[] suggestions, int flags, Class<?> notificationTargetClass)138     public SuggestionSpan(Context context, Locale locale, String[] suggestions, int flags,
139             Class<?> notificationTargetClass) {
140         final int N = Math.min(SUGGESTIONS_MAX_SIZE, suggestions.length);
141         mSuggestions = Arrays.copyOf(suggestions, N);
142         mFlags = flags;
143         final Locale sourceLocale;
144         if (locale != null) {
145             sourceLocale = locale;
146         } else if (context != null) {
147             // TODO: Consider to context.getResources().getResolvedLocale() instead.
148             sourceLocale = context.getResources().getConfiguration().locale;
149         } else {
150             Log.e("SuggestionSpan", "No locale or context specified in SuggestionSpan constructor");
151             sourceLocale = null;
152         }
153         mLocaleStringForCompatibility = sourceLocale == null ? "" : sourceLocale.toString();
154         mLanguageTag = sourceLocale == null ? "" : sourceLocale.toLanguageTag();
155 
156         if (context != null) {
157             mNotificationTargetPackageName = context.getPackageName();
158         } else {
159             mNotificationTargetPackageName = null;
160         }
161 
162         if (notificationTargetClass != null) {
163             mNotificationTargetClassName = notificationTargetClass.getCanonicalName();
164         } else {
165             mNotificationTargetClassName = "";
166         }
167         mHashCode = hashCodeInternal(mSuggestions, mLanguageTag, mLocaleStringForCompatibility,
168                 mNotificationTargetClassName);
169 
170         initStyle(context);
171     }
172 
initStyle(Context context)173     private void initStyle(Context context) {
174         if (context == null) {
175             mMisspelledUnderlineThickness = 0;
176             mEasyCorrectUnderlineThickness = 0;
177             mAutoCorrectionUnderlineThickness = 0;
178             mMisspelledUnderlineColor = Color.BLACK;
179             mEasyCorrectUnderlineColor = Color.BLACK;
180             mAutoCorrectionUnderlineColor = Color.BLACK;
181             return;
182         }
183 
184         int defStyleAttr = com.android.internal.R.attr.textAppearanceMisspelledSuggestion;
185         TypedArray typedArray = context.obtainStyledAttributes(
186                 null, com.android.internal.R.styleable.SuggestionSpan, defStyleAttr, 0);
187         mMisspelledUnderlineThickness = typedArray.getDimension(
188                 com.android.internal.R.styleable.SuggestionSpan_textUnderlineThickness, 0);
189         mMisspelledUnderlineColor = typedArray.getColor(
190                 com.android.internal.R.styleable.SuggestionSpan_textUnderlineColor, Color.BLACK);
191 
192         defStyleAttr = com.android.internal.R.attr.textAppearanceEasyCorrectSuggestion;
193         typedArray = context.obtainStyledAttributes(
194                 null, com.android.internal.R.styleable.SuggestionSpan, defStyleAttr, 0);
195         mEasyCorrectUnderlineThickness = typedArray.getDimension(
196                 com.android.internal.R.styleable.SuggestionSpan_textUnderlineThickness, 0);
197         mEasyCorrectUnderlineColor = typedArray.getColor(
198                 com.android.internal.R.styleable.SuggestionSpan_textUnderlineColor, Color.BLACK);
199 
200         defStyleAttr = com.android.internal.R.attr.textAppearanceAutoCorrectionSuggestion;
201         typedArray = context.obtainStyledAttributes(
202                 null, com.android.internal.R.styleable.SuggestionSpan, defStyleAttr, 0);
203         mAutoCorrectionUnderlineThickness = typedArray.getDimension(
204                 com.android.internal.R.styleable.SuggestionSpan_textUnderlineThickness, 0);
205         mAutoCorrectionUnderlineColor = typedArray.getColor(
206                 com.android.internal.R.styleable.SuggestionSpan_textUnderlineColor, Color.BLACK);
207     }
208 
SuggestionSpan(Parcel src)209     public SuggestionSpan(Parcel src) {
210         mSuggestions = src.readStringArray();
211         mFlags = src.readInt();
212         mLocaleStringForCompatibility = src.readString();
213         mLanguageTag = src.readString();
214         mNotificationTargetClassName = src.readString();
215         mNotificationTargetPackageName = src.readString();
216         mHashCode = src.readInt();
217         mEasyCorrectUnderlineColor = src.readInt();
218         mEasyCorrectUnderlineThickness = src.readFloat();
219         mMisspelledUnderlineColor = src.readInt();
220         mMisspelledUnderlineThickness = src.readFloat();
221         mAutoCorrectionUnderlineColor = src.readInt();
222         mAutoCorrectionUnderlineThickness = src.readFloat();
223     }
224 
225     /**
226      * @return an array of suggestion texts for this span
227      */
getSuggestions()228     public String[] getSuggestions() {
229         return mSuggestions;
230     }
231 
232     /**
233      * @deprecated use {@link #getLocaleObject()} instead.
234      * @return the locale of the suggestions. An empty string is returned if no locale is specified.
235      */
236     @NonNull
237     @Deprecated
getLocale()238     public String getLocale() {
239         return mLocaleStringForCompatibility;
240     }
241 
242     /**
243      * Returns a well-formed BCP 47 language tag representation of the suggestions, as a
244      * {@link Locale} object.
245      *
246      * <p><b>Caveat</b>: The returned object is guaranteed to be a  a well-formed BCP 47 language tag
247      * representation.  For example, this method can return an empty locale rather than returning a
248      * malformed data when this object is initialized with an malformed {@link Locale} object, e.g.
249      * {@code new Locale(" a ", " b c d ", " "}.</p>
250      *
251      * @return the locale of the suggestions. {@code null} is returned if no locale is specified.
252      */
253     @Nullable
getLocaleObject()254     public Locale getLocaleObject() {
255         return mLanguageTag.isEmpty() ? null : Locale.forLanguageTag(mLanguageTag);
256     }
257 
258     /**
259      * @return The name of the class to notify. The class of the original IME package will receive
260      * a notification when the user selects one of the suggestions. The notification will include
261      * the original string, the suggested replacement string as well as the hashCode of this span.
262      * The class will get notified by an intent that has those information.
263      * This is an internal API because only the framework should know the class name.
264      *
265      * @hide
266      */
getNotificationTargetClassName()267     public String getNotificationTargetClassName() {
268         return mNotificationTargetClassName;
269     }
270 
getFlags()271     public int getFlags() {
272         return mFlags;
273     }
274 
setFlags(int flags)275     public void setFlags(int flags) {
276         mFlags = flags;
277     }
278 
279     @Override
describeContents()280     public int describeContents() {
281         return 0;
282     }
283 
284     @Override
writeToParcel(Parcel dest, int flags)285     public void writeToParcel(Parcel dest, int flags) {
286         writeToParcelInternal(dest, flags);
287     }
288 
289     /** @hide */
writeToParcelInternal(Parcel dest, int flags)290     public void writeToParcelInternal(Parcel dest, int flags) {
291         dest.writeStringArray(mSuggestions);
292         dest.writeInt(mFlags);
293         dest.writeString(mLocaleStringForCompatibility);
294         dest.writeString(mLanguageTag);
295         dest.writeString(mNotificationTargetClassName);
296         dest.writeString(mNotificationTargetPackageName);
297         dest.writeInt(mHashCode);
298         dest.writeInt(mEasyCorrectUnderlineColor);
299         dest.writeFloat(mEasyCorrectUnderlineThickness);
300         dest.writeInt(mMisspelledUnderlineColor);
301         dest.writeFloat(mMisspelledUnderlineThickness);
302         dest.writeInt(mAutoCorrectionUnderlineColor);
303         dest.writeFloat(mAutoCorrectionUnderlineThickness);
304     }
305 
306     @Override
getSpanTypeId()307     public int getSpanTypeId() {
308         return getSpanTypeIdInternal();
309     }
310 
311     /** @hide */
getSpanTypeIdInternal()312     public int getSpanTypeIdInternal() {
313         return TextUtils.SUGGESTION_SPAN;
314     }
315 
316     @Override
equals(Object o)317     public boolean equals(Object o) {
318         if (o instanceof SuggestionSpan) {
319             return ((SuggestionSpan)o).hashCode() == mHashCode;
320         }
321         return false;
322     }
323 
324     @Override
hashCode()325     public int hashCode() {
326         return mHashCode;
327     }
328 
hashCodeInternal(String[] suggestions, @NonNull String languageTag, @NonNull String localeStringForCompatibility, String notificationTargetClassName)329     private static int hashCodeInternal(String[] suggestions, @NonNull String languageTag,
330             @NonNull String localeStringForCompatibility, String notificationTargetClassName) {
331         return Arrays.hashCode(new Object[] {Long.valueOf(SystemClock.uptimeMillis()), suggestions,
332                 languageTag, localeStringForCompatibility, notificationTargetClassName});
333     }
334 
335     public static final Parcelable.Creator<SuggestionSpan> CREATOR =
336             new Parcelable.Creator<SuggestionSpan>() {
337         @Override
338         public SuggestionSpan createFromParcel(Parcel source) {
339             return new SuggestionSpan(source);
340         }
341 
342         @Override
343         public SuggestionSpan[] newArray(int size) {
344             return new SuggestionSpan[size];
345         }
346     };
347 
348     @Override
updateDrawState(TextPaint tp)349     public void updateDrawState(TextPaint tp) {
350         final boolean misspelled = (mFlags & FLAG_MISSPELLED) != 0;
351         final boolean easy = (mFlags & FLAG_EASY_CORRECT) != 0;
352         final boolean autoCorrection = (mFlags & FLAG_AUTO_CORRECTION) != 0;
353         if (easy) {
354             if (!misspelled) {
355                 tp.setUnderlineText(mEasyCorrectUnderlineColor, mEasyCorrectUnderlineThickness);
356             } else if (tp.underlineColor == 0) {
357                 // Spans are rendered in an arbitrary order. Since misspelled is less prioritary
358                 // than just easy, do not apply misspelled if an easy (or a mispelled) has been set
359                 tp.setUnderlineText(mMisspelledUnderlineColor, mMisspelledUnderlineThickness);
360             }
361         } else if (autoCorrection) {
362             tp.setUnderlineText(mAutoCorrectionUnderlineColor, mAutoCorrectionUnderlineThickness);
363         }
364     }
365 
366     /**
367      * @return The color of the underline for that span, or 0 if there is no underline
368      *
369      * @hide
370      */
getUnderlineColor()371     public int getUnderlineColor() {
372         // The order here should match what is used in updateDrawState
373         final boolean misspelled = (mFlags & FLAG_MISSPELLED) != 0;
374         final boolean easy = (mFlags & FLAG_EASY_CORRECT) != 0;
375         final boolean autoCorrection = (mFlags & FLAG_AUTO_CORRECTION) != 0;
376         if (easy) {
377             if (!misspelled) {
378                 return mEasyCorrectUnderlineColor;
379             } else {
380                 return mMisspelledUnderlineColor;
381             }
382         } else if (autoCorrection) {
383             return mAutoCorrectionUnderlineColor;
384         }
385         return 0;
386     }
387 
388     /**
389      * Notifies a suggestion selection.
390      *
391      * @hide
392      */
notifySelection(Context context, String original, int index)393     public void notifySelection(Context context, String original, int index) {
394         final Intent intent = new Intent();
395 
396         if (context == null || mNotificationTargetClassName == null) {
397             return;
398         }
399         // Ensures that only a class in the original IME package will receive the
400         // notification.
401         if (mSuggestions == null || index < 0 || index >= mSuggestions.length) {
402             Log.w(TAG, "Unable to notify the suggestion as the index is out of range index=" + index
403                     + " length=" + mSuggestions.length);
404             return;
405         }
406 
407         // The package name is not mandatory (legacy from JB), and if the package name
408         // is missing, we try to notify the suggestion through the input method manager.
409         if (mNotificationTargetPackageName != null) {
410             intent.setClassName(mNotificationTargetPackageName, mNotificationTargetClassName);
411             intent.setAction(SuggestionSpan.ACTION_SUGGESTION_PICKED);
412             intent.putExtra(SuggestionSpan.SUGGESTION_SPAN_PICKED_BEFORE, original);
413             intent.putExtra(SuggestionSpan.SUGGESTION_SPAN_PICKED_AFTER, mSuggestions[index]);
414             intent.putExtra(SuggestionSpan.SUGGESTION_SPAN_PICKED_HASHCODE, hashCode());
415             context.sendBroadcast(intent);
416         } else {
417             InputMethodManager imm = InputMethodManager.peekInstance();
418             if (imm != null) {
419                 imm.notifySuggestionPicked(this, original, index);
420             }
421         }
422     }
423 }
424