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