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