1 /*
2  * Copyright (C) 2020 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.server.wm;
18 
19 import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN;
20 import static android.app.WindowConfiguration.WINDOWING_MODE_UNDEFINED;
21 import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
22 import static android.server.wm.ActivityManagerTestBase.executeShellCommand;
23 import static android.server.wm.WindowInsetsAnimationUtils.requestControlThenTransitionToVisibility;
24 import static android.view.Display.DEFAULT_DISPLAY;
25 import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
26 import static android.view.WindowInsets.Type.ime;
27 import static android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN;
28 
29 import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
30 
31 import android.app.Activity;
32 import android.app.ActivityOptions;
33 import android.content.ComponentName;
34 import android.content.Context;
35 import android.content.Intent;
36 import android.graphics.Bitmap;
37 import android.graphics.Canvas;
38 import android.graphics.Insets;
39 import android.graphics.Paint;
40 import android.graphics.Rect;
41 import android.inputmethodservice.InputMethodService;
42 import android.os.Bundle;
43 import android.platform.test.annotations.LargeTest;
44 import android.provider.Settings;
45 import android.server.wm.WindowInsetsAnimationControllerTests.LimitedErrorCollector;
46 import android.view.View;
47 import android.view.ViewGroup;
48 import android.view.Window;
49 import android.view.WindowInsets;
50 import android.view.WindowInsetsAnimation;
51 import android.view.WindowInsetsAnimation.Callback;
52 import android.view.WindowInsetsController;
53 import android.view.inputmethod.EditorInfo;
54 import android.view.inputmethod.InputMethodManager;
55 import android.widget.EditText;
56 import android.widget.FrameLayout;
57 
58 import androidx.annotation.NonNull;
59 import androidx.annotation.Nullable;
60 import androidx.test.platform.app.InstrumentationRegistry;
61 import androidx.test.rule.ActivityTestRule;
62 
63 import com.android.compatibility.common.util.PollingCheck;
64 import com.android.compatibility.common.util.SystemUtil;
65 
66 import org.junit.Rule;
67 import org.junit.Test;
68 
69 import java.lang.reflect.Array;
70 import java.util.List;
71 import java.util.Locale;
72 import java.util.function.Supplier;
73 
74 @LargeTest
75 public class WindowInsetsAnimationSynchronicityTests {
76     private static final int APP_COLOR = 0xff01fe10; // green
77     private static final int BEHIND_IME_COLOR = 0xfffeef00; // yellow
78     private static final int IME_COLOR = 0xfffe01fd; // pink
79 
80     @Rule
81     public LimitedErrorCollector mErrorCollector = new LimitedErrorCollector();
82 
83     private final Context mContext = InstrumentationRegistry.getInstrumentation().getContext();
84 
85     @Test
testShowAndHide_renderSynchronouslyBetweenImeWindowAndAppContent()86     public void testShowAndHide_renderSynchronouslyBetweenImeWindowAndAppContent() throws Throwable {
87         runTest(false /* useControlApi */);
88     }
89 
90     @Test
testControl_rendersSynchronouslyBetweenImeWindowAndAppContent()91     public void testControl_rendersSynchronouslyBetweenImeWindowAndAppContent() throws Throwable {
92         runTest(true /* useControlApi */);
93     }
94 
runTest(boolean useControlApi)95     private void runTest(boolean useControlApi) throws Exception {
96         try (ImeSession imeSession = new ImeSession(SimpleIme.getName(mContext))) {
97             TestActivity activity = launchActivity();
98             activity.setUseControlApi(useControlApi);
99             PollingCheck.waitFor(activity::hasWindowFocus);
100             activity.setEvaluator(() -> {
101                 // This runs from time to time on the UI thread.
102                 Bitmap screenshot = getInstrumentation().getUiAutomation().takeScreenshot();
103                 final int center = screenshot.getWidth() / 2;
104                 int imePositionApp = lowestPixelWithColor(APP_COLOR, 1, screenshot);
105                 int contentBottomMiddle = lowestPixelWithColor(APP_COLOR, center, screenshot);
106                 int behindImeBottomMiddle =
107                         lowestPixelWithColor(BEHIND_IME_COLOR, center, screenshot);
108                 int imePositionIme = Math.max(contentBottomMiddle, behindImeBottomMiddle);
109                 if (imePositionApp != imePositionIme) {
110                     mErrorCollector.addError(new AssertionError(String.format(Locale.US,
111                             "IME is positioned at %d (max of %d, %d),"
112                                     + " app thinks it is positioned at %d",
113                             imePositionIme, contentBottomMiddle, behindImeBottomMiddle,
114                             imePositionApp)));
115                 }
116             });
117             Thread.sleep(2000);
118             activity.setEvaluator(null);
119         }
120     }
121 
launchActivity()122     private TestActivity launchActivity() {
123         final ActivityOptions options= ActivityOptions.makeBasic();
124         options.setLaunchWindowingMode(WINDOWING_MODE_FULLSCREEN);
125         final TestActivity[] activity = (TestActivity[]) Array.newInstance(TestActivity.class, 1);
126         SystemUtil.runWithShellPermissionIdentity(() -> {
127             activity[0] = (TestActivity) getInstrumentation().startActivitySync(
128                     new Intent(getInstrumentation().getTargetContext(), TestActivity.class)
129                             .addFlags(FLAG_ACTIVITY_NEW_TASK), options.toBundle());
130         });
131         return activity[0];
132     }
133 
lowestPixelWithColor(int color, int x, Bitmap bitmap)134     private static int lowestPixelWithColor(int color, int x, Bitmap bitmap) {
135         int[] pixels = new int[bitmap.getHeight()];
136         bitmap.getPixels(pixels, 0, 1, x, 0, 1, bitmap.getHeight());
137         for (int y = pixels.length - 1; y >= 0; y--) {
138             if (pixels[y] == color) {
139                 return y;
140             }
141         }
142         return -1;
143     }
144 
145     public static class TestActivity extends Activity implements
146             WindowInsetsController.OnControllableInsetsChangedListener {
147 
148         private TestView mTestView;
149         private EditText mEditText;
150         private Runnable mEvaluator;
151         private boolean mUseControlApi;
152 
153         @Override
onCreate(@ullable Bundle savedInstanceState)154         protected void onCreate(@Nullable Bundle savedInstanceState) {
155             super.onCreate(savedInstanceState);
156             getWindow().requestFeature(Window.FEATURE_NO_TITLE);
157             getWindow().setDecorFitsSystemWindows(false);
158             getWindow().setSoftInputMode(SOFT_INPUT_STATE_ALWAYS_HIDDEN);
159             mTestView = new TestView(this);
160             mEditText = new EditText(this);
161             mEditText.setImeOptions(EditorInfo.IME_FLAG_NO_FULLSCREEN);
162             mTestView.addView(mEditText);
163             mTestView.mEvaluator = () -> {
164                 if (mEvaluator != null) {
165                     mEvaluator.run();
166                 }
167             };
168             mEditText.requestFocus();
169             setContentView(mTestView);
170             mEditText.getWindowInsetsController().addOnControllableInsetsChangedListener(this);
171         }
172 
setEvaluator(Runnable evaluator)173         void setEvaluator(Runnable evaluator) {
174             mEvaluator = evaluator;
175         }
176 
setUseControlApi(boolean useControlApi)177         void setUseControlApi(boolean useControlApi) {
178             mUseControlApi = useControlApi;
179         }
180 
181         @Override
onControllableInsetsChanged(@onNull WindowInsetsController controller, int typeMask)182         public void onControllableInsetsChanged(@NonNull WindowInsetsController controller,
183                 int typeMask) {
184             if ((typeMask & ime()) != 0) {
185                 mEditText.getWindowInsetsController().removeOnControllableInsetsChangedListener(
186                         this);
187                 showIme();
188             }
189         }
190 
showIme()191         private void showIme() {
192             if (mUseControlApi) {
193                 requestControlThenTransitionToVisibility(mTestView.getWindowInsetsController(),
194                         ime(), true);
195             } else {
196                 mTestView.getWindowInsetsController().show(ime());
197             }
198         }
199 
hideIme()200         private void hideIme() {
201             if (mUseControlApi) {
202                 requestControlThenTransitionToVisibility(mTestView.getWindowInsetsController(),
203                         ime(), false);
204             } else {
205                 mTestView.getWindowInsetsController().hide(ime());
206             }
207         }
208 
209         private static class TestView extends FrameLayout {
210             private WindowInsets mLayoutInsets;
211             private WindowInsets mAnimationInsets;
212             private final Rect mTmpRect = new Rect();
213             private final Paint mContentPaint = new Paint();
214 
215             private final Callback mInsetsCallback = new Callback(Callback.DISPATCH_MODE_STOP) {
216                 @NonNull
217                 @Override
218                 public WindowInsets onProgress(@NonNull WindowInsets insets,
219                         @NonNull List<WindowInsetsAnimation> runningAnimations) {
220                     if (runningAnimations.stream().anyMatch(TestView::isImeAnimation)) {
221                         mAnimationInsets = insets;
222                         invalidate();
223                     }
224                     return WindowInsets.CONSUMED;
225                 }
226 
227                 @Override
228                 public void onEnd(@NonNull WindowInsetsAnimation animation) {
229                     if (isImeAnimation(animation)) {
230                         mAnimationInsets = null;
231                         post(() -> {
232                             if (mLayoutInsets.isVisible(ime())) {
233                                 ((TestActivity) getContext()).hideIme();
234                             } else {
235                                 ((TestActivity) getContext()).showIme();
236                             }
237                         });
238 
239                     }
240                 }
241             };
242             private final Runnable mRunEvaluator;
243             private Runnable mEvaluator;
244 
TestView(Context context)245             TestView(Context context) {
246                 super(context);
247                 setWindowInsetsAnimationCallback(mInsetsCallback);
248                 mContentPaint.setColor(APP_COLOR);
249                 mContentPaint.setStyle(Paint.Style.FILL);
250                 setWillNotDraw(false);
251                 mRunEvaluator = () -> {
252                     if (mEvaluator != null) {
253                         mEvaluator.run();
254                     }
255                 };
256             }
257 
258             @Override
onApplyWindowInsets(WindowInsets insets)259             public WindowInsets onApplyWindowInsets(WindowInsets insets) {
260                 mLayoutInsets = insets;
261                 return WindowInsets.CONSUMED;
262             }
263 
getEffectiveInsets()264             private WindowInsets getEffectiveInsets() {
265                 return mAnimationInsets != null ? mAnimationInsets : mLayoutInsets;
266             }
267 
268             @Override
onDraw(Canvas canvas)269             protected void onDraw(Canvas canvas) {
270                 canvas.drawColor(BEHIND_IME_COLOR);
271                 mTmpRect.set(0, 0, getWidth(), getHeight());
272                 Insets insets = getEffectiveInsets().getInsets(ime());
273                 insetRect(mTmpRect, insets);
274                 canvas.drawRect(mTmpRect, mContentPaint);
275                 removeCallbacks(mRunEvaluator);
276                 post(mRunEvaluator);
277             }
278 
isImeAnimation(WindowInsetsAnimation animation)279             private static boolean isImeAnimation(WindowInsetsAnimation animation) {
280                 return (animation.getTypeMask() & ime()) != 0;
281             }
282 
insetRect(Rect rect, Insets insets)283             private static void insetRect(Rect rect, Insets insets) {
284                 rect.left += insets.left;
285                 rect.top += insets.top;
286                 rect.right -= insets.right;
287                 rect.bottom -= insets.bottom;
288             }
289         }
290     }
291 
292     private static class ImeSession implements AutoCloseable {
293 
294         private static final long TIMEOUT = 2000;
295         private final ComponentName mImeName;
296         private Context mContext = InstrumentationRegistry.getInstrumentation().getContext();
297 
ImeSession(ComponentName ime)298         ImeSession(ComponentName ime) throws Exception {
299             mImeName = ime;
300             executeShellCommand("ime reset");
301             executeShellCommand("ime enable " + ime.flattenToShortString());
302             executeShellCommand("ime set " + ime.flattenToShortString());
303             PollingCheck.check("Make sure that MockIME becomes available", TIMEOUT,
304                     () -> ime.equals(getCurrentInputMethodId()));
305         }
306 
307         @Override
close()308         public void close() throws Exception {
309             executeShellCommand("ime reset");
310             PollingCheck.check("Make sure that MockIME becomes unavailable", TIMEOUT, () ->
311                     mContext.getSystemService(InputMethodManager.class)
312                             .getEnabledInputMethodList()
313                             .stream()
314                             .noneMatch(info -> mImeName.equals(info.getComponent())));
315         }
316 
317         @Nullable
getCurrentInputMethodId()318         private ComponentName getCurrentInputMethodId() {
319             // TODO: Replace this with IMM#getCurrentInputMethodIdForTesting()
320             return ComponentName.unflattenFromString(
321                     Settings.Secure.getString(mContext.getContentResolver(),
322                     Settings.Secure.DEFAULT_INPUT_METHOD));
323         }
324     }
325 
326     public static class SimpleIme extends InputMethodService {
327 
328         public static final int HEIGHT_DP = 200;
329         public static final int SIDE_PADDING_DP = 50;
330 
331         @Override
onCreateInputView()332         public View onCreateInputView() {
333             final ViewGroup view = new FrameLayout(this);
334             final View inner = new View(this);
335             final float density = getResources().getDisplayMetrics().density;
336             final int height = (int) (HEIGHT_DP * density);
337             final int sidePadding = (int) (SIDE_PADDING_DP * density);
338             view.setPadding(sidePadding, 0, sidePadding, 0);
339             view.addView(inner, new FrameLayout.LayoutParams(MATCH_PARENT,
340                     height));
341             inner.setBackgroundColor(IME_COLOR);
342             return view;
343         }
344 
getName(Context context)345         static ComponentName getName(Context context) {
346             return new ComponentName(context, SimpleIme.class);
347         }
348     }
349 }
350