1 /*
2  * Copyright (C) 2015 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License
15  */
16 
17 package android.systemui.cts;
18 
19 import static android.Manifest.permission.POST_NOTIFICATIONS;
20 import static android.Manifest.permission.REVOKE_POST_NOTIFICATIONS_WITHOUT_KILL;
21 import static android.Manifest.permission.REVOKE_RUNTIME_PERMISSIONS;
22 import static android.server.wm.BarTestUtils.assumeHasColoredNavigationBar;
23 import static android.server.wm.BarTestUtils.assumeHasColoredStatusBar;
24 
25 import static androidx.test.InstrumentationRegistry.getInstrumentation;
26 
27 import static org.junit.Assert.assertTrue;
28 
29 import android.app.Notification;
30 import android.app.NotificationChannel;
31 import android.app.NotificationManager;
32 import android.app.UiAutomation;
33 import android.content.Context;
34 import android.graphics.Bitmap;
35 import android.graphics.Color;
36 import android.graphics.Insets;
37 import android.os.Process;
38 import android.os.SystemClock;
39 import android.permission.PermissionManager;
40 import android.permission.cts.PermissionUtils;
41 import android.platform.test.annotations.AppModeFull;
42 import android.platform.test.annotations.PlatinumTest;
43 import android.server.wm.IgnoreOrientationRequestSession;
44 import android.view.Gravity;
45 import android.view.InputDevice;
46 import android.view.MotionEvent;
47 import android.view.WindowInsets.Type;
48 import android.view.WindowManager;
49 import android.view.WindowMetrics;
50 
51 import androidx.test.rule.ActivityTestRule;
52 import androidx.test.runner.AndroidJUnit4;
53 
54 import com.android.compatibility.common.util.SystemUtil;
55 import com.android.compatibility.common.util.ThrowingRunnable;
56 import com.android.settingslib.flags.Flags;
57 
58 import org.junit.After;
59 import org.junit.Before;
60 import org.junit.Rule;
61 import org.junit.Test;
62 import org.junit.rules.TestName;
63 import org.junit.runner.RunWith;
64 
65 /**
66  * Test for light status bar.
67  *
68  * atest CtsSystemUiTestCases:LightBarTests
69  */
70 @RunWith(AndroidJUnit4.class)
71 public class LightBarTests extends LightBarTestBase {
72 
73     public static final String TAG = "LightStatusBarTests";
74 
75     /**
76      * Color may be slightly off-spec when resources are resized for lower densities. Use this error
77      * margin to accommodate for that when comparing colors.
78      */
79     private static final int COLOR_COMPONENT_ERROR_MARGIN = 20;
80 
81     /**
82      * It's possible for the device to have color sampling enabled in the nav bar -- in that
83      * case we need to pick a background color that would result in the same dark icon tint
84      * that matches the default visibility flags used when color sampling is not enabled.
85      */
86     private static final int LIGHT_BG_COLOR = Color.rgb(255, 128, 128);
87 
88     /**
89      * Flags.newStatusBarIcons() changes the default light mode tint (i.e., dark icons) to 100%
90      * black. If the flag is on we need to change the foreground color we're looking for.
91      */
92     private static final int DARK_ICON_TINT_LEGACY = 0x99000000;
93     private static final int DARK_ICON_TINT = 0xff000000;
94 
95     private final String NOTIFICATION_TAG = "TEST_TAG";
96     private final String NOTIFICATION_CHANNEL_ID = "test_channel";
97     private final String NOTIFICATION_GROUP_KEY = "test_group";
98     private NotificationManager mNm;
99     private IgnoreOrientationRequestSession mOrientationRequestSession;
100 
101     @Rule
102     public ActivityTestRule<LightBarActivity> mActivityRule = new ActivityTestRule<>(
103             LightBarActivity.class);
104     @Rule
105     public TestName mTestName = new TestName();
106 
107 
108 
109     @Before
setUp()110     public void setUp() {
111         // We need to prevent letterboxing because when an activity is letterboxed, then the status
112         // bar icons are outside the activity space so our verification will fail. See b/246515090.
113         //
114         // When ignore_orientation_request is set to true and the device is in landscape but the
115         // activity is in portrait, then the device remains in landscape but letterboxes the
116         // activity (so the activity is *not* full screen). Setting ignore_orientation_request to
117         // false will cause the device to instead rotate to portrait to match the activity, thus
118         // preventing letterboxing.
119         mOrientationRequestSession = new IgnoreOrientationRequestSession(false /* enable */);
120     }
121 
122     @After
tearDown()123     public void tearDown() {
124         if (mOrientationRequestSession != null) {
125             mOrientationRequestSession.close();
126         }
127     }
128 
129     @Test
130     @AppModeFull // Instant apps cannot create notifications
131     @PlatinumTest(focusArea = "sysui")
testLightStatusBarIcons()132     public void testLightStatusBarIcons() throws Throwable {
133         assumeHasColoredStatusBar(mActivityRule);
134 
135         runInNotificationSession(() -> {
136             requestLightBars(LIGHT_BG_COLOR);
137             Thread.sleep(WAIT_TIME);
138 
139             Bitmap bitmap = takeStatusBarScreenshot(mActivityRule.getActivity());
140             Stats s = evaluateLightBarBitmap(bitmap, LIGHT_BG_COLOR, 0);
141             assertStats(bitmap, s, true /* light */);
142         });
143     }
144 
145     @Test
146     @AppModeFull // Instant apps cannot create notifications
147     @PlatinumTest(focusArea = "sysui")
testAppearanceCanOverwriteLegacyFlags()148     public void testAppearanceCanOverwriteLegacyFlags() throws Throwable {
149         assumeHasColoredStatusBar(mActivityRule);
150 
151         runInNotificationSession(() -> {
152             final LightBarActivity activity = mActivityRule.getActivity();
153             activity.runOnUiThread(() -> {
154                 activity.getWindow().setStatusBarColor(LIGHT_BG_COLOR);
155                 activity.getWindow().setNavigationBarColor(LIGHT_BG_COLOR);
156 
157                 activity.setLightStatusBarLegacy(true);
158                 activity.setLightNavigationBarLegacy(true);
159 
160                 // The new appearance APIs can overwrite the appearance specified by the legacy
161                 // flags.
162                 activity.setLightStatusBarAppearance(false);
163                 activity.setLightNavigationBarAppearance(false);
164             });
165             Thread.sleep(WAIT_TIME);
166 
167             Bitmap bitmap = takeStatusBarScreenshot(mActivityRule.getActivity());
168             Stats s = evaluateDarkBarBitmap(bitmap, LIGHT_BG_COLOR, 0);
169             assertStats(bitmap, s, false /* light */);
170         });
171     }
172 
173     @Test
174     @AppModeFull // Instant apps cannot create notifications
175     @PlatinumTest(focusArea = "sysui")
testLegacyFlagsCannotOverwriteAppearance()176     public void testLegacyFlagsCannotOverwriteAppearance() throws Throwable {
177         assumeHasColoredStatusBar(mActivityRule);
178 
179         runInNotificationSession(() -> {
180             final LightBarActivity activity = mActivityRule.getActivity();
181             activity.runOnUiThread(() -> {
182                 activity.getWindow().setStatusBarColor(LIGHT_BG_COLOR);
183                 activity.getWindow().setNavigationBarColor(LIGHT_BG_COLOR);
184 
185                 activity.setLightStatusBarAppearance(false);
186                 activity.setLightNavigationBarAppearance(false);
187 
188                 // Once the client starts using the new appearance APIs, the legacy flags won't
189                 // change the appearance anymore.
190                 activity.setLightStatusBarLegacy(true);
191                 activity.setLightNavigationBarLegacy(true);
192             });
193             Thread.sleep(WAIT_TIME);
194 
195             Bitmap bitmap = takeStatusBarScreenshot(mActivityRule.getActivity());
196             Stats s = evaluateDarkBarBitmap(bitmap, LIGHT_BG_COLOR, 0);
197             assertStats(bitmap, s, false /* light */);
198         });
199     }
200 
201     @Test
testLightNavigationBar()202     public void testLightNavigationBar() throws Throwable {
203         assumeHasColoredNavigationBar(mActivityRule);
204 
205         requestLightBars(LIGHT_BG_COLOR);
206         Thread.sleep(WAIT_TIME);
207 
208         // Inject a cancelled interaction with the nav bar to ensure it is at full opacity.
209         int x = mActivityRule.getActivity().getWidth() / 2;
210         int y = mActivityRule.getActivity().getBottom() + 10;
211         injectCanceledTap(x, y);
212         Thread.sleep(WAIT_TIME);
213 
214         LightBarActivity activity = mActivityRule.getActivity();
215         Bitmap bitmap = takeNavigationBarScreenshot(activity);
216         Stats s = evaluateLightBarBitmap(bitmap, LIGHT_BG_COLOR, activity.getBottom());
217         assertStats(bitmap, s, true /* light */);
218     }
219 
220     @Test
testNavigationBarDivider()221     public void testNavigationBarDivider() throws Throwable {
222         assumeHasColoredNavigationBar(mActivityRule);
223 
224         mActivityRule.runOnUiThread(() -> {
225             mActivityRule.getActivity().getWindow().setNavigationBarColor(Color.RED);
226             mActivityRule.getActivity().getWindow().setNavigationBarDividerColor(Color.WHITE);
227         });
228         Thread.sleep(WAIT_TIME);
229 
230         checkNavigationBarDivider(mActivityRule.getActivity(), Color.WHITE, Color.RED,
231                 mTestName.getMethodName());
232     }
233 
234     @Test
235     @AppModeFull // Instant apps cannot create notifications
testLightBarIsNotAllowed_fitStatusBar()236     public void testLightBarIsNotAllowed_fitStatusBar() throws Throwable {
237         assumeHasColoredStatusBar(mActivityRule);
238 
239         runInNotificationSession(() -> {
240             final LightBarActivity activity = mActivityRule.getActivity();
241             activity.runOnUiThread(() -> {
242                 final WindowMetrics metrics = activity.getWindowManager().getCurrentWindowMetrics();
243                 final Insets insets = metrics.getWindowInsets().getInsets(Type.statusBars());
244                 final WindowManager.LayoutParams attrs = activity.getWindow().getAttributes();
245                 attrs.gravity = Gravity.LEFT | Gravity.TOP;
246                 attrs.x = insets.left;
247                 attrs.y = insets.top;
248                 attrs.width = metrics.getBounds().width() - insets.left - insets.right;
249                 attrs.height = metrics.getBounds().height() - insets.top - insets.bottom;
250                 activity.getWindow().setAttributes(attrs);
251                 activity.getWindow().setStatusBarColor(Color.BLACK);
252                 activity.getWindow().setNavigationBarColor(Color.BLACK);
253                 activity.setLightStatusBarAppearance(true);
254                 activity.setLightNavigationBarAppearance(true);
255             });
256             Thread.sleep(WAIT_TIME);
257 
258             Bitmap bitmap = takeStatusBarScreenshot(activity);
259             Stats s = evaluateDarkBarBitmap(bitmap, Color.TRANSPARENT, 0);
260             assertStats(bitmap, s, false /* light */);
261         });
262     }
263 
runInNotificationSession(ThrowingRunnable task)264     private void runInNotificationSession(ThrowingRunnable task) throws Exception {
265         Context context = getInstrumentation().getContext();
266         String packageName = getInstrumentation().getTargetContext().getPackageName();
267         try {
268             PermissionUtils.grantPermission(packageName, POST_NOTIFICATIONS);
269             mNm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
270             NotificationChannel channel1 = new NotificationChannel(NOTIFICATION_CHANNEL_ID,
271                     NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW);
272             mNm.createNotificationChannel(channel1);
273 
274             // post 10 notifications to ensure enough icons in the status bar
275             for (int i = 0; i < 10; i++) {
276                 Notification.Builder noti1 =
277                         new Notification.Builder(context, NOTIFICATION_CHANNEL_ID)
278                                 .setSmallIcon(R.drawable.ic_save)
279                                 .setChannelId(NOTIFICATION_CHANNEL_ID)
280                                 .setPriority(Notification.PRIORITY_LOW)
281                                 .setGroup(NOTIFICATION_GROUP_KEY);
282                 mNm.notify(NOTIFICATION_TAG, i, noti1.build());
283             }
284 
285             task.run();
286         } finally {
287             mNm.cancelAll();
288             mNm.deleteNotificationChannel(NOTIFICATION_CHANNEL_ID);
289 
290             // Use test API to prevent PermissionManager from killing the test process when revoking
291             // permission.
292             SystemUtil.runWithShellPermissionIdentity(
293                     () -> context.getSystemService(PermissionManager.class)
294                             .revokePostNotificationPermissionWithoutKillForTest(
295                                     packageName,
296                                     Process.myUserHandle().getIdentifier()),
297                     REVOKE_POST_NOTIFICATIONS_WITHOUT_KILL,
298                     REVOKE_RUNTIME_PERMISSIONS);
299         }
300     }
301 
injectCanceledTap(int x, int y)302     private void injectCanceledTap(int x, int y) {
303         long downTime = SystemClock.uptimeMillis();
304         injectEvent(MotionEvent.ACTION_DOWN, x, y, downTime);
305         injectEvent(MotionEvent.ACTION_CANCEL, x, y, downTime);
306     }
307 
injectEvent(int action, int x, int y, long downTime)308     private void injectEvent(int action, int x, int y, long downTime) {
309         final UiAutomation automation = getInstrumentation().getUiAutomation();
310         final long eventTime = SystemClock.uptimeMillis();
311         MotionEvent event = MotionEvent.obtain(downTime, eventTime, action, x, y, 0);
312         event.setSource(InputDevice.SOURCE_TOUCHSCREEN);
313         assertTrue(automation.injectInputEvent(event, true));
314         event.recycle();
315     }
316 
assertStats(Bitmap bitmap, Stats s, boolean light)317     private void assertStats(Bitmap bitmap, Stats s, boolean light) {
318         boolean success = false;
319         try {
320             assumeNavigationBarChangesColor(s.backgroundPixels, s.totalPixels());
321 
322             final String spec = light ? "60% black and 24% black" : "100% white and 30% white";
323             assertMoreThan("Not enough pixels colored as in the spec", 0.3f,
324                     (float) s.iconPixels / (float) s.foregroundPixels(),
325                     "Are the bar icons colored according to the spec (" + spec + ")?");
326 
327             final String unexpected = light ? "lighter" : "darker";
328             final String expected = light ? "dark" : "light";
329             final int sameHuePixels = light ? s.sameHueLightPixels : s.sameHueDarkPixels;
330             assertLessThan("Too many pixels " + unexpected + " than the background", 0.05f,
331                     (float) sameHuePixels / (float) s.foregroundPixels(),
332                     "Are the bar icons " + expected + "?");
333 
334             // New status bar icons introduce color into the battery icon more regularly. This
335             // value can't be asserted in this way anymore
336             if (!Flags.newStatusBarIcons()) {
337                 assertLessThan("Too many pixels with a changed hue", 0.05f,
338                         (float) s.unexpectedHuePixels / (float) s.foregroundPixels(),
339                         "Are the bar icons color-free?");
340             }
341 
342             success = true;
343         } finally {
344             if (!success) {
345                 dumpBitmap(bitmap, mTestName.getMethodName());
346             }
347         }
348     }
349 
requestLightBars(final int background)350     private void requestLightBars(final int background) {
351         final LightBarActivity activity = mActivityRule.getActivity();
352         activity.runOnUiThread(() -> {
353             activity.getWindow().setStatusBarColor(background);
354             activity.getWindow().setNavigationBarColor(background);
355             activity.setLightStatusBarLegacy(true);
356             activity.setLightNavigationBarLegacy(true);
357         });
358     }
359 
360     private static class Stats {
361         int backgroundPixels;
362         int iconPixels;
363         int sameHueDarkPixels;
364         int sameHueLightPixels;
365         int unexpectedHuePixels;
366 
totalPixels()367         int totalPixels() {
368             return backgroundPixels + iconPixels + sameHueDarkPixels
369                     + sameHueLightPixels + unexpectedHuePixels;
370         }
371 
foregroundPixels()372         int foregroundPixels() {
373             return iconPixels + sameHueDarkPixels
374                     + sameHueLightPixels + unexpectedHuePixels;
375         }
376 
377         @Override
toString()378         public String toString() {
379             return String.format("{bg=%d, ic=%d, dark=%d, light=%d, bad=%d}",
380                     backgroundPixels, iconPixels, sameHueDarkPixels, sameHueLightPixels,
381                     unexpectedHuePixels);
382         }
383     }
384 
evaluateLightBarBitmap(Bitmap bitmap, int background, int shiftY)385     private Stats evaluateLightBarBitmap(Bitmap bitmap, int background, int shiftY) {
386         if (Flags.newStatusBarIcons()) {
387             return evaluateBarBitmap(
388                 bitmap,
389                 background,
390                 shiftY,
391                 DARK_ICON_TINT,
392                 0x3d000000
393             );
394         } else {
395             return evaluateBarBitmap(
396                 bitmap,
397                 background,
398                 shiftY,
399                 DARK_ICON_TINT_LEGACY,
400                 0x3d000000
401             );
402         }
403     }
404 
evaluateDarkBarBitmap(Bitmap bitmap, int background, int shiftY)405     private Stats evaluateDarkBarBitmap(Bitmap bitmap, int background, int shiftY) {
406         return evaluateBarBitmap(bitmap, background, shiftY, 0xffffffff, 0x4dffffff);
407     }
408 
evaluateBarBitmap(Bitmap bitmap, int background, int shiftY, int iconColor, int iconPartialColor)409     private Stats evaluateBarBitmap(Bitmap bitmap, int background, int shiftY, int iconColor,
410             int iconPartialColor) {
411 
412         int mixedIconColor = mixSrcOver(background, iconColor);
413         int mixedIconPartialColor = mixSrcOver(background, iconPartialColor);
414         float [] hsvMixedIconColor = new float[3];
415         float [] hsvMixedPartialColor = new float[3];
416         Color.RGBToHSV(Color.red(mixedIconColor), Color.green(mixedIconColor),
417                 Color.blue(mixedIconColor), hsvMixedIconColor);
418         Color.RGBToHSV(Color.red(mixedIconPartialColor), Color.green(mixedIconPartialColor),
419                 Color.blue(mixedIconPartialColor), hsvMixedPartialColor);
420 
421         float maxHsvValue = Math.max(hsvMixedIconColor[2], hsvMixedPartialColor[2]);
422         float minHsvValue = Math.min(hsvMixedIconColor[2], hsvMixedPartialColor[2]);
423 
424         int[] pixels = new int[bitmap.getHeight() * bitmap.getWidth()];
425         bitmap.getPixels(pixels, 0, bitmap.getWidth(), 0, 0, bitmap.getWidth(), bitmap.getHeight());
426 
427         Stats s = new Stats();
428         float eps = 0.005f;
429 
430         loadCutout(mActivityRule.getActivity());
431         float [] hsvPixel = new float[3];
432         int i = 0;
433         for (int c : pixels) {
434             int x = i % bitmap.getWidth();
435             int y = i / bitmap.getWidth();
436             i++;
437             if (isInsideCutout(x, shiftY + y)) {
438                 continue;
439             }
440 
441             if (isColorSame(c, background)) {
442                 s.backgroundPixels++;
443                 continue;
444             }
445 
446             // What we expect the icons to be colored according to the spec.
447             Color.RGBToHSV(Color.red(c), Color.green(c), Color.blue(c), hsvPixel);
448             if (isColorSame(c, mixedIconColor) || isColorSame(c, mixedIconPartialColor)
449                     || (hsvPixel[2] >= minHsvValue && hsvPixel[2] <= maxHsvValue)) {
450                 s.iconPixels++;
451                 continue;
452             }
453 
454             // Due to anti-aliasing, there will be deviations from the ideal icon color, but it
455             // should still be mostly the same hue.
456             float hueDiff = Math.abs(ColorUtils.hue(background) - ColorUtils.hue(c));
457             if (hueDiff < eps || hueDiff > 1 - eps) {
458                 // .. it shouldn't be lighter than the original background though.
459                 if (ColorUtils.brightness(c) > ColorUtils.brightness(background)) {
460                     s.sameHueLightPixels++;
461                 } else {
462                     s.sameHueDarkPixels++;
463                 }
464                 continue;
465             }
466 
467             s.unexpectedHuePixels++;
468         }
469 
470         return s;
471     }
472 
mixSrcOver(int background, int foreground)473     private int mixSrcOver(int background, int foreground) {
474         int bgAlpha = Color.alpha(background);
475         int bgRed = Color.red(background);
476         int bgGreen = Color.green(background);
477         int bgBlue = Color.blue(background);
478 
479         int fgAlpha = Color.alpha(foreground);
480         int fgRed = Color.red(foreground);
481         int fgGreen = Color.green(foreground);
482         int fgBlue = Color.blue(foreground);
483 
484         return Color.argb(fgAlpha + (255 - fgAlpha) * bgAlpha / 255,
485                     fgRed + (255 - fgAlpha) * bgRed / 255,
486                     fgGreen + (255 - fgAlpha) * bgGreen / 255,
487                     fgBlue + (255 - fgAlpha) * bgBlue / 255);
488     }
489 
490     /**
491      * Check if two colors' diff is in the error margin as defined in
492      * {@link #COLOR_COMPONENT_ERROR_MARGIN}.
493      */
isColorSame(int c1, int c2)494     private boolean isColorSame(int c1, int c2){
495         return Math.abs(Color.alpha(c1) - Color.alpha(c2)) < COLOR_COMPONENT_ERROR_MARGIN
496                 && Math.abs(Color.red(c1) - Color.red(c2)) < COLOR_COMPONENT_ERROR_MARGIN
497                 && Math.abs(Color.green(c1) - Color.green(c2)) < COLOR_COMPONENT_ERROR_MARGIN
498                 && Math.abs(Color.blue(c1) - Color.blue(c2)) < COLOR_COMPONENT_ERROR_MARGIN;
499     }
500 }
501