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