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