1 /*
2  * Copyright (C) 2021 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.widget;
18 
19 import android.animation.Animator;
20 import android.animation.ValueAnimator;
21 import android.annotation.NonNull;
22 import android.annotation.Nullable;
23 import android.content.res.ColorStateList;
24 import android.graphics.Color;
25 import android.text.TextUtils;
26 import android.text.method.TransformationMethod;
27 import android.text.method.TranslationTransformationMethod;
28 import android.util.Log;
29 import android.view.View;
30 import android.view.translation.UiTranslationManager;
31 import android.view.translation.ViewTranslationCallback;
32 import android.view.translation.ViewTranslationRequest;
33 import android.view.translation.ViewTranslationResponse;
34 
35 import java.lang.ref.WeakReference;
36 
37 /**
38  * Default implementation for {@link ViewTranslationCallback} for {@link TextView} components.
39  * This class handles how to display the translated information for {@link TextView}.
40  *
41  * @hide
42  */
43 public class TextViewTranslationCallback implements ViewTranslationCallback {
44 
45     private static final String TAG = "TextViewTranslationCb";
46 
47     private static final boolean DEBUG = Log.isLoggable(UiTranslationManager.LOG_TAG, Log.DEBUG);
48 
49     private TranslationTransformationMethod mTranslationTransformation;
50     private boolean mIsShowingTranslation = false;
51     private boolean mAnimationRunning = false;
52     private boolean mIsTextPaddingEnabled = false;
53     private boolean mOriginalIsTextSelectable = false;
54     private int mOriginalFocusable = 0;
55     private boolean mOriginalFocusableInTouchMode = false;
56     private boolean mOriginalClickable = false;
57     private boolean mOriginalLongClickable = false;
58     private CharSequence mPaddedText;
59     private int mAnimationDurationMillis = 250; // default value
60 
61     private CharSequence mContentDescription;
62 
clearTranslationTransformation()63     private void clearTranslationTransformation() {
64         if (DEBUG) {
65             Log.v(TAG, "clearTranslationTransformation: " + mTranslationTransformation);
66         }
67         mTranslationTransformation = null;
68     }
69 
70     /**
71      * {@inheritDoc}
72      */
73     @Override
onShowTranslation(@onNull View view)74     public boolean onShowTranslation(@NonNull View view) {
75         if (mIsShowingTranslation) {
76             if (DEBUG) {
77                 Log.d(TAG, view + " is already showing translated text.");
78             }
79             return false;
80         }
81         ViewTranslationResponse response = view.getViewTranslationResponse();
82         if (response == null) {
83             Log.e(TAG, "onShowTranslation() shouldn't be called before "
84                     + "onViewTranslationResponse().");
85             return false;
86         }
87         // It is possible user changes text and new translation response returns, system should
88         // update the translation response to keep the result up to date.
89         // Because TextView.setTransformationMethod() will skip the same TransformationMethod
90         // instance, we should create a new one to let new translation can work.
91         TextView theTextView = (TextView) view;
92         if (mTranslationTransformation == null
93                 || !response.equals(mTranslationTransformation.getViewTranslationResponse())) {
94             TransformationMethod originalTranslationMethod =
95                     theTextView.getTransformationMethod();
96             mTranslationTransformation = new TranslationTransformationMethod(response,
97                     originalTranslationMethod);
98         }
99         final TransformationMethod transformation = mTranslationTransformation;
100         WeakReference<TextView> textViewRef = new WeakReference<>(theTextView);
101         runChangeTextWithAnimationIfNeeded(
102                 theTextView,
103                 () -> {
104                     mIsShowingTranslation = true;
105                     mAnimationRunning = false;
106 
107                     TextView textView = textViewRef.get();
108                     if (textView == null) {
109                         return;
110                     }
111                     // TODO(b/177214256): support selectable text translation.
112                     // We use the TransformationMethod to implement showing the translated text. The
113                     // TextView does not support the text length change for TransformationMethod.
114                     // If the text is selectable or editable, it will crash while selecting the
115                     // text. To support being able to select translated text, we need broader
116                     // changes to text APIs. For now, the callback makes the text non-selectable
117                     // while translated, and makes it selectable again after translation.
118                     mOriginalIsTextSelectable = textView.isTextSelectable();
119                     if (mOriginalIsTextSelectable) {
120                         // According to documentation for `setTextIsSelectable()`, it sets the
121                         // flags focusable, focusableInTouchMode, clickable, and longClickable
122                         // to the same value. We get the original values to restore when translation
123                         // is hidden.
124                         mOriginalFocusableInTouchMode = textView.isFocusableInTouchMode();
125                         mOriginalFocusable = textView.getFocusable();
126                         mOriginalClickable = textView.isClickable();
127                         mOriginalLongClickable = textView.isLongClickable();
128                         textView.setTextIsSelectable(false);
129                     }
130 
131                     // TODO(b/233406028): We should NOT restore the original
132                     //  TransformationMethod and selectable state if it was changed WHILE
133                     //  translation was being shown.
134                     textView.setTransformationMethod(transformation);
135                 });
136         if (response.getKeys().contains(ViewTranslationRequest.ID_CONTENT_DESCRIPTION)) {
137             CharSequence translatedContentDescription =
138                     response.getValue(ViewTranslationRequest.ID_CONTENT_DESCRIPTION).getText();
139             if (!TextUtils.isEmpty(translatedContentDescription)) {
140                 mContentDescription = view.getContentDescription();
141                 view.setContentDescription(translatedContentDescription);
142             }
143         }
144         return true;
145     }
146 
147     /**
148      * {@inheritDoc}
149      */
150     @Override
onHideTranslation(@onNull View view)151     public boolean onHideTranslation(@NonNull View view) {
152         if (view.getViewTranslationResponse() == null) {
153             Log.e(TAG, "onHideTranslation() shouldn't be called before "
154                     + "onViewTranslationResponse().");
155             return false;
156         }
157         // Restore to original text content.
158         if (mTranslationTransformation != null) {
159             final TransformationMethod transformation =
160                     mTranslationTransformation.getOriginalTransformationMethod();
161             TextView theTextView = (TextView) view;
162             WeakReference<TextView> textViewRef = new WeakReference<>(theTextView);
163             runChangeTextWithAnimationIfNeeded(
164                     theTextView,
165                     () -> {
166                         mIsShowingTranslation = false;
167                         mAnimationRunning = false;
168 
169                         TextView textView = textViewRef.get();
170                         if (textView == null) {
171                             return;
172                         }
173                         // TODO(b/233406028): We should NOT restore the original
174                         //  TransformationMethod and selectable state if it was changed WHILE
175                         //  translation was being shown.
176                         textView.setTransformationMethod(transformation);
177 
178                         if (mOriginalIsTextSelectable && !textView.isTextSelectable()) {
179                             // According to documentation for `setTextIsSelectable()`, it sets the
180                             // flags focusable, focusableInTouchMode, clickable, and longClickable
181                             // to the same value, and you must call `setFocusable()`, etc. to
182                             // restore all previous flag values.
183                             textView.setTextIsSelectable(true);
184                             textView.setFocusableInTouchMode(mOriginalFocusableInTouchMode);
185                             textView.setFocusable(mOriginalFocusable);
186                             textView.setClickable(mOriginalClickable);
187                             textView.setLongClickable(mOriginalLongClickable);
188                         }
189                     });
190             if (!TextUtils.isEmpty(mContentDescription)) {
191                 view.setContentDescription(mContentDescription);
192             }
193         } else {
194             if (DEBUG) {
195                 Log.w(TAG, "onHideTranslation(): no translated text.");
196             }
197             return false;
198         }
199         return true;
200     }
201 
202     /**
203      * {@inheritDoc}
204      */
205     @Override
onClearTranslation(@onNull View view)206     public boolean onClearTranslation(@NonNull View view) {
207         // Restore to original text content and clear TranslationTransformation
208         if (mTranslationTransformation != null) {
209             onHideTranslation(view);
210             clearTranslationTransformation();
211             mPaddedText = null;
212             mContentDescription = null;
213         } else {
214             if (DEBUG) {
215                 Log.w(TAG, "onClearTranslation(): no translated text.");
216             }
217             return false;
218         }
219         return true;
220     }
221 
isShowingTranslation()222     public boolean isShowingTranslation() {
223         return mIsShowingTranslation;
224     }
225 
226     /**
227      * Returns whether the view is running animation to show or hide the translation.
228      */
isAnimationRunning()229     public boolean isAnimationRunning() {
230         return mAnimationRunning;
231     }
232 
233     @Override
enableContentPadding()234     public void enableContentPadding() {
235         mIsTextPaddingEnabled = true;
236     }
237 
238     /**
239      * Returns whether readers of the view text should receive padded text for compatibility
240      * reasons. The view's original text will be padded to match the length of the translated text.
241      */
isTextPaddingEnabled()242     boolean isTextPaddingEnabled() {
243         return mIsTextPaddingEnabled;
244     }
245 
246     /**
247      * Returns the view's original text with padding added. If the translated text isn't longer than
248      * the original text, returns the original text itself.
249      *
250      * @param text the view's original text
251      * @param translatedText the view's translated text
252      * @see #isTextPaddingEnabled()
253      */
254     @Nullable
getPaddedText(CharSequence text, CharSequence translatedText)255     CharSequence getPaddedText(CharSequence text, CharSequence translatedText) {
256         if (text == null) {
257             return null;
258         }
259         if (mPaddedText == null) {
260             mPaddedText = computePaddedText(text, translatedText);
261         }
262         return mPaddedText;
263     }
264 
265     @NonNull
computePaddedText(CharSequence text, CharSequence translatedText)266     private CharSequence computePaddedText(CharSequence text, CharSequence translatedText) {
267         if (translatedText == null) {
268             return text;
269         }
270         int newLength = translatedText.length();
271         if (newLength <= text.length()) {
272             return text;
273         }
274         StringBuilder sb = new StringBuilder(newLength);
275         sb.append(text);
276         for (int i = text.length(); i < newLength; i++) {
277             sb.append(COMPAT_PAD_CHARACTER);
278         }
279         return sb;
280     }
281 
282     private static final char COMPAT_PAD_CHARACTER = '\u2002';
283 
284     @Override
setAnimationDurationMillis(int durationMillis)285     public void setAnimationDurationMillis(int durationMillis) {
286         mAnimationDurationMillis = durationMillis;
287     }
288 
289     /**
290      * Applies a simple text alpha animation when toggling between original and translated text. The
291      * text is fully faded out, then swapped to the new text, then the fading is reversed.
292      *
293      * @param changeTextRunnable the operation to run on the view after the text is faded out, to
294      * change to displaying the original or translated text.
295      */
runChangeTextWithAnimationIfNeeded(TextView view, Runnable changeTextRunnable)296     private void runChangeTextWithAnimationIfNeeded(TextView view, Runnable changeTextRunnable) {
297         boolean areAnimatorsEnabled = ValueAnimator.areAnimatorsEnabled();
298         if (!areAnimatorsEnabled) {
299             // The animation is disabled, just change display text
300             changeTextRunnable.run();
301             return;
302         }
303         if (mAnimator != null) {
304             mAnimator.end();
305             // Note: mAnimator is now null; do not use again here.
306         }
307         mAnimationRunning = true;
308         int fadedOutColor = colorWithAlpha(view.getCurrentTextColor(), 0);
309         mAnimator = ValueAnimator.ofArgb(view.getCurrentTextColor(), fadedOutColor);
310         mAnimator.addUpdateListener(
311                 // Note that if the text has a ColorStateList, this replaces it with a single color
312                 // for all states. The original ColorStateList is restored when the animation ends
313                 // (see below).
314                 (valueAnimator) -> view.setTextColor((Integer) valueAnimator.getAnimatedValue()));
315         mAnimator.setRepeatMode(ValueAnimator.REVERSE);
316         mAnimator.setRepeatCount(1);
317         mAnimator.setDuration(mAnimationDurationMillis);
318         final ColorStateList originalColors = view.getTextColors();
319         WeakReference<TextView> viewRef = new WeakReference<>(view);
320         mAnimator.addListener(new Animator.AnimatorListener() {
321             @Override
322             public void onAnimationStart(Animator animation) {
323             }
324 
325             @Override
326             public void onAnimationEnd(Animator animation) {
327                 TextView view = viewRef.get();
328                 if (view != null) {
329                     view.setTextColor(originalColors);
330                 }
331                 mAnimator = null;
332             }
333 
334             @Override
335             public void onAnimationCancel(Animator animation) {
336             }
337 
338             @Override
339             public void onAnimationRepeat(Animator animation) {
340                 changeTextRunnable.run();
341             }
342         });
343         mAnimator.start();
344     }
345 
346     private ValueAnimator mAnimator;
347 
348     /**
349      * Returns {@code color} with alpha changed to {@code newAlpha}
350      */
colorWithAlpha(int color, int newAlpha)351     private static int colorWithAlpha(int color, int newAlpha) {
352         return Color.argb(newAlpha, Color.red(color), Color.green(color), Color.blue(color));
353     }
354 }
355