Skip to content

Commit f9d05a7

Browse files
Hyung Tae KimGerrit Code Review
authored andcommitted
Merge "uiautomator: Desktop multi-window support #1" into androidx-main
2 parents a2d0343 + fb587b0 commit f9d05a7

13 files changed

Lines changed: 1592 additions & 2 deletions

File tree

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
/*
2+
* Copyright 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.test.uiautomator.testapp;
18+
19+
import static android.view.accessibility.AccessibilityWindowInfo.TYPE_APPLICATION;
20+
import static android.view.accessibility.AccessibilityWindowInfo.TYPE_SYSTEM;
21+
22+
import static org.junit.Assert.assertEquals;
23+
import static org.junit.Assert.assertFalse;
24+
import static org.junit.Assert.assertNotEquals;
25+
import static org.junit.Assert.assertNotNull;
26+
import static org.junit.Assert.assertNull;
27+
import static org.junit.Assert.assertTrue;
28+
import static org.junit.Assume.assumeTrue;
29+
30+
import android.app.Activity;
31+
import android.content.Intent;
32+
33+
import androidx.test.filters.LargeTest;
34+
import androidx.test.uiautomator.By;
35+
import androidx.test.uiautomator.UiObject2;
36+
import androidx.test.uiautomator.UiWindow;
37+
38+
import org.jspecify.annotations.NonNull;
39+
import org.junit.Before;
40+
import org.junit.Test;
41+
42+
import java.util.List;
43+
44+
@LargeTest
45+
public class UiWindowTest extends BaseTest {
46+
private static final String APP_NAME = "UiAutomator Test App";
47+
private static final long TIMEOUT_MS = 5_000;
48+
49+
@Before
50+
public void setUp() throws Exception {
51+
super.setUp();
52+
launchTestActivityInMultiWindow(UiWindowTestActivity.class);
53+
}
54+
55+
void launchTestActivityInMultiWindow(@NonNull Class<? extends Activity> activity) {
56+
launchTestActivity(
57+
activity,
58+
new Intent().setFlags(
59+
Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK),
60+
null);
61+
}
62+
63+
@Test
64+
public void testUiDevice_hasWindow() {
65+
assertTrue(
66+
"Test app window should exist",
67+
mDevice.hasWindow(By.Window.type(TYPE_APPLICATION).pkg(TEST_APP)));
68+
assertTrue(
69+
"At least one system window should exist",
70+
mDevice.hasWindow(By.Window.type(TYPE_SYSTEM)));
71+
}
72+
73+
@Test
74+
public void testUiDevice_findWindow() {
75+
// Verify that the test app window is found and active using ByWindowSelector.
76+
UiWindow window = mDevice.findWindow(By.Window.pkg(TEST_APP).active(true));
77+
assertNotNull("Test app window should be active and non-null", window);
78+
assertEquals(TEST_APP, window.getPackageName());
79+
assertTrue(window.isActive());
80+
}
81+
82+
@Test
83+
public void testUiDevice_findWindowByTitle() {
84+
String expectedTitle = APP_NAME;
85+
UiWindow window = mDevice.findWindow(By.Window.title(expectedTitle));
86+
assertNotNull(window);
87+
assertEquals(expectedTitle, window.getTitle());
88+
}
89+
90+
@Test
91+
public void testUiDevice_findAppHeaderWindow() {
92+
assumeTrue("The app header window is present only in desktop mode", isDesktopWindowing());
93+
94+
// Get the app window and its layer (z-order).
95+
UiWindow appWindow =
96+
mDevice.findWindow(By.Window.pkg(TEST_APP).type(TYPE_APPLICATION).active(true));
97+
assertNotNull(appWindow);
98+
int appWindowLayer = appWindow.getLayer();
99+
100+
// Find the header window that is on top of the app window using layerAbove().
101+
UiWindow headerWindow =
102+
mDevice.findWindow(
103+
By.Window
104+
.pkg("com.android.systemui")
105+
.type(TYPE_APPLICATION)
106+
.layerAbove(appWindowLayer));
107+
assertNotNull("No header window found for app window: " + appWindow, headerWindow);
108+
assertTrue(
109+
"Header window should be above the app window's layer",
110+
headerWindow.getLayer() > appWindowLayer);
111+
assertTrue(
112+
"Should have a close button in header",
113+
headerWindow.hasObject(By.res("com.android.systemui", "close_window")));
114+
}
115+
116+
@Test
117+
public void testUiDevice_closeNonActiveDesktopWindow() {
118+
assumeTrue("The app header window is present only in desktop mode", isDesktopWindowing());
119+
120+
// Launch another test app window in desktop mode. Total 2 windows.
121+
launchTestActivityInMultiWindow(UiWindowTestActivity.class);
122+
List<UiWindow> appWindows = mDevice.findWindows(By.Window.pkg(TEST_APP));
123+
assertEquals(2, appWindows.size());
124+
125+
// Find the non-active window that is the lowest z-order window and the last one in the
126+
// window stack.
127+
UiWindow nonActiveWindow = appWindows.get(1);
128+
assertNotNull(nonActiveWindow);
129+
assertFalse(nonActiveWindow.isActive());
130+
131+
List<UiWindow> windows =
132+
mDevice.findWindows(
133+
By.Window
134+
.pkg("com.android.systemui")
135+
.type(TYPE_APPLICATION)
136+
.layerAbove(nonActiveWindow.getLayer()));
137+
assertFalse(windows.isEmpty());
138+
UiWindow headerWindow = windows.get(windows.size() - 1);
139+
assertNotNull("No header window found for app window: " + nonActiveWindow, headerWindow);
140+
141+
// Find the close button in the header window, click it to close the window.
142+
UiObject2 closeButton = headerWindow.findObject(
143+
By.res("com.android.systemui", "close_window"));
144+
assertNotNull(closeButton);
145+
assertTrue(closeButton.isClickable());
146+
closeButton.click();
147+
// Wait for the app window to no longer be present, identified by its unique window ID.
148+
assertTrue(
149+
"Window should no longer be present if the close button is clicked.",
150+
mDevice.wait(d -> !d.hasWindow(By.Window.id(nonActiveWindow.getId())), TIMEOUT_MS));
151+
}
152+
153+
@Test
154+
public void testUiDevice_doesNotFindNonExistentWindow() {
155+
UiWindow window = mDevice.findWindow(By.Window.title("non existent title"));
156+
assertNull("Should not find a non-existent window", window);
157+
}
158+
159+
@Test
160+
public void testUiDevice_doesNotFindNonExistentWindows() {
161+
List<UiWindow> windows = mDevice.findWindows(By.Window.title("non existent title"));
162+
assertNotNull("Should not return null list", windows);
163+
assertTrue("Should not find any non-existent windows", windows.isEmpty());
164+
}
165+
166+
@Test
167+
public void testUiDevice_findWindows() {
168+
assumeTrue("Desktop mode required for multi-window", isDesktopWindowing());
169+
// Launch another test app window in desktop mode. Total 2 windows.
170+
launchTestActivityInMultiWindow(UiWindowTestActivity.class);
171+
172+
// Verify that there are two windows of the test app now.
173+
List<UiWindow> windows = mDevice.findWindows(By.Window.pkg(TEST_APP));
174+
assertNotNull(windows);
175+
assertEquals(2, windows.size());
176+
for (UiWindow window : windows) {
177+
assertEquals(TEST_APP, window.getPackageName());
178+
}
179+
}
180+
181+
@Test
182+
public void testUiWindow_equals() {
183+
UiWindow window1 = mDevice.findWindow(By.Window.pkg(TEST_APP).active(true));
184+
UiWindow window2 = mDevice.findWindow(By.Window.pkg(TEST_APP).active(true));
185+
assertNotNull(window1);
186+
assertNotNull(window2);
187+
assertEquals("Window should be equal to itself", window1, window1);
188+
assertEquals("Window should be equal to another window with same properties", window1,
189+
window2);
190+
191+
UiWindow systemWindow = mDevice.findWindow(By.Window.type(TYPE_SYSTEM));
192+
if (systemWindow != null) {
193+
assertNotEquals(window1, systemWindow);
194+
assertNotEquals(window1.hashCode(), systemWindow.hashCode());
195+
}
196+
}
197+
198+
@Test
199+
public void testUiWindow_hasObject() {
200+
UiWindow window = mDevice.findWindow(By.Window.pkg(TEST_APP).active(true));
201+
assertNotNull(window);
202+
assertTrue(
203+
"Button should be searchable within this window",
204+
window.hasObject(By.res(TEST_APP, "launch_task_window_button")));
205+
}
206+
207+
@Test
208+
public void testUiWindow_findObject() {
209+
UiWindow window = mDevice.findWindow(By.Window.pkg(TEST_APP).active(true));
210+
assertNotNull(window);
211+
// Window-to-object search.
212+
UiObject2 button = window.findObject(By.res(TEST_APP, "launch_task_window_button"));
213+
assertNotNull("Button should be accessible within this window", button);
214+
assertEquals("launch_task_window", button.getText());
215+
}
216+
217+
@Test
218+
public void testUiWindow_findObjects() {
219+
assumeTrue("Desktop mode required for multi-window", isDesktopWindowing());
220+
221+
// Launch another test app window in desktop mode. Total 2 windows.
222+
launchTestActivityInMultiWindow(UiWindowTestActivity.class);
223+
224+
// Window-to-object search.
225+
// Verify that two windows of the test app are now open, and each has two buttons.
226+
UiWindow window = mDevice.findWindow(By.Window.pkg(TEST_APP).active(true));
227+
assertNotNull(window);
228+
List<UiObject2> buttons = window.findObjects(By.clazz("android.widget.Button"));
229+
assertEquals("Should find two buttons in one window", 2, buttons.size());
230+
}
231+
232+
@Test
233+
public void testUiWindow_getRootObjectAndPackageName() {
234+
UiWindow window = mDevice.findWindow(By.Window.pkg(TEST_APP).active(true));
235+
assertNotNull(window);
236+
UiObject2 root = window.getRootObject();
237+
assertNotNull(root);
238+
assertEquals(TEST_APP, root.getApplicationPackage());
239+
240+
UiWindow systemWindow = mDevice.findWindow(By.Window.pkg("com.android.systemui"));
241+
if (systemWindow != null) {
242+
root = systemWindow.getRootObject();
243+
assertNotNull(root);
244+
assertEquals("com.android.systemui", root.getApplicationPackage());
245+
}
246+
}
247+
}

test/uiautomator/integration-tests/testapp/src/main/AndroidManifest.xml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,22 @@
193193
<action android:name="android.intent.action.MAIN" />
194194
</intent-filter>
195195
</activity>
196+
<!--
197+
Setting the activity attributes for multi-instance multi-window mode.
198+
The empty task affinity launches the activity in a separate task.
199+
The resizeableActivity allows the activity in a freeform window.
200+
The launchMode "standard" allows multiple instances of the activity.
201+
-->
202+
<activity
203+
android:name=".UiWindowTestActivity"
204+
android:exported="true"
205+
android:launchMode="standard"
206+
android:resizeableActivity="true"
207+
android:taskAffinity="">
208+
<intent-filter>
209+
<action android:name="android.intent.action.MAIN" />
210+
</intent-filter>
211+
</activity>
196212
<activity android:name=".UntilTestActivity"
197213
android:exported="true">
198214
<intent-filter>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.test.uiautomator.testapp;
18+
19+
import android.app.Activity;
20+
import android.content.Intent;
21+
import android.os.Bundle;
22+
import android.widget.Button;
23+
24+
import org.jspecify.annotations.Nullable;
25+
26+
/** {@link Activity} for testing UiWindow in multi-window environment. */
27+
public class UiWindowTestActivity extends Activity {
28+
29+
@Override
30+
public void onCreate(@Nullable Bundle savedInstanceState) {
31+
super.onCreate(savedInstanceState);
32+
setContentView(R.layout.uiwindow_test_activity);
33+
34+
Button launchWindowButton = findViewById(R.id.launch_task_window_button);
35+
launchWindowButton.setOnClickListener(v -> launchNewInstanceOfActivity());
36+
37+
Button closeWindowButton = findViewById(R.id.close_task_window_button);
38+
closeWindowButton.setOnClickListener(v -> finish());
39+
}
40+
41+
private void launchNewInstanceOfActivity() {
42+
Intent intent = new Intent(this, UiWindowTestActivity.class);
43+
// Set the flags for launching the activity in a new task and ensuring that a new task
44+
// window is created in multi-window environment.
45+
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
46+
intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
47+
startActivity(intent);
48+
}
49+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?xml version="1.0" encoding="utf-8"?><!--
2+
* Copyright (C) 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
-->
16+
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
17+
xmlns:tools="http://schemas.android.com/tools"
18+
android:layout_width="match_parent"
19+
android:layout_height="match_parent"
20+
android:gravity="center"
21+
android:orientation="vertical"
22+
tools:context=".UiWindowTestActivity">
23+
24+
<Button
25+
android:id="@+id/launch_task_window_button"
26+
android:layout_width="wrap_content"
27+
android:layout_height="wrap_content"
28+
android:text="launch_task_window" />
29+
30+
<Button
31+
android:id="@+id/close_task_window_button"
32+
android:layout_width="wrap_content"
33+
android:layout_height="wrap_content"
34+
android:text="close_task_window" />
35+
36+
</LinearLayout>

test/uiautomator/uiautomator/api/api_lint.ignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ MissingNullability: androidx.test.uiautomator.UiObject2#getResourceName():
2525
Missing nullability on method `getResourceName` return
2626
MissingNullability: androidx.test.uiautomator.UiObject2#getText():
2727
Missing nullability on method `getText` return
28+
MissingNullability: androidx.test.uiautomator.UiWindow#findObject(androidx.test.uiautomator.BySelector):
29+
Missing nullability on method `findObject` return
2830

2931

3032
PercentageInt: androidx.test.uiautomator.UiObject2#setGestureMarginPercentage(float):

0 commit comments

Comments
 (0)