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