Migrate Bubbles to wm-shell (5/n)

Move bubbles package and related resources to shell package,
also copied some used codes and resources.

Bug: 161980186
Test: atest SystemUITests
Test: atest WMShellUnitTests
Change-Id: Ia108bd4149b3c3bf86631ba1a7a6bce0e76af78f
This commit is contained in:
Tony Huang
2020-10-27 11:37:11 +08:00
parent 02c367d8c1
commit eb01a25c87
138 changed files with 959 additions and 977 deletions

View File

@@ -22,6 +22,13 @@
<application android:debuggable="true" android:largeHeap="true">
<uses-library android:name="android.test.mock" />
<uses-library android:name="android.test.runner" />
<activity android:name=".bubbles.BubblesTestActivity"
android:allowEmbedded="true"
android:documentLaunchMode="always"
android:excludeFromRecents="true"
android:exported="false"
android:resizeableActivity="true" />
</application>
<instrumentation

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
>
<TextView
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:text="this is a test activity"
/>
<EditText
android:layout_height="wrap_content"
android:id="@+id/editText1"
android:layout_width="match_parent">
<requestFocus></requestFocus>
</EditText>
</LinearLayout>

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.android.wm.shell.pip;
package com.android.wm.shell;
import static android.view.Display.DEFAULT_DISPLAY;
@@ -24,17 +24,18 @@ import android.testing.TestableContext;
import androidx.test.InstrumentationRegistry;
import org.junit.After;
import org.junit.Before;
/**
* Base class that does One Handed specific setup.
* Base class that does shell test case setup.
*/
public abstract class PipTestCase {
public abstract class ShellTestCase {
protected TestableContext mContext;
@Before
public void setup() {
public void shellSetup() {
final Context context =
InstrumentationRegistry.getInstrumentation().getTargetContext();
final DisplayManager dm = context.getSystemService(DisplayManager.class);
@@ -47,6 +48,14 @@ public abstract class PipTestCase {
.adoptShellPermissionIdentity();
}
@After
public void shellTearDown() {
InstrumentationRegistry
.getInstrumentation()
.getUiAutomation()
.dropShellPermissionIdentity();
}
protected Context getContext() {
return mContext;
}

View File

@@ -0,0 +1,218 @@
/*
* 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 com.android.wm.shell;
import static android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
import android.app.ActivityManager;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.content.Context;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper;
import android.view.SurfaceControl;
import android.view.SurfaceHolder;
import android.view.SurfaceSession;
import android.window.WindowContainerToken;
import androidx.test.filters.SmallTest;
import com.android.wm.shell.common.HandlerExecutor;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.invocation.InvocationOnMock;
@SmallTest
@RunWith(AndroidTestingRunner.class)
@TestableLooper.RunWithLooper(setAsMainLooper = true)
public class TaskViewTest extends ShellTestCase {
@Mock
TaskView.Listener mViewListener;
@Mock
ActivityManager.RunningTaskInfo mTaskInfo;
@Mock
WindowContainerToken mToken;
@Mock
ShellTaskOrganizer mOrganizer;
@Mock
HandlerExecutor mExecutor;
SurfaceSession mSession;
SurfaceControl mLeash;
Context mContext;
TaskView mTaskView;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
mLeash = new SurfaceControl.Builder(mSession)
.setName("test")
.build();
mContext = getContext();
mTaskInfo = new ActivityManager.RunningTaskInfo();
mTaskInfo.token = mToken;
mTaskInfo.taskId = 314;
mTaskInfo.taskDescription = mock(ActivityManager.TaskDescription.class);
doAnswer((InvocationOnMock invocationOnMock) -> {
final Runnable r = invocationOnMock.getArgument(0);
r.run();
return null;
}).when(mExecutor).execute(any());
mTaskView = new TaskView(mContext, mOrganizer);
mTaskView.setExecutor(mExecutor);
mTaskView.setListener(mViewListener);
}
@After
public void tearDown() {
if (mTaskView != null) {
mTaskView.release();
}
}
@Test
public void testSetPendingListener_throwsException() {
TaskView taskView = new TaskView(mContext, mOrganizer);
mTaskView.setExecutor(mExecutor);
taskView.setListener(mViewListener);
try {
taskView.setListener(mViewListener);
} catch (IllegalStateException e) {
// pass
return;
}
fail("Expected IllegalStateException");
}
@Test
public void testStartActivity() {
ActivityOptions options = ActivityOptions.makeBasic();
mTaskView.startActivity(mock(PendingIntent.class), null, options);
verify(mOrganizer).setPendingLaunchCookieListener(any(), eq(mTaskView));
assertThat(options.getLaunchWindowingMode()).isEqualTo(WINDOWING_MODE_MULTI_WINDOW);
assertThat(options.getTaskAlwaysOnTop()).isTrue();
}
@Test
public void testOnTaskAppeared_noSurface() {
mTaskView.onTaskAppeared(mTaskInfo, mLeash);
verify(mViewListener).onTaskCreated(eq(mTaskInfo.taskId), any());
verify(mViewListener, never()).onInitialized();
// If there's no surface the task should be made invisible
verify(mViewListener).onTaskVisibilityChanged(eq(mTaskInfo.taskId), eq(false));
}
@Test
public void testOnTaskAppeared_withSurface() {
mTaskView.surfaceCreated(mock(SurfaceHolder.class));
mTaskView.onTaskAppeared(mTaskInfo, mLeash);
verify(mViewListener).onTaskCreated(eq(mTaskInfo.taskId), any());
verify(mViewListener, never()).onTaskVisibilityChanged(anyInt(), anyBoolean());
}
@Test
public void testSurfaceCreated_noTask() {
mTaskView.surfaceCreated(mock(SurfaceHolder.class));
verify(mViewListener).onInitialized();
// No task, no visibility change
verify(mViewListener, never()).onTaskVisibilityChanged(anyInt(), anyBoolean());
}
@Test
public void testSurfaceCreated_withTask() {
mTaskView.onTaskAppeared(mTaskInfo, mLeash);
mTaskView.surfaceCreated(mock(SurfaceHolder.class));
verify(mViewListener).onInitialized();
verify(mViewListener).onTaskVisibilityChanged(eq(mTaskInfo.taskId), eq(true));
}
@Test
public void testSurfaceDestroyed_noTask() {
SurfaceHolder sh = mock(SurfaceHolder.class);
mTaskView.surfaceCreated(sh);
mTaskView.surfaceDestroyed(sh);
verify(mViewListener, never()).onTaskVisibilityChanged(anyInt(), anyBoolean());
}
@Test
public void testSurfaceDestroyed_withTask() {
SurfaceHolder sh = mock(SurfaceHolder.class);
mTaskView.onTaskAppeared(mTaskInfo, mLeash);
mTaskView.surfaceCreated(sh);
reset(mViewListener);
mTaskView.surfaceDestroyed(sh);
verify(mViewListener).onTaskVisibilityChanged(eq(mTaskInfo.taskId), eq(false));
}
@Test
public void testOnReleased() {
mTaskView.onTaskAppeared(mTaskInfo, mLeash);
mTaskView.surfaceCreated(mock(SurfaceHolder.class));
mTaskView.release();
verify(mOrganizer).removeListener(eq(mTaskView));
verify(mViewListener).onReleased();
}
@Test
public void testOnTaskVanished() {
mTaskView.onTaskAppeared(mTaskInfo, mLeash);
mTaskView.surfaceCreated(mock(SurfaceHolder.class));
mTaskView.onTaskVanished(mTaskInfo);
verify(mViewListener).onTaskRemovalStarted(eq(mTaskInfo.taskId));
}
@Test
public void testOnBackPressedOnTaskRoot() {
mTaskView.onTaskAppeared(mTaskInfo, mLeash);
mTaskView.onBackPressedOnTaskRoot(mTaskInfo);
verify(mViewListener).onBackPressedOnTaskRoot(eq(mTaskInfo.taskId));
}
}

View File

@@ -0,0 +1,907 @@
/*
* 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 com.android.wm.shell.bubbles;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyZeroInteractions;
import static org.mockito.Mockito.when;
import android.app.Notification;
import android.app.PendingIntent;
import android.graphics.drawable.Icon;
import android.os.Bundle;
import android.os.UserHandle;
import android.service.notification.NotificationListenerService;
import android.service.notification.StatusBarNotification;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper;
import android.util.Pair;
import androidx.test.filters.SmallTest;
import com.android.wm.shell.ShellTestCase;
import com.android.wm.shell.bubbles.BubbleData.TimeSource;
import com.google.common.collect.ImmutableList;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
/**
* Tests operations and the resulting state managed by BubbleData.
* <p>
* After each operation to verify, {@link #verifyUpdateReceived()} ensures the listener was called
* and captures the Update object received there.
* <p>
* Other methods beginning with 'assert' access the captured update object and assert on specific
* aspects of it.
*/
@SmallTest
@RunWith(AndroidTestingRunner.class)
@TestableLooper.RunWithLooper(setAsMainLooper = true)
public class BubbleDataTest extends ShellTestCase {
private BubbleEntry mEntryA1;
private BubbleEntry mEntryA2;
private BubbleEntry mEntryA3;
private BubbleEntry mEntryB1;
private BubbleEntry mEntryB2;
private BubbleEntry mEntryB3;
private BubbleEntry mEntryC1;
private BubbleEntry mEntryInterruptive;
private BubbleEntry mEntryDismissed;
private Bubble mBubbleA1;
private Bubble mBubbleA2;
private Bubble mBubbleA3;
private Bubble mBubbleB1;
private Bubble mBubbleB2;
private Bubble mBubbleB3;
private Bubble mBubbleC1;
private Bubble mBubbleInterruptive;
private Bubble mBubbleDismissed;
private BubbleData mBubbleData;
@Mock
private TimeSource mTimeSource;
@Mock
private BubbleData.Listener mListener;
@Mock
private PendingIntent mExpandIntent;
@Mock
private PendingIntent mDeleteIntent;
@Mock
private BubbleLogger mBubbleLogger;
@Captor
private ArgumentCaptor<BubbleData.Update> mUpdateCaptor;
@Mock
private Bubbles.NotificationSuppressionChangedListener mSuppressionListener;
@Mock
private Bubbles.PendingIntentCanceledListener mPendingIntentCanceledListener;
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
mEntryA1 = createBubbleEntry(1, "a1", "package.a", null);
mEntryA2 = createBubbleEntry(1, "a2", "package.a", null);
mEntryA3 = createBubbleEntry(1, "a3", "package.a", null);
mEntryB1 = createBubbleEntry(1, "b1", "package.b", null);
mEntryB2 = createBubbleEntry(1, "b2", "package.b", null);
mEntryB3 = createBubbleEntry(1, "b3", "package.b", null);
mEntryC1 = createBubbleEntry(1, "c1", "package.c", null);
NotificationListenerService.Ranking ranking =
mock(NotificationListenerService.Ranking.class);
when(ranking.visuallyInterruptive()).thenReturn(true);
mEntryInterruptive = createBubbleEntry(1, "interruptive", "package.d", ranking);
mBubbleInterruptive = new Bubble(mEntryInterruptive, mSuppressionListener, null);
mEntryDismissed = createBubbleEntry(1, "dismissed", "package.d", null);
mBubbleDismissed = new Bubble(mEntryDismissed, mSuppressionListener, null);
mBubbleA1 = new Bubble(mEntryA1, mSuppressionListener, mPendingIntentCanceledListener);
mBubbleA2 = new Bubble(mEntryA2, mSuppressionListener, mPendingIntentCanceledListener);
mBubbleA3 = new Bubble(mEntryA3, mSuppressionListener, mPendingIntentCanceledListener);
mBubbleB1 = new Bubble(mEntryB1, mSuppressionListener, mPendingIntentCanceledListener);
mBubbleB2 = new Bubble(mEntryB2, mSuppressionListener, mPendingIntentCanceledListener);
mBubbleB3 = new Bubble(mEntryB3, mSuppressionListener, mPendingIntentCanceledListener);
mBubbleC1 = new Bubble(mEntryC1, mSuppressionListener, mPendingIntentCanceledListener);
mBubbleData = new BubbleData(getContext(), mBubbleLogger);
// Used by BubbleData to set lastAccessedTime
when(mTimeSource.currentTimeMillis()).thenReturn(1000L);
mBubbleData.setTimeSource(mTimeSource);
// Assert baseline starting state
assertThat(mBubbleData.hasBubbles()).isFalse();
assertThat(mBubbleData.isExpanded()).isFalse();
assertThat(mBubbleData.getSelectedBubble()).isNull();
}
@Test
public void testAddBubble() {
// Setup
mBubbleData.setListener(mListener);
// Test
sendUpdatedEntryAtTime(mEntryA1, 1000);
// Verify
verifyUpdateReceived();
assertBubbleAdded(mBubbleA1);
assertSelectionChangedTo(mBubbleA1);
}
@Test
public void testRemoveBubble() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryA2, 2000);
sendUpdatedEntryAtTime(mEntryA3, 3000);
mBubbleData.setListener(mListener);
// Test
mBubbleData.dismissBubbleWithKey(mEntryA1.getKey(), Bubbles.DISMISS_USER_GESTURE);
// Verify
verifyUpdateReceived();
assertBubbleRemoved(mBubbleA1, Bubbles.DISMISS_USER_GESTURE);
}
@Test
public void ifSuppress_hideFlyout() {
// Setup
mBubbleData.setListener(mListener);
// Test
mBubbleData.notificationEntryUpdated(mBubbleC1, /* suppressFlyout */ true, /* showInShade */
true);
// Verify
verifyUpdateReceived();
BubbleData.Update update = mUpdateCaptor.getValue();
assertThat(update.addedBubble.showFlyout()).isFalse();
}
@Test
public void ifInterruptiveAndNotSuppressed_thenShowFlyout() {
// Setup
mBubbleData.setListener(mListener);
// Test
mBubbleData.notificationEntryUpdated(mBubbleInterruptive,
false /* suppressFlyout */, true /* showInShade */);
// Verify
verifyUpdateReceived();
BubbleData.Update update = mUpdateCaptor.getValue();
assertThat(update.addedBubble.showFlyout()).isTrue();
}
@Test
public void sameUpdate_InShade_thenHideFlyout() {
// Setup
mBubbleData.setListener(mListener);
// Test
mBubbleData.notificationEntryUpdated(mBubbleC1, false /* suppressFlyout */,
true /* showInShade */);
verifyUpdateReceived();
mBubbleData.notificationEntryUpdated(mBubbleC1, false /* suppressFlyout */,
true /* showInShade */);
verifyUpdateReceived();
// Verify
BubbleData.Update update = mUpdateCaptor.getValue();
assertThat(update.updatedBubble.showFlyout()).isFalse();
}
@Test
public void sameUpdate_NotInShade_NotVisuallyInterruptive_dontShowFlyout() {
// Setup
mBubbleData.setListener(mListener);
// Test
mBubbleData.notificationEntryUpdated(mBubbleDismissed, false /* suppressFlyout */,
true /* showInShade */);
verifyUpdateReceived();
// Suppress the notif / make it look dismissed
mBubbleDismissed.setSuppressNotification(true);
mBubbleData.notificationEntryUpdated(mBubbleDismissed, false /* suppressFlyout */,
true /* showInShade */);
verifyUpdateReceived();
// Verify
BubbleData.Update update = mUpdateCaptor.getValue();
assertThat(update.updatedBubble.showFlyout()).isFalse();
}
//
// Overflow
//
/**
* Verifies that when the bubble stack reaches its maximum, the oldest bubble is overflowed.
*/
@Test
public void testOverflow_add_stackAtMaxBubbles_overflowsOldest() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryA2, 2000);
sendUpdatedEntryAtTime(mEntryA3, 3000);
sendUpdatedEntryAtTime(mEntryB1, 4000);
sendUpdatedEntryAtTime(mEntryB2, 5000);
mBubbleData.setListener(mListener);
sendUpdatedEntryAtTime(mEntryC1, 6000);
verifyUpdateReceived();
assertBubbleRemoved(mBubbleA1, Bubbles.DISMISS_AGED);
assertOverflowChangedTo(ImmutableList.of(mBubbleA1));
Bubble bubbleA1 = mBubbleData.getOrCreateBubble(mEntryA1, null /* persistedBubble */);
bubbleA1.markUpdatedAt(7000L);
mBubbleData.notificationEntryUpdated(bubbleA1, false /* suppressFlyout*/,
true /* showInShade */);
verifyUpdateReceived();
assertBubbleRemoved(mBubbleA2, Bubbles.DISMISS_AGED);
assertOverflowChangedTo(ImmutableList.of(mBubbleA2));
}
/**
* Verifies that once the number of overflowed bubbles reaches its maximum, the oldest
* overflow bubble is removed.
*/
@Test
public void testOverflow_maxReached_bubbleRemoved() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryA2, 2000);
sendUpdatedEntryAtTime(mEntryA3, 3000);
mBubbleData.setListener(mListener);
mBubbleData.setMaxOverflowBubbles(1);
mBubbleData.dismissBubbleWithKey(mEntryA1.getKey(), Bubbles.DISMISS_USER_GESTURE);
verifyUpdateReceived();
assertOverflowChangedTo(ImmutableList.of(mBubbleA1));
// Overflow max of 1 is reached; A1 is oldest, so it gets removed
mBubbleData.dismissBubbleWithKey(mEntryA2.getKey(), Bubbles.DISMISS_USER_GESTURE);
verifyUpdateReceived();
assertOverflowChangedTo(ImmutableList.of(mBubbleA2));
}
/**
* Verifies that overflow bubbles are canceled on notif entry removal.
*/
@Test
public void testOverflow_notifCanceled_removesOverflowBubble() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryA2, 2000);
sendUpdatedEntryAtTime(mEntryA3, 3000);
sendUpdatedEntryAtTime(mEntryB1, 4000);
sendUpdatedEntryAtTime(mEntryB2, 5000);
sendUpdatedEntryAtTime(mEntryB3, 6000); // [A2, A3, B1, B2, B3], overflow: [A1]
sendUpdatedEntryAtTime(mEntryC1, 7000); // [A3, B1, B2, B3, C1], overflow: [A2, A1]
mBubbleData.setListener(mListener);
// Test
mBubbleData.dismissBubbleWithKey(mEntryA1.getKey(), Bubbles.DISMISS_NOTIF_CANCEL);
verifyUpdateReceived();
assertOverflowChangedTo(ImmutableList.of(mBubbleA2));
// Test
mBubbleData.dismissBubbleWithKey(mEntryA2.getKey(), Bubbles.DISMISS_GROUP_CANCELLED);
verifyUpdateReceived();
assertOverflowChangedTo(ImmutableList.of());
}
// COLLAPSED / ADD
/**
* Verifies that new bubbles insert to the left when collapsed.
* <p>
* Placement within the list is based on {@link Bubble#getLastActivity()}, descending
* order (with most recent first).
*/
@Test
public void test_collapsed_addBubble() {
// Setup
mBubbleData.setListener(mListener);
// Test
sendUpdatedEntryAtTime(mEntryA1, 1000);
verifyUpdateReceived();
assertOrderNotChanged();
sendUpdatedEntryAtTime(mEntryB1, 2000);
verifyUpdateReceived();
assertOrderChangedTo(mBubbleB1, mBubbleA1);
sendUpdatedEntryAtTime(mEntryB2, 3000);
verifyUpdateReceived();
assertOrderChangedTo(mBubbleB2, mBubbleB1, mBubbleA1);
sendUpdatedEntryAtTime(mEntryA2, 4000);
verifyUpdateReceived();
assertOrderChangedTo(mBubbleA2, mBubbleB2, mBubbleB1, mBubbleA1);
}
/**
* Verifies that new bubbles become the selected bubble when they appear when the stack is in
* the collapsed state.
*
* @see #test_collapsed_updateBubble_selectionChanges()
*/
@Test
public void test_collapsed_addBubble_selectionChanges() {
// Setup
mBubbleData.setListener(mListener);
// Test
sendUpdatedEntryAtTime(mEntryA1, 1000);
verifyUpdateReceived();
assertSelectionChangedTo(mBubbleA1);
sendUpdatedEntryAtTime(mEntryB1, 2000);
verifyUpdateReceived();
assertSelectionChangedTo(mBubbleB1);
sendUpdatedEntryAtTime(mEntryB2, 3000);
verifyUpdateReceived();
assertSelectionChangedTo(mBubbleB2);
sendUpdatedEntryAtTime(mEntryA2, 4000);
verifyUpdateReceived();
assertSelectionChangedTo(mBubbleA2);
}
// COLLAPSED / REMOVE
/**
* Verifies order of bubbles after a removal.
*/
@Test
public void test_collapsed_removeBubble_sort() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryB1, 2000);
sendUpdatedEntryAtTime(mEntryB2, 3000);
sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, B2, B1, A1]
mBubbleData.setListener(mListener);
// Test
mBubbleData.dismissBubbleWithKey(mEntryA2.getKey(), Bubbles.DISMISS_USER_GESTURE);
verifyUpdateReceived();
// TODO: this should fail if things work as I expect them to?
assertOrderChangedTo(mBubbleB2, mBubbleB1, mBubbleA1);
}
/**
* Verifies that onOrderChanged is not called when a bubble is removed if the removal does not
* cause other bubbles to change position.
*/
@Test
public void test_collapsed_removeOldestBubble_doesNotCallOnOrderChanged() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryB1, 2000);
sendUpdatedEntryAtTime(mEntryB2, 3000);
sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, B2, B1, A1]
mBubbleData.setListener(mListener);
// Test
mBubbleData.dismissBubbleWithKey(mEntryA1.getKey(), Bubbles.DISMISS_USER_GESTURE);
verifyUpdateReceived();
assertOrderNotChanged();
}
/**
* Verifies that when the selected bubble is removed with the stack in the collapsed state,
* the selection moves to the next most-recently updated bubble.
*/
@Test
public void test_collapsed_removeBubble_selectionChanges() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryB1, 2000);
sendUpdatedEntryAtTime(mEntryB2, 3000);
sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, B2, B1, A1]
mBubbleData.setListener(mListener);
// Test
mBubbleData.dismissBubbleWithKey(mEntryA2.getKey(), Bubbles.DISMISS_NOTIF_CANCEL);
verifyUpdateReceived();
assertSelectionChangedTo(mBubbleB2);
}
// COLLAPSED / UPDATE
/**
* Verifies that bubble ordering changes with updates while the stack is in the
* collapsed state.
*/
@Test
public void test_collapsed_updateBubble() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryB1, 2000);
sendUpdatedEntryAtTime(mEntryB2, 3000);
sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, B2, B1, A1]
mBubbleData.setListener(mListener);
// Test
sendUpdatedEntryAtTime(mEntryB1, 5000);
verifyUpdateReceived();
assertOrderChangedTo(mBubbleB1, mBubbleA2, mBubbleB2, mBubbleA1);
sendUpdatedEntryAtTime(mEntryA1, 6000);
verifyUpdateReceived();
assertOrderChangedTo(mBubbleA1, mBubbleB1, mBubbleA2, mBubbleB2);
}
/**
* Verifies that selection tracks the most recently updated bubble while in the collapsed state.
*/
@Test
public void test_collapsed_updateBubble_selectionChanges() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryB1, 2000);
sendUpdatedEntryAtTime(mEntryB2, 3000);
sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, B2, B1, A1]
mBubbleData.setListener(mListener);
// Test
sendUpdatedEntryAtTime(mEntryB1, 5000);
verifyUpdateReceived();
assertSelectionChangedTo(mBubbleB1);
sendUpdatedEntryAtTime(mEntryA1, 6000);
verifyUpdateReceived();
assertSelectionChangedTo(mBubbleA1);
}
/**
* Verifies that when a non visually interruptive update occurs, that the selection does not
* change.
*/
@Test
public void test_notVisuallyInterruptive_updateBubble_selectionDoesntChange() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryB1, 2000);
sendUpdatedEntryAtTime(mEntryB2, 3000);
sendUpdatedEntryAtTime(mEntryA2, 4000); // [A2, B2, B1, A1]
mBubbleData.setListener(mListener);
assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleA2);
// Test
sendUpdatedEntryAtTime(mEntryB1, 5000, false /* isVisuallyInterruptive */);
assertThat(mBubbleData.getSelectedBubble()).isEqualTo(mBubbleA2);
}
/**
* Verifies that a request to expand the stack has no effect if there are no bubbles.
*/
@Test
public void test_collapsed_expansion_whenEmpty_doesNothing() {
assertThat(mBubbleData.hasBubbles()).isFalse();
mBubbleData.setListener(mListener);
changeExpandedStateAtTime(true, 2000L);
verifyZeroInteractions(mListener);
}
/**
* Verifies that removing the last bubble clears the selected bubble and collapses the stack.
*/
@Test
public void test_collapsed_removeLastBubble_clearsSelectedBubble() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
mBubbleData.setListener(mListener);
// Test
mBubbleData.dismissBubbleWithKey(mEntryA1.getKey(), Bubbles.DISMISS_USER_GESTURE);
// Verify the selection was cleared.
verifyUpdateReceived();
assertThat(mBubbleData.isExpanded()).isFalse();
assertThat(mBubbleData.getSelectedBubble()).isNull();
}
// EXPANDED / ADD / UPDATE
/**
* Verifies that bubbles are added at the front of the stack.
* <p>
* Placement within the list is based on {@link Bubble#getLastActivity()}, descending
* order (with most recent first).
*
* @see #test_collapsed_addBubble()
*/
@Test
public void test_expanded_addBubble() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryA2, 2000);
sendUpdatedEntryAtTime(mEntryB1, 3000); // [B1, A2, A1]
changeExpandedStateAtTime(true, 4000L); // B1 marked updated at 4000L
mBubbleData.setListener(mListener);
// Test
sendUpdatedEntryAtTime(mEntryC1, 4000);
verifyUpdateReceived();
assertOrderChangedTo(mBubbleC1, mBubbleB1, mBubbleA2, mBubbleA1);
}
/**
* Verifies that updates to bubbles while expanded do not result in any change to sorting
* of bubbles.
*/
@Test
public void test_expanded_updateBubble_noChanges() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryA2, 2000);
sendUpdatedEntryAtTime(mEntryB1, 3000);
sendUpdatedEntryAtTime(mEntryB2, 4000); // [B2, B1, A2, A1]
changeExpandedStateAtTime(true, 5000L);
mBubbleData.setListener(mListener);
// Test
sendUpdatedEntryAtTime(mEntryA1, 4000);
verifyUpdateReceived();
assertOrderNotChanged();
}
/**
* Verifies that updates to bubbles while expanded do not result in any change to selection.
*
* @see #test_collapsed_addBubble_selectionChanges()
*/
@Test
public void test_expanded_updateBubble_noSelectionChanges() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryA2, 2000);
sendUpdatedEntryAtTime(mEntryB1, 3000);
sendUpdatedEntryAtTime(mEntryB2, 4000); // [B2, B1, A2, A1]
changeExpandedStateAtTime(true, 5000L);
mBubbleData.setListener(mListener);
// Test
sendUpdatedEntryAtTime(mEntryA1, 6000);
verifyUpdateReceived();
assertOrderNotChanged();
sendUpdatedEntryAtTime(mEntryA2, 7000);
verifyUpdateReceived();
assertOrderNotChanged();
sendUpdatedEntryAtTime(mEntryB1, 8000);
verifyUpdateReceived();
assertOrderNotChanged();
}
// EXPANDED / REMOVE
/**
* Verifies that removing a bubble while expanded does not result in reordering of bubbles.
*
* @see #test_collapsed_addBubble()
*/
@Test
public void test_expanded_removeBubble() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryB1, 2000);
sendUpdatedEntryAtTime(mEntryA2, 3000);
sendUpdatedEntryAtTime(mEntryB2, 4000); // [B2, A2, B1, A1]
changeExpandedStateAtTime(true, 5000L);
mBubbleData.setListener(mListener);
// Test
mBubbleData.dismissBubbleWithKey(mEntryB2.getKey(), Bubbles.DISMISS_USER_GESTURE);
verifyUpdateReceived();
assertOrderChangedTo(mBubbleA2, mBubbleB1, mBubbleA1);
}
/**
* Verifies that removing the selected bubble while expanded causes another bubble to become
* selected. The replacement selection is the bubble which appears at the same index as the
* previous one, or the previous index if this was the last position.
*
* @see #test_collapsed_addBubble()
*/
@Test
public void test_expanded_removeBubble_selectionChanges_whenSelectedRemoved() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryB1, 2000);
sendUpdatedEntryAtTime(mEntryA2, 3000);
sendUpdatedEntryAtTime(mEntryB2, 4000);
changeExpandedStateAtTime(true, 5000L);
mBubbleData.setSelectedBubble(mBubbleA2); // [B2, A2^, B1, A1]
mBubbleData.setListener(mListener);
// Test
mBubbleData.dismissBubbleWithKey(mEntryA2.getKey(), Bubbles.DISMISS_USER_GESTURE);
verifyUpdateReceived();
assertSelectionChangedTo(mBubbleB1);
mBubbleData.dismissBubbleWithKey(mEntryB1.getKey(), Bubbles.DISMISS_USER_GESTURE);
verifyUpdateReceived();
assertSelectionChangedTo(mBubbleA1);
}
@Test
public void test_expandAndCollapse_callsOnExpandedChanged() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
mBubbleData.setListener(mListener);
// Test
changeExpandedStateAtTime(true, 3000L);
verifyUpdateReceived();
assertExpandedChangedTo(true);
changeExpandedStateAtTime(false, 4000L);
verifyUpdateReceived();
assertExpandedChangedTo(false);
}
/**
* Verifies that transitions between the collapsed and expanded state maintain sorting and
* grouping rules.
* <p>
* While collapsing, sorting is applied since no sorting happens while expanded. The resulting
* state is the new expanded ordering. This state is saved and restored if possible when next
* expanded.
* <p>
* When the stack transitions to the collapsed state, the selected bubble is brought to the top.
* <p>
* When the stack transitions back to the expanded state, this new order is kept as is.
*/
@Test
public void test_expansionChanges() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryB1, 2000);
sendUpdatedEntryAtTime(mEntryA2, 3000);
sendUpdatedEntryAtTime(mEntryB2, 4000);
changeExpandedStateAtTime(true, 5000L); // [B2=4000, A2=3000, B1=2000, A1=1000]
sendUpdatedEntryAtTime(mEntryB1, 6000); // [B2=4000, A2=3000, B1=6000, A1=1000]
setCurrentTime(7000);
mBubbleData.setSelectedBubble(mBubbleA2);
mBubbleData.setListener(mListener);
assertThat(mBubbleData.getBubbles()).isEqualTo(
ImmutableList.of(mBubbleB2, mBubbleA2, mBubbleB1, mBubbleA1));
// Test
// At this point, B1 has been updated but sorting has not been changed because the
// stack is expanded. When next collapsed, sorting will be applied and saved, just prior
// to moving the selected bubble to the top (first).
//
// In this case, the expected re-expand state will be: [A2^, B1, B2, A1]
//
// collapse -> selected bubble (A2) moves first.
changeExpandedStateAtTime(false, 8000L);
verifyUpdateReceived();
assertOrderChangedTo(mBubbleA2, mBubbleB1, mBubbleB2, mBubbleA1);
}
/**
* When a change occurs while collapsed (any update, add, remove), the previous expanded
* order becomes invalidated, the stack is resorted and will reflect that when next expanded.
*/
@Test
public void test_expansionChanges_withUpdatesWhileCollapsed() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
sendUpdatedEntryAtTime(mEntryB1, 2000);
sendUpdatedEntryAtTime(mEntryA2, 3000);
sendUpdatedEntryAtTime(mEntryB2, 4000);
changeExpandedStateAtTime(true, 5000L); // [B2=4000, A2=3000, B1=2000, A1=1000]
sendUpdatedEntryAtTime(mEntryB1, 6000); // [B2=4000, A2=3000, B1=6000, A1=1000]
setCurrentTime(7000);
mBubbleData.setSelectedBubble(mBubbleA2); // [B2, A2^, B1, A1]
mBubbleData.setListener(mListener);
// Test
// At this point, B1 has been updated but sorting has not been changed because the
// stack is expanded. When next collapsed, sorting will be applied and saved, just prior
// to moving the selected bubble to the top (first).
//
// In this case, the expected re-expand state will be: [A2^, B1, B2, A1]
//
// That state is restored as long as no changes occur (add/remove/update) while in
// the collapsed state.
//
// collapse -> selected bubble (A2) moves first.
changeExpandedStateAtTime(false, 8000L);
verifyUpdateReceived();
assertOrderChangedTo(mBubbleA2, mBubbleB1, mBubbleB2, mBubbleA1);
// An update occurs, which causes sorting, and this invalidates the previously saved order.
sendUpdatedEntryAtTime(mEntryA1, 9000);
verifyUpdateReceived();
assertOrderChangedTo(mBubbleA1, mBubbleA2, mBubbleB1, mBubbleB2);
// No order changes when expanding because the new sorted order remains.
changeExpandedStateAtTime(true, 10000L);
verifyUpdateReceived();
assertOrderNotChanged();
}
@Test
public void test_expanded_removeLastBubble_collapsesStack() {
// Setup
sendUpdatedEntryAtTime(mEntryA1, 1000);
changeExpandedStateAtTime(true, 2000);
mBubbleData.setListener(mListener);
// Test
mBubbleData.dismissBubbleWithKey(mEntryA1.getKey(), Bubbles.DISMISS_USER_GESTURE);
verifyUpdateReceived();
assertExpandedChangedTo(false);
}
private void verifyUpdateReceived() {
verify(mListener).applyUpdate(mUpdateCaptor.capture());
reset(mListener);
}
private void assertBubbleAdded(Bubble expected) {
BubbleData.Update update = mUpdateCaptor.getValue();
assertWithMessage("addedBubble").that(update.addedBubble).isEqualTo(expected);
}
private void assertBubbleRemoved(Bubble expected, @BubbleController.DismissReason int reason) {
BubbleData.Update update = mUpdateCaptor.getValue();
assertWithMessage("removedBubbles").that(update.removedBubbles)
.isEqualTo(ImmutableList.of(Pair.create(expected, reason)));
}
private void assertOrderNotChanged() {
BubbleData.Update update = mUpdateCaptor.getValue();
assertWithMessage("orderChanged").that(update.orderChanged).isFalse();
}
private void assertOrderChangedTo(Bubble... order) {
BubbleData.Update update = mUpdateCaptor.getValue();
assertWithMessage("orderChanged").that(update.orderChanged).isTrue();
assertWithMessage("bubble order").that(update.bubbles)
.isEqualTo(ImmutableList.copyOf(order));
}
private void assertSelectionNotChanged() {
BubbleData.Update update = mUpdateCaptor.getValue();
assertWithMessage("selectionChanged").that(update.selectionChanged).isFalse();
}
private void assertSelectionChangedTo(Bubble bubble) {
BubbleData.Update update = mUpdateCaptor.getValue();
assertWithMessage("selectionChanged").that(update.selectionChanged).isTrue();
assertWithMessage("selectedBubble").that(update.selectedBubble).isEqualTo(bubble);
}
private void assertSelectionCleared() {
BubbleData.Update update = mUpdateCaptor.getValue();
assertWithMessage("selectionChanged").that(update.selectionChanged).isTrue();
assertWithMessage("selectedBubble").that(update.selectedBubble).isNull();
}
private void assertExpandedChangedTo(boolean expected) {
BubbleData.Update update = mUpdateCaptor.getValue();
assertWithMessage("expandedChanged").that(update.expandedChanged).isTrue();
assertWithMessage("expanded").that(update.expanded).isEqualTo(expected);
}
private void assertOverflowChangedTo(ImmutableList<Bubble> bubbles) {
BubbleData.Update update = mUpdateCaptor.getValue();
assertThat(update.overflowBubbles).isEqualTo(bubbles);
}
private BubbleEntry createBubbleEntry(int userId, String notifKey, String packageName,
NotificationListenerService.Ranking ranking) {
return createBubbleEntry(userId, notifKey, packageName, ranking, 1000);
}
private void setPostTime(BubbleEntry entry, long postTime) {
when(entry.getStatusBarNotification().getPostTime()).thenReturn(postTime);
}
/**
* No ExpandableNotificationRow is required to test BubbleData. This setup is all that is
* required for BubbleData functionality and verification. NotificationTestHelper is used only
* as a convenience to create a Notification w/BubbleMetadata.
*/
private BubbleEntry createBubbleEntry(int userId, String notifKey, String packageName,
NotificationListenerService.Ranking ranking, long postTime) {
// BubbleMetadata
Notification.BubbleMetadata bubbleMetadata = new Notification.BubbleMetadata.Builder(
mExpandIntent, Icon.createWithResource("", 0))
.setDeleteIntent(mDeleteIntent)
.build();
// Notification -> BubbleMetadata
Notification notification = mock(Notification.class);
notification.setBubbleMetadata(bubbleMetadata);
// Notification -> extras
notification.extras = new Bundle();
// StatusBarNotification
StatusBarNotification sbn = mock(StatusBarNotification.class);
when(sbn.getKey()).thenReturn(notifKey);
when(sbn.getUser()).thenReturn(new UserHandle(userId));
when(sbn.getPackageName()).thenReturn(packageName);
when(sbn.getPostTime()).thenReturn(postTime);
when(sbn.getNotification()).thenReturn(notification);
// NotificationEntry -> StatusBarNotification -> Notification -> BubbleMetadata
return new BubbleEntry(sbn, ranking, true, false, false, false);
}
private void setCurrentTime(long time) {
when(mTimeSource.currentTimeMillis()).thenReturn(time);
}
private void sendUpdatedEntryAtTime(BubbleEntry entry, long postTime) {
sendUpdatedEntryAtTime(entry, postTime, true /* visuallyInterruptive */);
}
private void sendUpdatedEntryAtTime(BubbleEntry entry, long postTime,
boolean visuallyInterruptive) {
setPostTime(entry, postTime);
// BubbleController calls this:
Bubble b = mBubbleData.getOrCreateBubble(entry, null /* persistedBubble */);
b.setVisuallyInterruptiveForTest(visuallyInterruptive);
// And then this
mBubbleData.notificationEntryUpdated(b, false /* suppressFlyout*/,
true /* showInShade */);
}
private void changeExpandedStateAtTime(boolean shouldBeExpanded, long time) {
setCurrentTime(time);
mBubbleData.setExpanded(shouldBeExpanded);
}
}

View File

@@ -0,0 +1,107 @@
/*
* 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 com.android.wm.shell.bubbles;
import static junit.framework.Assert.assertEquals;
import static junit.framework.Assert.assertNotSame;
import static org.mockito.Mockito.verify;
import android.graphics.Color;
import android.graphics.PointF;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper;
import android.view.View;
import android.widget.TextView;
import androidx.test.filters.SmallTest;
import com.android.wm.shell.R;
import com.android.wm.shell.ShellTestCase;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
@SmallTest
@RunWith(AndroidTestingRunner.class)
@TestableLooper.RunWithLooper(setAsMainLooper = true)
public class BubbleFlyoutViewTest extends ShellTestCase {
private BubbleFlyoutView mFlyout;
private TextView mFlyoutText;
private TextView mSenderName;
private float[] mDotCenter = new float[2];
private Bubble.FlyoutMessage mFlyoutMessage;
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
mFlyoutMessage = new Bubble.FlyoutMessage();
mFlyoutMessage.senderName = "Josh";
mFlyoutMessage.message = "Hello";
mFlyout = new BubbleFlyoutView(getContext());
mFlyoutText = mFlyout.findViewById(R.id.bubble_flyout_text);
mSenderName = mFlyout.findViewById(R.id.bubble_flyout_name);
mDotCenter[0] = 30;
mDotCenter[1] = 30;
}
@Test
public void testShowFlyout_isVisible() {
mFlyout.setupFlyoutStartingAsDot(
mFlyoutMessage,
new PointF(100, 100), 500, true, Color.WHITE, null, null, mDotCenter,
false);
mFlyout.setVisibility(View.VISIBLE);
assertEquals("Hello", mFlyoutText.getText());
assertEquals("Josh", mSenderName.getText());
assertEquals(View.VISIBLE, mFlyout.getVisibility());
}
@Test
public void testFlyoutHide_runsCallback() {
Runnable after = Mockito.mock(Runnable.class);
mFlyout.setupFlyoutStartingAsDot(mFlyoutMessage,
new PointF(100, 100), 500, true, Color.WHITE, null, after, mDotCenter,
false);
mFlyout.hideFlyout();
verify(after).run();
}
@Test
public void testSetCollapsePercent() {
mFlyout.setupFlyoutStartingAsDot(mFlyoutMessage,
new PointF(100, 100), 500, true, Color.WHITE, null, null, mDotCenter,
false);
mFlyout.setVisibility(View.VISIBLE);
mFlyout.setCollapsePercent(1f);
assertEquals(0f, mFlyoutText.getAlpha(), 0.01f);
assertNotSame(0f, mFlyoutText.getTranslationX()); // Should have moved to collapse.
mFlyout.setCollapsePercent(0f);
assertEquals(1f, mFlyoutText.getAlpha(), 0.01f);
assertEquals(0f, mFlyoutText.getTranslationX());
}
}

View File

@@ -0,0 +1,162 @@
/*
* 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 com.android.wm.shell.bubbles;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.Intent;
import android.graphics.drawable.Icon;
import android.os.Bundle;
import android.service.notification.StatusBarNotification;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper;
import androidx.test.filters.SmallTest;
import com.android.wm.shell.R;
import com.android.wm.shell.ShellTestCase;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
@SmallTest
@RunWith(AndroidTestingRunner.class)
@TestableLooper.RunWithLooper
public class BubbleTest extends ShellTestCase {
@Mock
private Notification mNotif;
@Mock
private StatusBarNotification mSbn;
private BubbleEntry mBubbleEntry;
private Bundle mExtras;
private Bubble mBubble;
@Mock
private Bubbles.NotificationSuppressionChangedListener mSuppressionListener;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
mExtras = new Bundle();
mNotif.extras = mExtras;
Intent target = new Intent(mContext, BubblesTestActivity.class);
Notification.BubbleMetadata metadata = new Notification.BubbleMetadata.Builder(
PendingIntent.getActivity(mContext, 0, target, 0),
Icon.createWithResource(mContext, R.drawable.bubble_ic_create_bubble))
.build();
when(mSbn.getNotification()).thenReturn(mNotif);
when(mNotif.getBubbleMetadata()).thenReturn(metadata);
when(mSbn.getKey()).thenReturn("mock");
mBubbleEntry = new BubbleEntry(mSbn, null, true, false, false, false);
mBubble = new Bubble(mBubbleEntry, mSuppressionListener, null);
}
@Test
public void testGetUpdateMessage_default() {
final String msg = "Hello there!";
doReturn(Notification.Style.class).when(mNotif).getNotificationStyle();
mExtras.putCharSequence(Notification.EXTRA_TEXT, msg);
assertEquals(msg, Bubble.extractFlyoutMessage(mBubbleEntry).message);
}
@Test
public void testGetUpdateMessage_bigText() {
final String msg = "A big hello there!";
doReturn(Notification.BigTextStyle.class).when(mNotif).getNotificationStyle();
mExtras.putCharSequence(Notification.EXTRA_TEXT, "A small hello there.");
mExtras.putCharSequence(Notification.EXTRA_BIG_TEXT, msg);
// Should be big text, not the small text.
assertEquals(msg, Bubble.extractFlyoutMessage(mBubbleEntry).message);
}
@Test
public void testGetUpdateMessage_media() {
doReturn(Notification.MediaStyle.class).when(mNotif).getNotificationStyle();
// Media notifs don't get update messages.
assertNull(Bubble.extractFlyoutMessage(mBubbleEntry).message);
}
@Test
public void testGetUpdateMessage_inboxStyle() {
doReturn(Notification.InboxStyle.class).when(mNotif).getNotificationStyle();
mExtras.putCharSequenceArray(
Notification.EXTRA_TEXT_LINES,
new CharSequence[]{
"How do you feel about tests?",
"They're okay, I guess.",
"I hate when they're flaky.",
"Really? I prefer them that way."});
// Should be the last one only.
assertEquals("Really? I prefer them that way.",
Bubble.extractFlyoutMessage(mBubbleEntry).message);
}
@Test
public void testGetUpdateMessage_messagingStyle() {
doReturn(Notification.MessagingStyle.class).when(mNotif).getNotificationStyle();
mExtras.putParcelableArray(
Notification.EXTRA_MESSAGES,
new Bundle[]{
new Notification.MessagingStyle.Message(
"Hello", 0, "Josh").toBundle(),
new Notification.MessagingStyle.Message(
"Oh, hello!", 0, "Mady").toBundle()});
// Should be the last one only.
assertEquals("Oh, hello!", Bubble.extractFlyoutMessage(mBubbleEntry).message);
assertEquals("Mady", Bubble.extractFlyoutMessage(mBubbleEntry).senderName);
}
@Test
public void testSuppressionListener_change_notified() {
assertThat(mBubble.showInShade()).isTrue();
mBubble.setSuppressNotification(true);
assertThat(mBubble.showInShade()).isFalse();
verify(mSuppressionListener).onBubbleNotificationSuppressionChange(mBubble);
}
@Test
public void testSuppressionListener_noChange_doesntNotify() {
assertThat(mBubble.showInShade()).isTrue();
mBubble.setSuppressNotification(false);
verify(mSuppressionListener, never()).onBubbleNotificationSuppressionChange(any());
}
}

View File

@@ -0,0 +1,40 @@
/*
* 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 com.android.wm.shell.bubbles;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import com.android.wm.shell.R;
/**
* Referenced by NotificationTestHelper#makeBubbleMetadata
*/
public class BubblesTestActivity extends Activity {
public static final String BUBBLE_ACTIVITY_OPENED = "BUBBLE_ACTIVITY_OPENED";
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
Intent i = new Intent(BUBBLE_ACTIVITY_OPENED);
sendBroadcast(i);
}
}

View File

@@ -0,0 +1,154 @@
/*
* 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 com.android.wm.shell.bubbles.animation;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import android.annotation.SuppressLint;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Insets;
import android.graphics.PointF;
import android.graphics.Rect;
import android.testing.AndroidTestingRunner;
import android.view.View;
import android.view.WindowManager;
import android.widget.FrameLayout;
import androidx.dynamicanimation.animation.DynamicAnimation;
import androidx.test.filters.SmallTest;
import com.android.wm.shell.R;
import com.android.wm.shell.bubbles.BubblePositioner;
import org.junit.Before;
import org.junit.Ignore;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Spy;
@SmallTest
@RunWith(AndroidTestingRunner.class)
public class ExpandedAnimationControllerTest extends PhysicsAnimationLayoutTestCase {
private int mDisplayWidth = 500;
private int mDisplayHeight = 1000;
private int mExpandedViewPadding = 10;
private Runnable mOnBubbleAnimatedOutAction = mock(Runnable.class);
@Spy
ExpandedAnimationController mExpandedController;
private int mStackOffset;
private PointF mExpansionPoint;
@SuppressLint("VisibleForTests")
@Before
public void setUp() throws Exception {
super.setUp();
BubblePositioner positioner = new BubblePositioner(getContext(), mock(WindowManager.class));
positioner.update(Configuration.ORIENTATION_PORTRAIT,
Insets.of(0, 0, 0, 0),
new Rect(0, 0, mDisplayWidth, mDisplayHeight));
mExpandedController = new ExpandedAnimationController(positioner, mExpandedViewPadding,
mOnBubbleAnimatedOutAction);
addOneMoreThanBubbleLimitBubbles();
mLayout.setActiveController(mExpandedController);
Resources res = mLayout.getResources();
mStackOffset = res.getDimensionPixelSize(R.dimen.bubble_stack_offset);
mExpansionPoint = new PointF(100, 100);
}
@Test
@Ignore
public void testExpansionAndCollapse() throws InterruptedException {
Runnable afterExpand = mock(Runnable.class);
mExpandedController.expandFromStack(afterExpand);
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
testBubblesInCorrectExpandedPositions();
verify(afterExpand).run();
Runnable afterCollapse = mock(Runnable.class);
mExpandedController.collapseBackToStack(mExpansionPoint, afterCollapse);
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
testStackedAtPosition(mExpansionPoint.x, mExpansionPoint.y, -1);
verify(afterExpand).run();
}
@Test
@Ignore
public void testOnChildAdded() throws InterruptedException {
expand();
// Add another new view and wait for its animation.
final View newView = new FrameLayout(getContext());
mLayout.addView(newView, 0);
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
testBubblesInCorrectExpandedPositions();
}
@Test
@Ignore
public void testOnChildRemoved() throws InterruptedException {
expand();
// Remove some views and see if the remaining child views still pass the expansion test.
mLayout.removeView(mViews.get(0));
mLayout.removeView(mViews.get(3));
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
testBubblesInCorrectExpandedPositions();
}
/** Expand the stack and wait for animations to finish. */
private void expand() throws InterruptedException {
mExpandedController.expandFromStack(mock(Runnable.class));
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
}
/** Check that children are in the correct positions for being stacked. */
private void testStackedAtPosition(float x, float y, int offsetMultiplier) {
// Make sure the rest of the stack moved again, including the first bubble not moving, and
// is stacked to the right now that we're on the right side of the screen.
for (int i = 0; i < mLayout.getChildCount(); i++) {
assertEquals(x + i * offsetMultiplier * mStackOffset,
mLayout.getChildAt(i).getTranslationX(), 2f);
assertEquals(y, mLayout.getChildAt(i).getTranslationY(), 2f);
assertEquals(1f, mLayout.getChildAt(i).getAlpha(), .01f);
}
}
/** Check that children are in the correct positions for being expanded. */
private void testBubblesInCorrectExpandedPositions() {
// Check all the visible bubbles to see if they're in the right place.
for (int i = 0; i < mLayout.getChildCount(); i++) {
float expectedPosition = mExpandedController.getBubbleXOrYForOrientation(i);
assertEquals(expectedPosition,
mLayout.getChildAt(i).getTranslationX(),
2f);
assertEquals(expectedPosition,
mLayout.getChildAt(i).getTranslationY(), 2f);
}
}
}

View File

@@ -0,0 +1,507 @@
/*
* 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 com.android.wm.shell.bubbles.animation;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import android.os.SystemClock;
import android.testing.AndroidTestingRunner;
import android.view.View;
import android.widget.FrameLayout;
import androidx.dynamicanimation.animation.DynamicAnimation;
import androidx.dynamicanimation.animation.SpringForce;
import androidx.test.filters.SmallTest;
import com.google.android.collect.Sets;
import org.junit.Before;
import org.junit.Ignore;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InOrder;
import org.mockito.Mockito;
import org.mockito.Spy;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
@SmallTest
@RunWith(AndroidTestingRunner.class)
/** Tests the PhysicsAnimationLayout itself, with a basic test animation controller. */
public class PhysicsAnimationLayoutTest extends PhysicsAnimationLayoutTestCase {
static final float TEST_TRANSLATION_X_OFFSET = 15f;
@Spy
private TestableAnimationController mTestableController = new TestableAnimationController();
@Before
public void setUp() throws Exception {
super.setUp();
// By default, use translation animations, chain the X animations with the default
// offset, and don't actually remove views immediately (since most implementations will wait
// to animate child views out before actually removing them).
mTestableController.setAnimatedProperties(Sets.newHashSet(
DynamicAnimation.TRANSLATION_X,
DynamicAnimation.TRANSLATION_Y));
mTestableController.setChainedProperties(Sets.newHashSet(DynamicAnimation.TRANSLATION_X));
mTestableController.setOffsetForProperty(
DynamicAnimation.TRANSLATION_X, TEST_TRANSLATION_X_OFFSET);
mTestableController.setRemoveImmediately(false);
}
@Test
@Ignore
public void testHierarchyChanges() throws InterruptedException {
mLayout.setActiveController(mTestableController);
addOneMoreThanBubbleLimitBubbles();
// Make sure the controller was notified of all the views we added.
for (View mView : mViews) {
Mockito.verify(mTestableController).onChildAdded(mView, 0);
}
// Remove some views and ensure the controller was notified, with the proper indices.
mTestableController.setRemoveImmediately(true);
mLayout.removeView(mViews.get(1));
mLayout.removeView(mViews.get(2));
Mockito.verify(mTestableController).onChildRemoved(
eq(mViews.get(1)), eq(1), any());
Mockito.verify(mTestableController).onChildRemoved(
eq(mViews.get(2)), eq(1), any());
// Make sure we still get view added notifications after doing some removals.
final View newBubble = new FrameLayout(mContext);
mLayout.addView(newBubble, 0);
Mockito.verify(mTestableController).onChildAdded(newBubble, 0);
}
@Test
@Ignore
public void testUpdateValueNotChained() throws InterruptedException {
mLayout.setActiveController(mTestableController);
addOneMoreThanBubbleLimitBubbles();
// Don't chain any values.
mTestableController.setChainedProperties(Sets.newHashSet());
// Child views should not be translated.
assertEquals(0, mLayout.getChildAt(0).getTranslationX(), .1f);
assertEquals(0, mLayout.getChildAt(1).getTranslationX(), .1f);
// Animate the first child's translation X.
final CountDownLatch animLatch = new CountDownLatch(1);
mTestableController
.animationForChildAtIndex(0)
.translationX(100)
.start(animLatch::countDown);
animLatch.await(1, TimeUnit.SECONDS);
// Ensure that the first view has been translated, but not the second one.
assertEquals(100, mLayout.getChildAt(0).getTranslationX(), .1f);
assertEquals(0, mLayout.getChildAt(1).getTranslationX(), .1f);
}
@Test
@Ignore
public void testUpdateValueXChained() throws InterruptedException {
testChainedTranslationAnimations();
}
@Test
@Ignore
public void testSetEndActions() throws InterruptedException {
mLayout.setActiveController(mTestableController);
addOneMoreThanBubbleLimitBubbles();
mTestableController.setChainedProperties(Sets.newHashSet());
final CountDownLatch xLatch = new CountDownLatch(1);
Runnable xEndAction = Mockito.spy(new Runnable() {
@Override
public void run() {
xLatch.countDown();
}
});
final CountDownLatch yLatch = new CountDownLatch(1);
Runnable yEndAction = Mockito.spy(new Runnable() {
@Override
public void run() {
yLatch.countDown();
}
});
// Set end listeners for both x and y.
mTestableController.setEndActionForProperty(xEndAction, DynamicAnimation.TRANSLATION_X);
mTestableController.setEndActionForProperty(yEndAction, DynamicAnimation.TRANSLATION_Y);
// Animate x, and wait for it to finish.
mTestableController.animationForChildAtIndex(0)
.translationX(100)
.start();
xLatch.await();
yLatch.await(1, TimeUnit.SECONDS);
// Make sure the x end listener was called only one time, and the y listener was never
// called since we didn't animate y. Wait 1 second after the original animation end trigger
// to make sure it doesn't get called again.
Mockito.verify(xEndAction, Mockito.after(1000).times(1)).run();
Mockito.verify(yEndAction, Mockito.after(1000).never()).run();
}
@Test
@Ignore
public void testRemoveEndListeners() throws InterruptedException {
mLayout.setActiveController(mTestableController);
addOneMoreThanBubbleLimitBubbles();
mTestableController.setChainedProperties(Sets.newHashSet());
final CountDownLatch xLatch = new CountDownLatch(1);
Runnable xEndListener = Mockito.spy(new Runnable() {
@Override
public void run() {
xLatch.countDown();
}
});
// Set the end listener.
mTestableController.setEndActionForProperty(xEndListener, DynamicAnimation.TRANSLATION_X);
// Animate x, and wait for it to finish.
mTestableController.animationForChildAtIndex(0)
.translationX(100)
.start();
xLatch.await();
InOrder endListenerCalls = inOrder(xEndListener);
endListenerCalls.verify(xEndListener, Mockito.times(1)).run();
// Animate X again, remove the end listener.
mTestableController.animationForChildAtIndex(0)
.translationX(1000)
.start();
mTestableController.removeEndActionForProperty(DynamicAnimation.TRANSLATION_X);
xLatch.await(1, TimeUnit.SECONDS);
// Make sure the end listener was not called.
endListenerCalls.verifyNoMoreInteractions();
}
@Test
@Ignore
public void testSetController() throws InterruptedException {
// Add the bubbles, then set the controller, to make sure that a controller added to an
// already-initialized view works correctly.
addOneMoreThanBubbleLimitBubbles();
mLayout.setActiveController(mTestableController);
testChainedTranslationAnimations();
TestableAnimationController secondController =
Mockito.spy(new TestableAnimationController());
secondController.setAnimatedProperties(Sets.newHashSet(
DynamicAnimation.SCALE_X, DynamicAnimation.SCALE_Y));
secondController.setChainedProperties(Sets.newHashSet(
DynamicAnimation.SCALE_X));
secondController.setOffsetForProperty(
DynamicAnimation.SCALE_X, 10f);
secondController.setRemoveImmediately(true);
mLayout.setActiveController(secondController);
mTestableController.animationForChildAtIndex(0)
.scaleX(1.5f)
.start();
waitForPropertyAnimations(DynamicAnimation.SCALE_X);
// Make sure we never asked the original controller about any SCALE animations, that would
// mean the controller wasn't switched over properly.
Mockito.verify(mTestableController, Mockito.never())
.getNextAnimationInChain(eq(DynamicAnimation.SCALE_X), anyInt());
Mockito.verify(mTestableController, Mockito.never())
.getOffsetForChainedPropertyAnimation(eq(DynamicAnimation.SCALE_X));
// Make sure we asked the new controller about its animated properties, and configuration
// options.
Mockito.verify(secondController, Mockito.atLeastOnce())
.getAnimatedProperties();
Mockito.verify(secondController, Mockito.atLeastOnce())
.getNextAnimationInChain(eq(DynamicAnimation.SCALE_X), anyInt());
Mockito.verify(secondController, Mockito.atLeastOnce())
.getOffsetForChainedPropertyAnimation(eq(DynamicAnimation.SCALE_X));
mLayout.setActiveController(mTestableController);
mTestableController.animationForChildAtIndex(0)
.translationX(100f)
.start();
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X);
// Make sure we never asked the second controller about the TRANSLATION_X animation.
Mockito.verify(secondController, Mockito.never())
.getNextAnimationInChain(eq(DynamicAnimation.TRANSLATION_X), anyInt());
Mockito.verify(secondController, Mockito.never())
.getOffsetForChainedPropertyAnimation(eq(DynamicAnimation.TRANSLATION_X));
}
@Test
@Ignore
public void testArePropertiesAnimating() throws InterruptedException {
mLayout.setActiveController(mTestableController);
addOneMoreThanBubbleLimitBubbles();
assertFalse(mLayout.arePropertiesAnimating(
DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y));
mTestableController.animationForChildAtIndex(0)
.translationX(100f)
.start();
// Wait for the animations to get underway.
SystemClock.sleep(50);
assertTrue(mLayout.arePropertiesAnimating(DynamicAnimation.TRANSLATION_X));
assertFalse(mLayout.arePropertiesAnimating(DynamicAnimation.TRANSLATION_Y));
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X);
assertFalse(mLayout.arePropertiesAnimating(
DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y));
}
@Test
@Ignore
public void testCancelAllAnimations() throws InterruptedException {
mLayout.setActiveController(mTestableController);
addOneMoreThanBubbleLimitBubbles();
mTestableController.animationForChildAtIndex(0)
.position(1000, 1000)
.start();
mLayout.cancelAllAnimations();
// Animations should be somewhere before their end point.
assertTrue(mViews.get(0).getTranslationX() < 1000);
assertTrue(mViews.get(0).getTranslationY() < 1000);
}
/** Standard test of chained translation animations. */
private void testChainedTranslationAnimations() throws InterruptedException {
mLayout.setActiveController(mTestableController);
addOneMoreThanBubbleLimitBubbles();
assertEquals(0, mLayout.getChildAt(0).getTranslationX(), .1f);
assertEquals(0, mLayout.getChildAt(1).getTranslationX(), .1f);
mTestableController.animationForChildAtIndex(0)
.translationX(100f)
.start();
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X);
for (int i = 0; i < mLayout.getChildCount(); i++) {
assertEquals(
100 + i * TEST_TRANSLATION_X_OFFSET,
mLayout.getChildAt(i).getTranslationX(), .1f);
}
// Ensure that the Y translations were unaffected.
assertEquals(0, mLayout.getChildAt(0).getTranslationY(), .1f);
assertEquals(0, mLayout.getChildAt(1).getTranslationY(), .1f);
// Animate the first child's Y translation.
mTestableController.animationForChildAtIndex(0)
.translationY(100f)
.start();
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_Y);
// Ensure that only the first view's Y translation chained, since we only chained X
// translations.
assertEquals(100, mLayout.getChildAt(0).getTranslationY(), .1f);
assertEquals(0, mLayout.getChildAt(1).getTranslationY(), .1f);
}
@Test
@Ignore
public void testPhysicsAnimator() throws InterruptedException {
mLayout.setActiveController(mTestableController);
addOneMoreThanBubbleLimitBubbles();
Runnable afterAll = Mockito.mock(Runnable.class);
Runnable after = Mockito.spy(new Runnable() {
int mCallCount = 0;
@Override
public void run() {
// Make sure that if only one of the animations has finished, we didn't already call
// afterAll.
if (mCallCount == 1) {
Mockito.verifyNoMoreInteractions(afterAll);
}
}
});
// Animate from x = 7 to x = 100, and from y = 100 to 7 = 200, calling 'after' after each
// property's animation completes, then call afterAll when they're all complete.
mTestableController.animationForChildAtIndex(0)
.translationX(7, 100, after)
.translationY(100, 200, after)
.start(afterAll);
// We should have immediately set the 'from' values.
assertEquals(7, mViews.get(0).getTranslationX(), .01f);
assertEquals(100, mViews.get(0).getTranslationY(), .01f);
waitForPropertyAnimations(
DynamicAnimation.TRANSLATION_X,
DynamicAnimation.TRANSLATION_Y);
// We should have called the after callback twice, and afterAll once. We verify in the
// mocked callback that afterAll isn't called before both finish.
Mockito.verify(after, times(2)).run();
Mockito.verify(afterAll).run();
// Make sure we actually animated the views.
assertEquals(100, mViews.get(0).getTranslationX(), .01f);
assertEquals(200, mViews.get(0).getTranslationY(), .01f);
}
@Test
@Ignore
public void testAnimationsForChildrenFromIndex() throws InterruptedException {
// Don't chain since we're going to invoke each animation independently.
mTestableController.setChainedProperties(new HashSet<>());
mLayout.setActiveController(mTestableController);
addOneMoreThanBubbleLimitBubbles();
Runnable allEnd = Mockito.mock(Runnable.class);
mTestableController.animationsForChildrenFromIndex(
1, (index, animation) -> animation.translationX((index - 1) * 50))
.startAll(allEnd);
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X);
assertEquals(0, mViews.get(0).getTranslationX(), .1f);
assertEquals(0, mViews.get(1).getTranslationX(), .1f);
assertEquals(50, mViews.get(2).getTranslationX(), .1f);
assertEquals(100, mViews.get(3).getTranslationX(), .1f);
Mockito.verify(allEnd, times(1)).run();
}
@Test
@Ignore
public void testAnimationsForChildrenFromIndex_noChildren() {
mLayout.setActiveController(mTestableController);
final Runnable after = Mockito.mock(Runnable.class);
mTestableController
.animationsForChildrenFromIndex(0, (index, animation) -> { })
.startAll(after);
verify(after, Mockito.times(1)).run();
}
/**
* Animation controller with configuration methods whose return values can be set by individual
* tests.
*/
private class TestableAnimationController
extends PhysicsAnimationLayout.PhysicsAnimationController {
private Set<DynamicAnimation.ViewProperty> mAnimatedProperties = new HashSet<>();
private Set<DynamicAnimation.ViewProperty> mChainedProperties = new HashSet<>();
private HashMap<DynamicAnimation.ViewProperty, Float> mOffsetForProperty = new HashMap<>();
private boolean mRemoveImmediately = false;
void setAnimatedProperties(
Set<DynamicAnimation.ViewProperty> animatedProperties) {
mAnimatedProperties = animatedProperties;
}
void setChainedProperties(
Set<DynamicAnimation.ViewProperty> chainedProperties) {
mChainedProperties = chainedProperties;
}
void setOffsetForProperty(
DynamicAnimation.ViewProperty property, float offset) {
mOffsetForProperty.put(property, offset);
}
public void setRemoveImmediately(boolean removeImmediately) {
mRemoveImmediately = removeImmediately;
}
@Override
Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
return mAnimatedProperties;
}
@Override
int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index) {
return mChainedProperties.contains(property) ? index + 1 : NONE;
}
@Override
float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property) {
return mOffsetForProperty.getOrDefault(property, 0f);
}
@Override
SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) {
return new SpringForce();
}
@Override
void onChildAdded(View child, int index) {}
@Override
void onChildRemoved(View child, int index, Runnable finishRemoval) {
if (mRemoveImmediately) {
finishRemoval.run();
}
}
@Override
void onChildReordered(View child, int oldIndex, int newIndex) {}
@Override
void onActiveControllerForLayout(PhysicsAnimationLayout layout) {}
}
}

View File

@@ -0,0 +1,324 @@
/*
* 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 com.android.wm.shell.bubbles.animation;
import static org.mockito.Mockito.when;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.view.DisplayCutout;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.widget.FrameLayout;
import androidx.dynamicanimation.animation.DynamicAnimation;
import androidx.dynamicanimation.animation.SpringForce;
import com.android.wm.shell.R;
import com.android.wm.shell.ShellTestCase;
import org.junit.Before;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* Test case for tests that involve the {@link PhysicsAnimationLayout}. This test case constructs a
* testable version of the layout, and provides some helpful methods to add views to the layout and
* wait for physics animations to finish running.
*
* See physics-animation-testing.md.
*/
public class PhysicsAnimationLayoutTestCase extends ShellTestCase {
TestablePhysicsAnimationLayout mLayout;
List<View> mViews = new ArrayList<>();
Handler mMainThreadHandler;
int mSystemWindowInsetSize = 50;
int mCutoutInsetSize = 100;
int mWidth = 1000;
int mHeight = 1000;
@Mock
private WindowInsets mWindowInsets;
@Mock
private DisplayCutout mCutout;
protected int mMaxBubbles;
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
mLayout = new TestablePhysicsAnimationLayout(mContext);
mLayout.setLeft(0);
mLayout.setRight(mWidth);
mLayout.setTop(0);
mLayout.setBottom(mHeight);
mMaxBubbles =
getContext().getResources().getInteger(R.integer.bubbles_max_rendered);
mMainThreadHandler = new Handler(Looper.getMainLooper());
when(mWindowInsets.getSystemWindowInsetTop()).thenReturn(mSystemWindowInsetSize);
when(mWindowInsets.getSystemWindowInsetBottom()).thenReturn(mSystemWindowInsetSize);
when(mWindowInsets.getSystemWindowInsetLeft()).thenReturn(mSystemWindowInsetSize);
when(mWindowInsets.getSystemWindowInsetRight()).thenReturn(mSystemWindowInsetSize);
when(mWindowInsets.getDisplayCutout()).thenReturn(mCutout);
when(mCutout.getSafeInsetTop()).thenReturn(mCutoutInsetSize);
when(mCutout.getSafeInsetBottom()).thenReturn(mCutoutInsetSize);
when(mCutout.getSafeInsetLeft()).thenReturn(mCutoutInsetSize);
when(mCutout.getSafeInsetRight()).thenReturn(mCutoutInsetSize);
}
/** Add one extra bubble over the limit, so we can make sure it's gone/chains appropriately. */
void addOneMoreThanBubbleLimitBubbles() throws InterruptedException {
for (int i = 0; i < mMaxBubbles + 1; i++) {
final View newView = new FrameLayout(mContext);
mLayout.addView(newView, 0);
mViews.add(0, newView);
newView.setTranslationX(0);
newView.setTranslationY(0);
}
}
/**
* Uses a {@link java.util.concurrent.CountDownLatch} to wait for the given properties'
* animations to finish before allowing the test to proceed.
*/
void waitForPropertyAnimations(DynamicAnimation.ViewProperty... properties)
throws InterruptedException {
final CountDownLatch animLatch = new CountDownLatch(properties.length);
for (DynamicAnimation.ViewProperty property : properties) {
mLayout.setTestEndActionForProperty(animLatch::countDown, property);
}
animLatch.await(2, TimeUnit.SECONDS);
}
/** Uses a latch to wait for the main thread message queue to finish. */
void waitForLayoutMessageQueue() throws InterruptedException {
CountDownLatch layoutLatch = new CountDownLatch(1);
mMainThreadHandler.post(layoutLatch::countDown);
layoutLatch.await(2, TimeUnit.SECONDS);
}
/**
* Testable subclass of the PhysicsAnimationLayout that ensures methods that trigger animations
* are run on the main thread, which is a requirement of DynamicAnimation.
*/
protected class TestablePhysicsAnimationLayout extends PhysicsAnimationLayout {
public TestablePhysicsAnimationLayout(Context context) {
super(context);
}
@Override
protected boolean isActiveController(PhysicsAnimationController controller) {
// Return true since otherwise all test controllers will be seen as inactive since they
// are wrapped by MainThreadAnimationControllerWrapper.
return true;
}
@Override
public boolean post(Runnable action) {
return mMainThreadHandler.post(action);
}
@Override
public boolean postDelayed(Runnable action, long delayMillis) {
return mMainThreadHandler.postDelayed(action, delayMillis);
}
@Override
public void setActiveController(PhysicsAnimationController controller) {
runOnMainThreadAndBlock(
() -> super.setActiveController(
new MainThreadAnimationControllerWrapper(controller)));
}
@Override
public void cancelAllAnimations() {
mMainThreadHandler.post(super::cancelAllAnimations);
}
@Override
public void cancelAnimationsOnView(View view) {
mMainThreadHandler.post(() -> super.cancelAnimationsOnView(view));
}
@Override
public WindowInsets getRootWindowInsets() {
return mWindowInsets;
}
@Override
public void addView(View child, int index) {
child.setTag(R.id.physics_animator_tag, new TestablePhysicsPropertyAnimator(child));
super.addView(child, index);
}
@Override
public void addView(View child, int index, ViewGroup.LayoutParams params) {
child.setTag(R.id.physics_animator_tag, new TestablePhysicsPropertyAnimator(child));
super.addView(child, index, params);
}
/**
* Sets an end action that will be called after the 'real' end action that was already set.
*/
private void setTestEndActionForProperty(
Runnable action, DynamicAnimation.ViewProperty property) {
final Runnable realEndAction = mEndActionForProperty.get(property);
mLayout.mEndActionForProperty.put(property, () -> {
if (realEndAction != null) {
realEndAction.run();
}
action.run();
});
}
/** PhysicsPropertyAnimator that posts its animations to the main thread. */
protected class TestablePhysicsPropertyAnimator extends PhysicsPropertyAnimator {
public TestablePhysicsPropertyAnimator(View view) {
super(view);
}
@Override
protected void animateValueForChild(DynamicAnimation.ViewProperty property, View view,
float value, float startVel, long startDelay, float stiffness,
float dampingRatio, Runnable[] afterCallbacks) {
mMainThreadHandler.post(() -> super.animateValueForChild(
property, view, value, startVel, startDelay, stiffness, dampingRatio,
afterCallbacks));
}
@Override
protected void startPathAnimation() {
mMainThreadHandler.post(super::startPathAnimation);
}
}
/**
* Wrapper around an animation controller that dispatches methods that could start
* animations to the main thread.
*/
protected class MainThreadAnimationControllerWrapper extends PhysicsAnimationController {
private final PhysicsAnimationController mWrappedController;
protected MainThreadAnimationControllerWrapper(PhysicsAnimationController controller) {
mWrappedController = controller;
}
@Override
protected void setLayout(PhysicsAnimationLayout layout) {
mWrappedController.setLayout(layout);
}
@Override
protected PhysicsAnimationLayout getLayout() {
return mWrappedController.getLayout();
}
@Override
Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
return mWrappedController.getAnimatedProperties();
}
@Override
int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index) {
return mWrappedController.getNextAnimationInChain(property, index);
}
@Override
float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property) {
return mWrappedController.getOffsetForChainedPropertyAnimation(property);
}
@Override
SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) {
return mWrappedController.getSpringForce(property, view);
}
@Override
void onChildAdded(View child, int index) {
runOnMainThreadAndBlock(() -> mWrappedController.onChildAdded(child, index));
}
@Override
void onChildRemoved(View child, int index, Runnable finishRemoval) {
runOnMainThreadAndBlock(
() -> mWrappedController.onChildRemoved(child, index, finishRemoval));
}
@Override
void onChildReordered(View child, int oldIndex, int newIndex) {
runOnMainThreadAndBlock(
() -> mWrappedController.onChildReordered(child, oldIndex, newIndex));
}
@Override
void onActiveControllerForLayout(PhysicsAnimationLayout layout) {
runOnMainThreadAndBlock(
() -> mWrappedController.onActiveControllerForLayout(layout));
}
@Override
protected PhysicsPropertyAnimator animationForChild(View child) {
PhysicsPropertyAnimator animator =
(PhysicsPropertyAnimator) child.getTag(R.id.physics_animator_tag);
if (!(animator instanceof TestablePhysicsPropertyAnimator)) {
animator = new TestablePhysicsPropertyAnimator(child);
child.setTag(R.id.physics_animator_tag, animator);
}
return animator;
}
}
}
/**
* Posts the given Runnable on the main thread, and blocks the calling thread until it's run.
*/
private void runOnMainThreadAndBlock(Runnable action) {
final CountDownLatch latch = new CountDownLatch(1);
mMainThreadHandler.post(() -> {
action.run();
latch.countDown();
});
try {
latch.await(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

View File

@@ -0,0 +1,333 @@
/*
* 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 com.android.wm.shell.bubbles.animation;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import android.graphics.PointF;
import android.testing.AndroidTestingRunner;
import android.view.View;
import android.widget.FrameLayout;
import androidx.dynamicanimation.animation.DynamicAnimation;
import androidx.dynamicanimation.animation.SpringForce;
import androidx.test.filters.SmallTest;
import com.android.wm.shell.R;
import com.android.wm.shell.bubbles.BubblePositioner;
import com.android.wm.shell.common.FloatingContentCoordinator;
import org.junit.Before;
import org.junit.Ignore;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.function.IntSupplier;
@SmallTest
@RunWith(AndroidTestingRunner.class)
public class StackAnimationControllerTest extends PhysicsAnimationLayoutTestCase {
@Mock
private FloatingContentCoordinator mFloatingContentCoordinator;
private TestableStackController mStackController;
private int mStackOffset;
private Runnable mCheckStartPosSet;
@Before
public void setUp() throws Exception {
super.setUp();
mStackController = spy(new TestableStackController(
mFloatingContentCoordinator, new IntSupplier() {
@Override
public int getAsInt() {
return mLayout.getChildCount();
}
}, mock(Runnable.class)));
mLayout.setActiveController(mStackController);
addOneMoreThanBubbleLimitBubbles();
mStackOffset = mLayout.getResources().getDimensionPixelSize(R.dimen.bubble_stack_offset);
}
/**
* Test moving around the stack, and make sure the position is updated correctly, and the stack
* direction is correct.
*/
@Test
@Ignore("Flaking")
public void testMoveFirstBubbleWithStackFollowing() throws InterruptedException {
mStackController.moveFirstBubbleWithStackFollowing(200, 100);
// The first bubble should have moved instantly, the rest should be waiting for animation.
assertEquals(200, mViews.get(0).getTranslationX(), .1f);
assertEquals(100, mViews.get(0).getTranslationY(), .1f);
assertEquals(0, mViews.get(1).getTranslationX(), .1f);
assertEquals(0, mViews.get(1).getTranslationY(), .1f);
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
// Make sure the rest of the stack got moved to the right place and is stacked to the left.
testStackedAtPosition(200, 100, -1);
assertEquals(new PointF(200, 100), mStackController.getStackPosition());
mStackController.moveFirstBubbleWithStackFollowing(1000, 500);
// The first bubble again should have moved instantly while the rest remained where they
// were until the animation takes over.
assertEquals(1000, mViews.get(0).getTranslationX(), .1f);
assertEquals(500, mViews.get(0).getTranslationY(), .1f);
assertEquals(200 + -mStackOffset, mViews.get(1).getTranslationX(), .1f);
assertEquals(100, mViews.get(1).getTranslationY(), .1f);
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
// Make sure the rest of the stack moved again, including the first bubble not moving, and
// is stacked to the right now that we're on the right side of the screen.
testStackedAtPosition(1000, 500, 1);
assertEquals(new PointF(1000, 500), mStackController.getStackPosition());
}
@Test
@Ignore("Sporadically failing due to DynamicAnimation not settling.")
public void testFlingSideways() throws InterruptedException {
// Hard fling directly upwards, no X velocity. The X fling should terminate pretty much
// immediately, and spring to 0f, the y fling is hard enough that it will overshoot the top
// but should bounce back down.
mStackController.flingThenSpringFirstBubbleWithStackFollowing(
DynamicAnimation.TRANSLATION_X,
5000f, 1.15f, new SpringForce(), mWidth * 1f);
mStackController.flingThenSpringFirstBubbleWithStackFollowing(
DynamicAnimation.TRANSLATION_Y,
0f, 1.15f, new SpringForce(), 0f);
// Nothing should move initially since the animations haven't begun, including the first
// view.
assertEquals(0f, mViews.get(0).getTranslationX(), 1f);
assertEquals(0f, mViews.get(0).getTranslationY(), 1f);
// Wait for the flinging.
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X,
DynamicAnimation.TRANSLATION_Y);
// Wait for the springing.
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X,
DynamicAnimation.TRANSLATION_Y);
// Once the dust has settled, we should have flung all the way to the right side, with the
// stack stacked off to the right now.
testStackedAtPosition(mWidth * 1f, 0f, 1);
}
@Test
@Ignore("Sporadically failing due to DynamicAnimation not settling.")
public void testFlingUpFromBelowBottomCenter() throws InterruptedException {
// Move to the center of the screen, just past the bottom.
mStackController.moveFirstBubbleWithStackFollowing(mWidth / 2f, mHeight + 100);
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
// Hard fling directly upwards, no X velocity. The X fling should terminate pretty much
// immediately, and spring to 0f, the y fling is hard enough that it will overshoot the top
// but should bounce back down.
mStackController.flingThenSpringFirstBubbleWithStackFollowing(
DynamicAnimation.TRANSLATION_X,
0, 1.15f, new SpringForce(), 27f);
mStackController.flingThenSpringFirstBubbleWithStackFollowing(
DynamicAnimation.TRANSLATION_Y,
5000f, 1.15f, new SpringForce(), 27f);
// Nothing should move initially since the animations haven't begun.
assertEquals(mWidth / 2f, mViews.get(0).getTranslationX(), .1f);
assertEquals(mHeight + 100, mViews.get(0).getTranslationY(), .1f);
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X,
DynamicAnimation.TRANSLATION_Y);
// Once the dust has settled, we should have flung a bit but then sprung to the final
// destination which is (27, 27).
testStackedAtPosition(27, 27, -1);
}
@Test
@Ignore("Flaking")
public void testChildAdded() throws InterruptedException {
// Move the stack to y = 500.
mStackController.moveFirstBubbleWithStackFollowing(0f, 500f);
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X,
DynamicAnimation.TRANSLATION_Y);
final View newView = new FrameLayout(mContext);
mLayout.addView(
newView,
0,
new FrameLayout.LayoutParams(50, 50));
waitForStartPosToBeSet();
waitForLayoutMessageQueue();
waitForPropertyAnimations(
DynamicAnimation.TRANSLATION_X,
DynamicAnimation.TRANSLATION_Y,
DynamicAnimation.SCALE_X,
DynamicAnimation.SCALE_Y);
// The new view should be at the top of the stack, in the correct position.
assertEquals(0f, newView.getTranslationX(), .1f);
assertEquals(500f, newView.getTranslationY(), .1f);
assertEquals(1f, newView.getScaleX(), .1f);
assertEquals(1f, newView.getScaleY(), .1f);
assertEquals(1f, newView.getAlpha(), .1f);
}
@Test
@Ignore("Occasionally flakes, ignoring pending investigation.")
public void testChildRemoved() throws InterruptedException {
assertEquals(0, mLayout.getTransientViewCount());
final View firstView = mLayout.getChildAt(0);
mLayout.removeView(firstView);
// The view should now be transient, and missing from the view's normal hierarchy.
assertEquals(1, mLayout.getTransientViewCount());
assertEquals(-1, mLayout.indexOfChild(firstView));
waitForPropertyAnimations(DynamicAnimation.ALPHA);
waitForLayoutMessageQueue();
// The view should now be gone entirely, no transient views left.
assertEquals(0, mLayout.getTransientViewCount());
// The subsequent view should have been translated over to 0, not stacked off to the left.
assertEquals(0, mLayout.getChildAt(0).getTranslationX(), .1f);
}
@Test
@Ignore("Flaky")
public void testRestoredAtRestingPosition() throws InterruptedException {
mStackController.flingStackThenSpringToEdge(0, 5000, 5000);
waitForPropertyAnimations(
DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
waitForLayoutMessageQueue();
final PointF prevStackPos = mStackController.getStackPosition();
mLayout.removeAllViews();
waitForLayoutMessageQueue();
mLayout.addView(new FrameLayout(getContext()));
waitForLayoutMessageQueue();
waitForPropertyAnimations(
DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
assertEquals(prevStackPos, mStackController.getStackPosition());
}
@Test
public void testFloatingCoordinator() {
// We should have called onContentAdded only once while adding all of the bubbles in
// setup().
verify(mFloatingContentCoordinator, times(1)).onContentAdded(any());
verify(mFloatingContentCoordinator, never()).onContentRemoved(any());
// Remove all views and verify that we called onContentRemoved only once.
while (mLayout.getChildCount() > 0) {
mLayout.removeView(mLayout.getChildAt(0));
}
verify(mFloatingContentCoordinator, times(1)).onContentRemoved(any());
}
/**
* Checks every child view to make sure it's stacked at the given coordinates, off to the left
* or right side depending on offset multiplier.
*/
private void testStackedAtPosition(float x, float y, int offsetMultiplier) {
// Make sure the rest of the stack moved again, including the first bubble not moving, and
// is stacked to the right now that we're on the right side of the screen.
for (int i = 0; i < mLayout.getChildCount(); i++) {
assertEquals(x + i * offsetMultiplier * mStackOffset,
mViews.get(i).getTranslationX(), 2f);
assertEquals(y, mViews.get(i).getTranslationY(), 2f);
}
}
/** Waits up to 2 seconds for the initial stack position to be initialized. */
private void waitForStartPosToBeSet() throws InterruptedException {
final CountDownLatch animLatch = new CountDownLatch(1);
mCheckStartPosSet = () -> {
if (mStackController.getStackPosition().x >= 0) {
animLatch.countDown();
} else {
mMainThreadHandler.post(mCheckStartPosSet);
}
};
mMainThreadHandler.post(mCheckStartPosSet);
try {
animLatch.await(2, TimeUnit.SECONDS);
} catch (InterruptedException e) {
mMainThreadHandler.removeCallbacks(mCheckStartPosSet);
throw e;
}
}
/**
* Testable version of the stack controller that dispatches its animations on the main thread.
*/
private class TestableStackController extends StackAnimationController {
TestableStackController(
FloatingContentCoordinator floatingContentCoordinator,
IntSupplier bubbleCountSupplier,
Runnable onBubbleAnimatedOutAction) {
super(floatingContentCoordinator,
bubbleCountSupplier,
onBubbleAnimatedOutAction,
mock(BubblePositioner.class));
}
@Override
protected void flingThenSpringFirstBubbleWithStackFollowing(
DynamicAnimation.ViewProperty property, float vel, float friction,
SpringForce spring, Float finalPosition) {
mMainThreadHandler.post(() ->
super.flingThenSpringFirstBubbleWithStackFollowing(
property, vel, friction, spring, finalPosition));
}
@Override
protected void springFirstBubbleWithStackFollowing(DynamicAnimation.ViewProperty property,
SpringForce spring, float vel, float finalPosition, Runnable... after) {
mMainThreadHandler.post(() ->
super.springFirstBubbleWithStackFollowing(
property, spring, vel, finalPosition, after));
}
}
}

View File

@@ -0,0 +1,55 @@
/*
* 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 com.android.wm.shell.bubbles.storage
import android.testing.AndroidTestingRunner
import androidx.test.filters.SmallTest
import com.android.wm.shell.ShellTestCase
import junit.framework.Assert.assertEquals
import junit.framework.Assert.assertNotNull
import junit.framework.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@SmallTest
@RunWith(AndroidTestingRunner::class)
class BubblePersistentRepositoryTest : ShellTestCase() {
private val bubbles = listOf(
BubbleEntity(0, "com.example.messenger", "shortcut-1", "key-1", 120, 0),
BubbleEntity(10, "com.example.chat", "alice and bob", "key-2", 0, 16537428, "title"),
BubbleEntity(0, "com.example.messenger", "shortcut-2", "key-3", 120, 0)
)
private lateinit var repository: BubblePersistentRepository
@Before
fun setup() {
repository = BubblePersistentRepository(mContext)
}
@Test
fun testReadWriteOperation() {
// Verify read before write doesn't cause FileNotFoundException
val actual = repository.readFromDisk()
assertNotNull(actual)
assertTrue(actual.isEmpty())
repository.persistsToDisk(bubbles)
assertEquals(bubbles, repository.readFromDisk())
}
}

View File

@@ -0,0 +1,121 @@
/*
* 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 com.android.wm.shell.bubbles.storage
import android.content.pm.LauncherApps
import android.os.UserHandle
import android.testing.AndroidTestingRunner
import androidx.test.filters.SmallTest
import com.android.systemui.util.mockito.eq
import com.android.wm.shell.ShellTestCase
import junit.framework.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoMoreInteractions
@SmallTest
@RunWith(AndroidTestingRunner::class)
class BubbleVolatileRepositoryTest : ShellTestCase() {
private val user0 = UserHandle.of(0)
private val user10 = UserHandle.of(10)
private val bubble1 = BubbleEntity(0, "com.example.messenger", "shortcut-1", "key-1", 120, 0)
private val bubble2 = BubbleEntity(10, "com.example.chat", "alice and bob",
"key-2", 0, 16537428, "title")
private val bubble3 = BubbleEntity(0, "com.example.messenger", "shortcut-2", "key-3", 120, 0)
private val bubbles = listOf(bubble1, bubble2, bubble3)
private lateinit var repository: BubbleVolatileRepository
private lateinit var launcherApps: LauncherApps
@Before
fun setup() {
launcherApps = mock(LauncherApps::class.java)
repository = BubbleVolatileRepository(launcherApps)
}
@Test
fun testAddBubbles() {
repository.addBubbles(bubbles)
assertEquals(bubbles, repository.bubbles)
verify(launcherApps).cacheShortcuts(eq(PKG_MESSENGER),
eq(listOf("shortcut-1", "shortcut-2")), eq(user0),
eq(LauncherApps.FLAG_CACHE_BUBBLE_SHORTCUTS))
verify(launcherApps).cacheShortcuts(eq(PKG_CHAT),
eq(listOf("alice and bob")), eq(user10),
eq(LauncherApps.FLAG_CACHE_BUBBLE_SHORTCUTS))
repository.addBubbles(listOf(bubble1))
assertEquals(listOf(bubble2, bubble3, bubble1), repository.bubbles)
verifyNoMoreInteractions(launcherApps)
}
@Test
fun testRemoveBubbles() {
repository.addBubbles(bubbles)
assertEquals(bubbles, repository.bubbles)
repository.removeBubbles(listOf(bubble3))
assertEquals(listOf(bubble1, bubble2), repository.bubbles)
verify(launcherApps).uncacheShortcuts(eq(PKG_MESSENGER),
eq(listOf("shortcut-2")), eq(user0),
eq(LauncherApps.FLAG_CACHE_BUBBLE_SHORTCUTS))
}
@Test
fun testAddAndRemoveBubblesWhenExceedingCapacity() {
repository.capacity = 2
// push bubbles beyond capacity
repository.addBubbles(bubbles)
// verify it is trim down to capacity
assertEquals(listOf(bubble2, bubble3), repository.bubbles)
verify(launcherApps).cacheShortcuts(eq(PKG_MESSENGER),
eq(listOf("shortcut-2")), eq(user0),
eq(LauncherApps.FLAG_CACHE_BUBBLE_SHORTCUTS))
verify(launcherApps).cacheShortcuts(eq(PKG_CHAT),
eq(listOf("alice and bob")), eq(user10),
eq(LauncherApps.FLAG_CACHE_BUBBLE_SHORTCUTS))
repository.addBubbles(listOf(bubble1))
// verify the oldest bubble is popped
assertEquals(listOf(bubble3, bubble1), repository.bubbles)
verify(launcherApps).uncacheShortcuts(eq(PKG_CHAT),
eq(listOf("alice and bob")), eq(user10),
eq(LauncherApps.FLAG_CACHE_BUBBLE_SHORTCUTS))
}
@Test
fun testAddBubbleMatchesByKey() {
val bubble = BubbleEntity(0, "com.example.pkg", "shortcut-id", "key", 120, 0, "title")
repository.addBubbles(listOf(bubble))
assertEquals(bubble, repository.bubbles.get(0))
// Same key as first bubble but different entry
val bubbleModified = BubbleEntity(0, "com.example.pkg", "shortcut-id", "key", 120, 0,
"different title")
repository.addBubbles(listOf(bubbleModified))
assertEquals(bubbleModified, repository.bubbles.get(0))
}
}
private const val PKG_MESSENGER = "com.example.messenger"
private const val PKG_CHAT = "com.example.chat"

View File

@@ -0,0 +1,82 @@
/*
* 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 com.android.wm.shell.bubbles.storage
import android.testing.AndroidTestingRunner
import androidx.test.filters.SmallTest
import com.android.wm.shell.ShellTestCase
import junit.framework.Assert.assertEquals
import junit.framework.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
@SmallTest
@RunWith(AndroidTestingRunner::class)
class BubbleXmlHelperTest : ShellTestCase() {
private val bubbles = listOf(
BubbleEntity(0, "com.example.messenger", "shortcut-1", "k1", 120, 0),
BubbleEntity(10, "com.example.chat", "alice and bob", "k2", 0, 16537428, "title"),
BubbleEntity(0, "com.example.messenger", "shortcut-2", "k3", 120, 0)
)
@Test
fun testWriteXml() {
val expectedEntries = """
<bb uid="0" pkg="com.example.messenger" sid="shortcut-1" key="k1" h="120" hid="0" />
<bb uid="10" pkg="com.example.chat" sid="alice and bob" key="k2" h="0" hid="16537428" t="title" />
<bb uid="0" pkg="com.example.messenger" sid="shortcut-2" key="k3" h="120" hid="0" />
""".trimIndent()
ByteArrayOutputStream().use {
writeXml(it, bubbles)
val actual = it.toString()
assertTrue("cannot find expected entry in \n$actual",
actual.contains(expectedEntries))
}
}
@Test
fun testReadXml() {
val src = """
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<bs v="1">
<bb uid="0" pkg="com.example.messenger" sid="shortcut-1" key="k1" h="120" hid="0" />
<bb uid="10" pkg="com.example.chat" sid="alice and bob" key="k2" h="0" hid="16537428" t="title" />
<bb uid="0" pkg="com.example.messenger" sid="shortcut-2" key="k3" h="120" hid="0" />
</bs>
""".trimIndent()
val actual = readXml(ByteArrayInputStream(src.toByteArray(Charsets.UTF_8)))
assertEquals("failed parsing bubbles from xml\n$src", bubbles, actual)
}
// TODO: We should handle upgrades gracefully but this is v1
@Test
fun testUpgradeDropsPreviousData() {
val src = """
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<bs>
<bb uid="0" pkg="com.example.messenger" sid="shortcut-1" key="k1" h="120" hid="0" />
<bb uid="10" pkg="com.example.chat" sid="alice and bob" key="k2" h="0" hid="16537428" t="title" />
<bb uid="0" pkg="com.example.messenger" sid="shortcut-2" key="k3" h="120" hid="0" />
</bs>
""".trimIndent()
val actual = readXml(ByteArrayInputStream(src.toByteArray(Charsets.UTF_8)))
assertEquals("failed parsing bubbles from xml\n$src", emptyList<BubbleEntity>(), actual)
}
}

View File

@@ -32,9 +32,7 @@ import android.view.SurfaceControl;
import androidx.test.filters.SmallTest;
import com.android.wm.shell.pip.PipAnimationController;
import com.android.wm.shell.pip.PipSurfaceTransactionHelper;
import com.android.wm.shell.pip.PipTestCase;
import com.android.wm.shell.ShellTestCase;
import org.junit.Before;
import org.junit.Test;
@@ -49,7 +47,7 @@ import org.mockito.MockitoAnnotations;
@RunWith(AndroidTestingRunner.class)
@SmallTest
@TestableLooper.RunWithLooper(setAsMainLooper = true)
public class PipAnimationControllerTest extends PipTestCase {
public class PipAnimationControllerTest extends ShellTestCase {
private PipAnimationController mPipAnimationController;

View File

@@ -30,8 +30,7 @@ import android.view.Gravity;
import androidx.test.filters.SmallTest;
import com.android.wm.shell.pip.PipBoundsHandler;
import com.android.wm.shell.pip.PipTestCase;
import com.android.wm.shell.ShellTestCase;
import org.junit.Before;
import org.junit.Test;
@@ -46,7 +45,7 @@ import org.junit.runner.RunWith;
@RunWith(AndroidTestingRunner.class)
@SmallTest
@TestableLooper.RunWithLooper(setAsMainLooper = true)
public class PipBoundsHandlerTest extends PipTestCase {
public class PipBoundsHandlerTest extends ShellTestCase {
private static final int ROUNDING_ERROR_MARGIN = 16;
private static final float ASPECT_RATIO_ERROR_MARGIN = 0.01f;
private static final float DEFAULT_ASPECT_RATIO = 1f;

View File

@@ -28,6 +28,8 @@ import android.util.Size;
import androidx.test.filters.SmallTest;
import com.android.wm.shell.ShellTestCase;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -38,7 +40,7 @@ import org.junit.runner.RunWith;
@RunWith(AndroidTestingRunner.class)
@TestableLooper.RunWithLooper
@SmallTest
public class PipBoundsStateTest extends PipTestCase {
public class PipBoundsStateTest extends ShellTestCase {
private static final Rect DEFAULT_BOUNDS = new Rect(0, 0, 10, 10);
private static final float DEFAULT_SNAP_FRACTION = 1.0f;

View File

@@ -40,6 +40,7 @@ import android.view.DisplayInfo;
import android.window.WindowContainerToken;
import com.android.wm.shell.ShellTaskOrganizer;
import com.android.wm.shell.ShellTestCase;
import com.android.wm.shell.common.DisplayController;
import com.android.wm.shell.pip.phone.PipMenuActivityController;
import com.android.wm.shell.splitscreen.SplitScreen;
@@ -58,7 +59,7 @@ import java.util.Optional;
@SmallTest
@RunWith(AndroidTestingRunner.class)
@TestableLooper.RunWithLooper
public class PipTaskOrganizerTest extends PipTestCase {
public class PipTaskOrganizerTest extends ShellTestCase {
private PipTaskOrganizer mSpiedPipTaskOrganizer;
@Mock private DisplayController mMockdDisplayController;

View File

@@ -35,6 +35,7 @@ import android.test.suitebuilder.annotation.SmallTest;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper;
import com.android.wm.shell.ShellTestCase;
import com.android.wm.shell.WindowManagerShellWrapper;
import com.android.wm.shell.common.DisplayController;
import com.android.wm.shell.common.ShellExecutor;
@@ -42,7 +43,6 @@ import com.android.wm.shell.pip.PipBoundsHandler;
import com.android.wm.shell.pip.PipBoundsState;
import com.android.wm.shell.pip.PipMediaController;
import com.android.wm.shell.pip.PipTaskOrganizer;
import com.android.wm.shell.pip.PipTestCase;
import org.junit.Before;
import org.junit.Test;
@@ -56,7 +56,7 @@ import org.mockito.MockitoAnnotations;
@SmallTest
@RunWith(AndroidTestingRunner.class)
@TestableLooper.RunWithLooper
public class PipControllerTest extends PipTestCase {
public class PipControllerTest extends ShellTestCase {
private PipController mPipController;
@Mock private DisplayController mMockDisplayController;

View File

@@ -31,17 +31,13 @@ import android.util.Size;
import androidx.test.filters.SmallTest;
import com.android.wm.shell.R;
import com.android.wm.shell.ShellTestCase;
import com.android.wm.shell.common.FloatingContentCoordinator;
import com.android.wm.shell.pip.PipBoundsHandler;
import com.android.wm.shell.pip.PipBoundsState;
import com.android.wm.shell.pip.PipSnapAlgorithm;
import com.android.wm.shell.pip.PipTaskOrganizer;
import com.android.wm.shell.pip.PipTestCase;
import com.android.wm.shell.pip.PipUiEventLogger;
import com.android.wm.shell.pip.phone.PipMenuActivityController;
import com.android.wm.shell.pip.phone.PipMotionHelper;
import com.android.wm.shell.pip.phone.PipResizeGestureHandler;
import com.android.wm.shell.pip.phone.PipTouchHandler;
import org.junit.Before;
import org.junit.Test;
@@ -59,7 +55,7 @@ import org.mockito.MockitoAnnotations;
@RunWith(AndroidTestingRunner.class)
@SmallTest
@TestableLooper.RunWithLooper(setAsMainLooper = true)
public class PipTouchHandlerTest extends PipTestCase {
public class PipTouchHandlerTest extends ShellTestCase {
private PipTouchHandler mPipTouchHandler;

View File

@@ -35,8 +35,7 @@ import android.view.ViewConfiguration;
import androidx.test.filters.SmallTest;
import com.android.wm.shell.pip.PipTestCase;
import com.android.wm.shell.pip.phone.PipTouchState;
import com.android.wm.shell.ShellTestCase;
import org.junit.Before;
import org.junit.Test;
@@ -47,7 +46,7 @@ import java.util.concurrent.CountDownLatch;
@RunWith(AndroidTestingRunner.class)
@SmallTest
@RunWithLooper
public class PipTouchStateTest extends PipTestCase {
public class PipTouchStateTest extends ShellTestCase {
private PipTouchState mTouchState;
private CountDownLatch mDoubleTapCallbackTriggeredLatch;