blob: 8e2890321337150c64946969654f3410351e890b [file] [log] [blame]
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.server.wm;
import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN;
import static android.app.WindowConfiguration.WINDOWING_MODE_UNDEFINED;
import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
import static android.server.wm.ActivityManagerTestBase.executeShellCommand;
import static android.server.wm.WindowInsetsAnimationUtils.requestControlThenTransitionToVisibility;
import static android.view.Display.DEFAULT_DISPLAY;
import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import static android.view.WindowInsets.Type.ime;
import static android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN;
import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
import android.app.Activity;
import android.app.ActivityOptions;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Insets;
import android.graphics.Paint;
import android.graphics.Rect;
import android.inputmethodservice.InputMethodService;
import android.os.Bundle;
import android.platform.test.annotations.LargeTest;
import android.provider.Settings;
import android.server.wm.WindowInsetsAnimationControllerTests.LimitedErrorCollector;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowInsets;
import android.view.WindowInsetsAnimation;
import android.view.WindowInsetsAnimation.Callback;
import android.view.WindowInsetsController;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.ActivityTestRule;
import com.android.compatibility.common.util.PollingCheck;
import com.android.compatibility.common.util.SystemUtil;
import org.junit.Rule;
import org.junit.Test;
import java.lang.reflect.Array;
import java.util.List;
import java.util.Locale;
import java.util.function.Supplier;
@LargeTest
public class WindowInsetsAnimationSynchronicityTests {
private static final int APP_COLOR = 0xff01fe10; // green
private static final int BEHIND_IME_COLOR = 0xfffeef00; // yellow
private static final int IME_COLOR = 0xfffe01fd; // pink
@Rule
public LimitedErrorCollector mErrorCollector = new LimitedErrorCollector();
private final Context mContext = InstrumentationRegistry.getInstrumentation().getContext();
@Test
public void testShowAndHide_renderSynchronouslyBetweenImeWindowAndAppContent() throws Throwable {
runTest(false /* useControlApi */);
}
@Test
public void testControl_rendersSynchronouslyBetweenImeWindowAndAppContent() throws Throwable {
runTest(true /* useControlApi */);
}
private void runTest(boolean useControlApi) throws Exception {
try (ImeSession imeSession = new ImeSession(SimpleIme.getName(mContext))) {
TestActivity activity = launchActivity();
activity.setUseControlApi(useControlApi);
PollingCheck.waitFor(activity::hasWindowFocus);
activity.setEvaluator(() -> {
// This runs from time to time on the UI thread.
Bitmap screenshot = getInstrumentation().getUiAutomation().takeScreenshot();
final int center = screenshot.getWidth() / 2;
int imePositionApp = lowestPixelWithColor(APP_COLOR, 1, screenshot);
int contentBottomMiddle = lowestPixelWithColor(APP_COLOR, center, screenshot);
int behindImeBottomMiddle =
lowestPixelWithColor(BEHIND_IME_COLOR, center, screenshot);
int imePositionIme = Math.max(contentBottomMiddle, behindImeBottomMiddle);
if (imePositionApp != imePositionIme) {
mErrorCollector.addError(new AssertionError(String.format(Locale.US,
"IME is positioned at %d (max of %d, %d),"
+ " app thinks it is positioned at %d",
imePositionIme, contentBottomMiddle, behindImeBottomMiddle,
imePositionApp)));
}
});
Thread.sleep(2000);
activity.setEvaluator(null);
}
}
private TestActivity launchActivity() {
final ActivityOptions options= ActivityOptions.makeBasic();
options.setLaunchWindowingMode(WINDOWING_MODE_FULLSCREEN);
final TestActivity[] activity = (TestActivity[]) Array.newInstance(TestActivity.class, 1);
SystemUtil.runWithShellPermissionIdentity(() -> {
activity[0] = (TestActivity) getInstrumentation().startActivitySync(
new Intent(getInstrumentation().getTargetContext(), TestActivity.class)
.addFlags(FLAG_ACTIVITY_NEW_TASK), options.toBundle());
});
return activity[0];
}
private static int lowestPixelWithColor(int color, int x, Bitmap bitmap) {
int[] pixels = new int[bitmap.getHeight()];
bitmap.getPixels(pixels, 0, 1, x, 0, 1, bitmap.getHeight());
for (int y = pixels.length - 1; y >= 0; y--) {
if (pixels[y] == color) {
return y;
}
}
return -1;
}
public static class TestActivity extends Activity implements
WindowInsetsController.OnControllableInsetsChangedListener {
private TestView mTestView;
private EditText mEditText;
private Runnable mEvaluator;
private boolean mUseControlApi;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().requestFeature(Window.FEATURE_NO_TITLE);
getWindow().setDecorFitsSystemWindows(false);
getWindow().setSoftInputMode(SOFT_INPUT_STATE_ALWAYS_HIDDEN);
mTestView = new TestView(this);
mEditText = new EditText(this);
mEditText.setImeOptions(EditorInfo.IME_FLAG_NO_FULLSCREEN);
mTestView.addView(mEditText);
mTestView.mEvaluator = () -> {
if (mEvaluator != null) {
mEvaluator.run();
}
};
mEditText.requestFocus();
setContentView(mTestView);
mEditText.getWindowInsetsController().addOnControllableInsetsChangedListener(this);
}
void setEvaluator(Runnable evaluator) {
mEvaluator = evaluator;
}
void setUseControlApi(boolean useControlApi) {
mUseControlApi = useControlApi;
}
@Override
public void onControllableInsetsChanged(@NonNull WindowInsetsController controller,
int typeMask) {
if ((typeMask & ime()) != 0) {
mEditText.getWindowInsetsController().removeOnControllableInsetsChangedListener(
this);
showIme();
}
}
private void showIme() {
if (mUseControlApi) {
requestControlThenTransitionToVisibility(mTestView.getWindowInsetsController(),
ime(), true);
} else {
mTestView.getWindowInsetsController().show(ime());
}
}
private void hideIme() {
if (mUseControlApi) {
requestControlThenTransitionToVisibility(mTestView.getWindowInsetsController(),
ime(), false);
} else {
mTestView.getWindowInsetsController().hide(ime());
}
}
private static class TestView extends FrameLayout {
private WindowInsets mLayoutInsets;
private WindowInsets mAnimationInsets;
private final Rect mTmpRect = new Rect();
private final Paint mContentPaint = new Paint();
private final Callback mInsetsCallback = new Callback(Callback.DISPATCH_MODE_STOP) {
@NonNull
@Override
public WindowInsets onProgress(@NonNull WindowInsets insets,
@NonNull List<WindowInsetsAnimation> runningAnimations) {
if (runningAnimations.stream().anyMatch(TestView::isImeAnimation)) {
mAnimationInsets = insets;
invalidate();
}
return WindowInsets.CONSUMED;
}
@Override
public void onEnd(@NonNull WindowInsetsAnimation animation) {
if (isImeAnimation(animation)) {
mAnimationInsets = null;
post(() -> {
if (mLayoutInsets.isVisible(ime())) {
((TestActivity) getContext()).hideIme();
} else {
((TestActivity) getContext()).showIme();
}
});
}
}
};
private final Runnable mRunEvaluator;
private Runnable mEvaluator;
TestView(Context context) {
super(context);
setWindowInsetsAnimationCallback(mInsetsCallback);
mContentPaint.setColor(APP_COLOR);
mContentPaint.setStyle(Paint.Style.FILL);
setWillNotDraw(false);
mRunEvaluator = () -> {
if (mEvaluator != null) {
mEvaluator.run();
}
};
}
@Override
public WindowInsets onApplyWindowInsets(WindowInsets insets) {
mLayoutInsets = insets;
return WindowInsets.CONSUMED;
}
private WindowInsets getEffectiveInsets() {
return mAnimationInsets != null ? mAnimationInsets : mLayoutInsets;
}
@Override
protected void onDraw(Canvas canvas) {
canvas.drawColor(BEHIND_IME_COLOR);
mTmpRect.set(0, 0, getWidth(), getHeight());
Insets insets = getEffectiveInsets().getInsets(ime());
insetRect(mTmpRect, insets);
canvas.drawRect(mTmpRect, mContentPaint);
removeCallbacks(mRunEvaluator);
post(mRunEvaluator);
}
private static boolean isImeAnimation(WindowInsetsAnimation animation) {
return (animation.getTypeMask() & ime()) != 0;
}
private static void insetRect(Rect rect, Insets insets) {
rect.left += insets.left;
rect.top += insets.top;
rect.right -= insets.right;
rect.bottom -= insets.bottom;
}
}
}
private static class ImeSession implements AutoCloseable {
private static final long TIMEOUT = 2000;
private final ComponentName mImeName;
private Context mContext = InstrumentationRegistry.getInstrumentation().getContext();
ImeSession(ComponentName ime) throws Exception {
mImeName = ime;
executeShellCommand("ime reset");
executeShellCommand("ime enable " + ime.flattenToShortString());
executeShellCommand("ime set " + ime.flattenToShortString());
PollingCheck.check("Make sure that MockIME becomes available", TIMEOUT,
() -> ime.equals(getCurrentInputMethodId()));
}
@Override
public void close() throws Exception {
executeShellCommand("ime reset");
PollingCheck.check("Make sure that MockIME becomes unavailable", TIMEOUT, () ->
mContext.getSystemService(InputMethodManager.class)
.getEnabledInputMethodList()
.stream()
.noneMatch(info -> mImeName.equals(info.getComponent())));
}
@Nullable
private ComponentName getCurrentInputMethodId() {
// TODO: Replace this with IMM#getCurrentInputMethodIdForTesting()
return ComponentName.unflattenFromString(
Settings.Secure.getString(mContext.getContentResolver(),
Settings.Secure.DEFAULT_INPUT_METHOD));
}
}
public static class SimpleIme extends InputMethodService {
public static final int HEIGHT_DP = 200;
public static final int SIDE_PADDING_DP = 50;
@Override
public View onCreateInputView() {
final ViewGroup view = new FrameLayout(this);
final View inner = new View(this);
final float density = getResources().getDisplayMetrics().density;
final int height = (int) (HEIGHT_DP * density);
final int sidePadding = (int) (SIDE_PADDING_DP * density);
view.setPadding(sidePadding, 0, sidePadding, 0);
view.addView(inner, new FrameLayout.LayoutParams(MATCH_PARENT,
height));
inner.setBackgroundColor(IME_COLOR);
return view;
}
static ComponentName getName(Context context) {
return new ComponentName(context, SimpleIme.class);
}
}
}