1 /* 2 * Copyright (C) 2020 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 com.android.settings.notification.history; 18 19 import static android.provider.Settings.Secure.NOTIFICATION_HISTORY_ENABLED; 20 import static android.view.View.GONE; 21 import static android.view.View.VISIBLE; 22 23 import static androidx.core.view.accessibility.AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUSED; 24 25 import android.annotation.AttrRes; 26 import android.annotation.ColorInt; 27 import android.annotation.DrawableRes; 28 import android.app.ActionBar; 29 import android.app.ActivityManager; 30 import android.app.INotificationManager; 31 import android.content.ComponentName; 32 import android.content.Context; 33 import android.content.pm.PackageManager; 34 import android.content.res.Resources; 35 import android.content.res.TypedArray; 36 import android.os.Bundle; 37 import android.os.RemoteException; 38 import android.os.ServiceManager; 39 import android.os.UserHandle; 40 import android.os.UserManager; 41 import android.provider.Settings; 42 import android.service.notification.NotificationListenerService; 43 import android.service.notification.StatusBarNotification; 44 import android.util.Log; 45 import android.util.Slog; 46 import android.view.ContextThemeWrapper; 47 import android.view.LayoutInflater; 48 import android.view.View; 49 import android.view.ViewGroup; 50 import android.widget.CompoundButton.OnCheckedChangeListener; 51 import android.widget.ImageView; 52 import android.widget.TextView; 53 54 import androidx.core.graphics.ColorUtils; 55 import androidx.recyclerview.widget.LinearLayoutManager; 56 import androidx.recyclerview.widget.RecyclerView; 57 58 import com.android.internal.logging.UiEvent; 59 import com.android.internal.logging.UiEventLogger; 60 import com.android.internal.logging.UiEventLoggerImpl; 61 import com.android.internal.widget.NotificationExpandButton; 62 import com.android.settings.R; 63 import com.android.settings.notification.NotificationBackend; 64 import com.android.settingslib.collapsingtoolbar.CollapsingToolbarBaseActivity; 65 import com.android.settingslib.utils.StringUtil; 66 import com.android.settingslib.utils.ThreadUtils; 67 import com.android.settingslib.widget.MainSwitchBar; 68 69 import java.util.ArrayList; 70 import java.util.Arrays; 71 import java.util.concurrent.CountDownLatch; 72 import java.util.concurrent.Future; 73 import java.util.concurrent.TimeUnit; 74 75 public class NotificationHistoryActivity extends CollapsingToolbarBaseActivity { 76 77 private static String TAG = "NotifHistory"; 78 // MAX_RECENT_DISMISS_ITEM_COUNT needs to be less or equals than 79 // R.integer.config_notificationServiceArchiveSize, which is the Number of notifications kept 80 // in the notification service historical archive 81 private static final int MAX_RECENT_DISMISS_ITEM_COUNT = 50; 82 private static final int HISTORY_HOURS = 24; 83 84 private ViewGroup mHistoryOn; 85 private ViewGroup mHistoryOff; 86 private ViewGroup mHistoryEmpty; 87 private ViewGroup mTodayView; 88 private ViewGroup mSnoozeView; 89 private ViewGroup mDismissView; 90 private MainSwitchBar mSwitchBar; 91 92 private HistoryLoader mHistoryLoader; 93 private INotificationManager mNm; 94 private UserManager mUm; 95 private PackageManager mPm; 96 private CountDownLatch mCountdownLatch; 97 private Future mCountdownFuture; 98 99 private UiEventLogger mUiEventLogger = new UiEventLoggerImpl(); 100 101 enum NotificationHistoryEvent implements UiEventLogger.UiEventEnum { 102 @UiEvent(doc = "User turned on notification history") 103 NOTIFICATION_HISTORY_ON(504), 104 105 @UiEvent(doc = "User turned off notification history") 106 NOTIFICATION_HISTORY_OFF(505), 107 108 @UiEvent(doc = "User opened notification history page") 109 NOTIFICATION_HISTORY_OPEN(506), 110 111 @UiEvent(doc = "User closed notification history page") 112 NOTIFICATION_HISTORY_CLOSE(507), 113 114 @UiEvent(doc = "User clicked on a notification history item in recently dismissed section") 115 NOTIFICATION_HISTORY_RECENT_ITEM_CLICK(508), 116 117 @UiEvent(doc = "User clicked on a notification history item in snoozed section") 118 NOTIFICATION_HISTORY_SNOOZED_ITEM_CLICK(509), 119 120 @UiEvent(doc = "User clicked to expand the notification history of a package (app)") 121 NOTIFICATION_HISTORY_PACKAGE_HISTORY_OPEN(510), 122 123 @UiEvent(doc = "User clicked to close the notification history of a package (app)") 124 NOTIFICATION_HISTORY_PACKAGE_HISTORY_CLOSE(511), 125 126 @UiEvent(doc = "User clicked on a notification history item in an expanded by-app section") 127 NOTIFICATION_HISTORY_OLDER_ITEM_CLICK(512), 128 129 @UiEvent(doc = "User dismissed a notification history item in an expanded by-app section") 130 NOTIFICATION_HISTORY_OLDER_ITEM_DELETE(513); 131 132 private int mId; 133 NotificationHistoryEvent(int id)134 NotificationHistoryEvent(int id) { 135 mId = id; 136 } 137 138 @Override getId()139 public int getId() { 140 return mId; 141 } 142 } 143 144 private HistoryLoader.OnHistoryLoaderListener mOnHistoryLoaderListener = notifications -> { 145 findViewById(R.id.today_list).setVisibility( 146 notifications.isEmpty() ? GONE : VISIBLE); 147 mCountdownLatch.countDown(); 148 View recyclerView = mTodayView.findViewById(R.id.apps); 149 recyclerView.setClipToOutline(true); 150 // for each package, new header and recycler view 151 for (int i = 0, notificationsSize = notifications.size(); i < notificationsSize; i++) { 152 NotificationHistoryPackage nhp = notifications.get(i); 153 View viewForPackage = LayoutInflater.from(this) 154 .inflate(R.layout.notification_history_app_layout, null); 155 156 int cornerType = ROUND_CORNER_CENTER; 157 if (i == (notificationsSize - 1)) { 158 cornerType |= ROUND_CORNER_BOTTOM; 159 } 160 if (i == 0) { 161 cornerType |= ROUND_CORNER_TOP; 162 } 163 int backgroundRes = NotificationHistoryActivity.getRoundCornerDrawableRes(cornerType); 164 viewForPackage.setBackgroundResource(backgroundRes); 165 166 final View container = viewForPackage.findViewById(R.id.notification_list_wrapper); 167 container.setVisibility(GONE); 168 View header = viewForPackage.findViewById(R.id.app_header); 169 NotificationExpandButton expand = viewForPackage.findViewById( 170 com.android.internal.R.id.expand_button); 171 int textColor = obtainThemeColor(android.R.attr.textColorPrimary); 172 int backgroundColor = obtainThemeColor(android.R.attr.colorBackgroundFloating); 173 int pillColor = ColorUtils.blendARGB(textColor, backgroundColor, 0.9f); 174 expand.setDefaultPillColor(pillColor); 175 expand.setDefaultTextColor(textColor); 176 expand.setExpanded(false); 177 header.setStateDescription(container.getVisibility() == VISIBLE 178 ? getString(R.string.condition_expand_hide) 179 : getString(R.string.condition_expand_show)); 180 int finalI = i; 181 header.setOnClickListener(v -> { 182 container.setVisibility(container.getVisibility() == VISIBLE 183 ? GONE : VISIBLE); 184 expand.setExpanded(container.getVisibility() == VISIBLE); 185 header.setStateDescription(container.getVisibility() == VISIBLE 186 ? getString(R.string.condition_expand_hide) 187 : getString(R.string.condition_expand_show)); 188 header.sendAccessibilityEvent(TYPE_VIEW_ACCESSIBILITY_FOCUSED); 189 mUiEventLogger.logWithPosition((container.getVisibility() == VISIBLE) 190 ? NotificationHistoryEvent.NOTIFICATION_HISTORY_PACKAGE_HISTORY_OPEN 191 : NotificationHistoryEvent.NOTIFICATION_HISTORY_PACKAGE_HISTORY_CLOSE, 192 nhp.uid, nhp.pkgName, finalI); 193 }); 194 195 TextView label = viewForPackage.findViewById(R.id.label); 196 label.setText(nhp.label != null ? nhp.label : nhp.pkgName); 197 label.setContentDescription(mUm.getBadgedLabelForUser(label.getText(), 198 UserHandle.getUserHandleForUid(nhp.uid))); 199 ImageView icon = viewForPackage.findViewById(R.id.icon); 200 icon.setImageDrawable(nhp.icon); 201 202 TextView count = viewForPackage.findViewById(R.id.count); 203 count.setText(StringUtil.getIcuPluralsString(this, nhp.notifications.size(), 204 R.string.notification_history_count)); 205 206 final NotificationHistoryRecyclerView rv = 207 viewForPackage.findViewById(R.id.notification_list); 208 rv.setAdapter(new NotificationHistoryAdapter(mNm, rv, 209 newCount -> { 210 count.setText(StringUtil.getIcuPluralsString(this, newCount, 211 R.string.notification_history_count)); 212 if (newCount == 0) { 213 viewForPackage.setVisibility(GONE); 214 } 215 }, mUiEventLogger)); 216 ((NotificationHistoryAdapter) rv.getAdapter()).onRebuildComplete( 217 new ArrayList<>(nhp.notifications)); 218 219 mTodayView.addView(viewForPackage); 220 } 221 }; 222 223 @Override onCreate(Bundle savedInstanceState)224 protected void onCreate(Bundle savedInstanceState) { 225 super.onCreate(savedInstanceState); 226 setTitle(R.string.notification_history); 227 setContentView(R.layout.notification_history); 228 mTodayView = findViewById(R.id.apps); 229 mSnoozeView = findViewById(R.id.snoozed_list); 230 mDismissView = findViewById(R.id.recently_dismissed_list); 231 mHistoryOff = findViewById(R.id.history_off); 232 mHistoryOn = findViewById(R.id.history_on); 233 mHistoryEmpty = findViewById(R.id.history_on_empty); 234 mSwitchBar = findViewById(R.id.main_switch_bar); 235 ((TextView) findViewById(R.id.today_header)).setText( 236 getString(R.string.notification_history_today, HISTORY_HOURS)); 237 238 ActionBar actionBar = getActionBar(); 239 if (actionBar != null) { 240 actionBar.setDisplayHomeAsUpEnabled(true); 241 actionBar.setHomeButtonEnabled(true); 242 actionBar.setDisplayShowTitleEnabled(true); 243 } 244 } 245 246 @Override onResume()247 protected void onResume() { 248 super.onResume(); 249 250 mPm = getPackageManager(); 251 mUm = getSystemService(UserManager.class); 252 // wait for history loading and recent/snooze loading 253 mCountdownLatch = new CountDownLatch(2); 254 255 mTodayView.removeAllViews(); 256 mHistoryLoader = new HistoryLoader(this, new NotificationBackend(), mPm); 257 mHistoryLoader.load(mOnHistoryLoaderListener); 258 259 mNm = INotificationManager.Stub.asInterface( 260 ServiceManager.getService(Context.NOTIFICATION_SERVICE)); 261 try { 262 mListener.registerAsSystemService(this, new ComponentName(getPackageName(), 263 this.getClass().getCanonicalName()), ActivityManager.getCurrentUser()); 264 } catch (RemoteException e) { 265 Log.e(TAG, "Cannot register listener", e); 266 } 267 268 bindSwitch(); 269 270 mCountdownFuture = ThreadUtils.postOnBackgroundThread(() -> { 271 try { 272 mCountdownLatch.await(2, TimeUnit.SECONDS); 273 } catch (InterruptedException e) { 274 Slog.e(TAG, "timed out waiting for loading", e); 275 } 276 ThreadUtils.postOnMainThread(() -> { 277 if (mSwitchBar.isChecked() 278 && findViewById(R.id.today_list).getVisibility() == GONE 279 && mSnoozeView.getVisibility() == GONE 280 && mDismissView.getVisibility() == GONE) { 281 mHistoryOn.setVisibility(GONE); 282 mHistoryEmpty.setVisibility(VISIBLE); 283 } 284 }); 285 }); 286 287 mUiEventLogger.log(NotificationHistoryEvent.NOTIFICATION_HISTORY_OPEN); 288 } 289 290 @Override onPause()291 public void onPause() { 292 try { 293 mListener.unregisterAsSystemService(); 294 } catch (RemoteException e) { 295 Log.e(TAG, "Cannot unregister listener", e); 296 } 297 mUiEventLogger.log(NotificationHistoryEvent.NOTIFICATION_HISTORY_CLOSE); 298 super.onPause(); 299 } 300 301 @Override onDestroy()302 public void onDestroy() { 303 if (mCountdownFuture != null) { 304 mCountdownFuture.cancel(true); 305 } 306 super.onDestroy(); 307 } 308 309 public static final int ROUND_CORNER_CENTER = 1; 310 public static final int ROUND_CORNER_TOP = 1 << 1; 311 public static final int ROUND_CORNER_BOTTOM = 1 << 2; 312 getRoundCornerDrawableRes(int cornerType)313 public static @DrawableRes int getRoundCornerDrawableRes(int cornerType) { 314 315 if ((cornerType & ROUND_CORNER_CENTER) == 0) { 316 return 0; 317 } 318 319 if (((cornerType & ROUND_CORNER_TOP) != 0) && ((cornerType & ROUND_CORNER_BOTTOM) == 0)) { 320 // the first 321 return com.android.settingslib.widget.theme.R.drawable.settingslib_round_background_top; 322 } else if (((cornerType & ROUND_CORNER_BOTTOM) != 0) 323 && ((cornerType & ROUND_CORNER_TOP) == 0)) { 324 // the last 325 return com.android.settingslib.widget.theme.R.drawable.settingslib_round_background_bottom; 326 } else if (((cornerType & ROUND_CORNER_TOP) != 0) 327 && ((cornerType & ROUND_CORNER_BOTTOM) != 0)) { 328 // the only one preference 329 return com.android.settingslib.widget.theme.R.drawable.settingslib_round_background; 330 } else { 331 // in the center 332 return com.android.settingslib.widget.theme.R.drawable.settingslib_round_background_center; 333 } 334 } 335 obtainThemeColor(@ttrRes int attrRes)336 private @ColorInt int obtainThemeColor(@AttrRes int attrRes) { 337 Resources.Theme theme = new ContextThemeWrapper(this, 338 android.R.style.Theme_DeviceDefault_DayNight).getTheme(); 339 try (TypedArray ta = theme.obtainStyledAttributes(new int[]{attrRes})) { 340 return ta == null ? 0 : ta.getColor(0, 0); 341 } 342 } 343 bindSwitch()344 private void bindSwitch() { 345 if (mSwitchBar != null) { 346 mSwitchBar.show(); 347 mSwitchBar.setTitle(getString(R.string.notification_history_toggle)); 348 try { 349 mSwitchBar.addOnSwitchChangeListener(mOnSwitchClickListener); 350 } catch (IllegalStateException e) { 351 // an exception is thrown if you try to add the listener twice 352 } 353 mSwitchBar.setChecked(Settings.Secure.getInt(getContentResolver(), 354 NOTIFICATION_HISTORY_ENABLED, 0) == 1); 355 toggleViews(mSwitchBar.isChecked()); 356 } 357 } 358 toggleViews(boolean isChecked)359 private void toggleViews(boolean isChecked) { 360 if (isChecked) { 361 mHistoryOff.setVisibility(GONE); 362 mHistoryOn.setVisibility(VISIBLE); 363 } else { 364 mHistoryOn.setVisibility(GONE); 365 mHistoryOff.setVisibility(VISIBLE); 366 mTodayView.removeAllViews(); 367 } 368 mHistoryEmpty.setVisibility(GONE); 369 } 370 371 private final OnCheckedChangeListener mOnSwitchClickListener = 372 (switchView, isChecked) -> { 373 int oldState = 0; 374 try { 375 oldState = Settings.Secure.getInt(getContentResolver(), 376 NOTIFICATION_HISTORY_ENABLED); 377 } catch (Settings.SettingNotFoundException ignored) { 378 } 379 final int newState = isChecked ? 1 : 0; 380 if (oldState != newState) { 381 Settings.Secure.putInt( 382 getContentResolver(), NOTIFICATION_HISTORY_ENABLED, newState); 383 mUiEventLogger.log(isChecked ? NotificationHistoryEvent.NOTIFICATION_HISTORY_ON 384 : NotificationHistoryEvent.NOTIFICATION_HISTORY_OFF); 385 Log.d(TAG, "onSwitchChange history to " + isChecked); 386 } 387 // Reset UI visibility to ensure it matches real state. 388 mHistoryOn.setVisibility(GONE); 389 if (isChecked) { 390 mHistoryEmpty.setVisibility(VISIBLE); 391 mHistoryOff.setVisibility(GONE); 392 } else { 393 mHistoryOff.setVisibility(VISIBLE); 394 mHistoryEmpty.setVisibility(GONE); 395 } 396 mTodayView.removeAllViews(); 397 }; 398 399 private final NotificationListenerService mListener = new NotificationListenerService() { 400 private RecyclerView mDismissedRv; 401 private RecyclerView mSnoozedRv; 402 403 @Override 404 public void onListenerConnected() { 405 StatusBarNotification[] snoozed = null; 406 StatusBarNotification[] dismissed = null; 407 try { 408 snoozed = getSnoozedNotifications(); 409 dismissed = mNm.getHistoricalNotificationsWithAttribution( 410 NotificationHistoryActivity.this.getPackageName(), 411 NotificationHistoryActivity.this.getAttributionTag(), 412 MAX_RECENT_DISMISS_ITEM_COUNT, false); 413 } catch (SecurityException | RemoteException e) { 414 Log.d(TAG, "OnPaused called while trying to retrieve notifications"); 415 } 416 417 mSnoozedRv = mSnoozeView.findViewById(R.id.notification_list); 418 LinearLayoutManager lm = new LinearLayoutManager(NotificationHistoryActivity.this); 419 mSnoozedRv.setLayoutManager(lm); 420 mSnoozedRv.setAdapter( 421 new NotificationSbnAdapter(NotificationHistoryActivity.this, mPm, mUm, 422 true, mUiEventLogger)); 423 mSnoozedRv.setNestedScrollingEnabled(false); 424 425 if (snoozed == null || snoozed.length == 0) { 426 mSnoozeView.setVisibility(GONE); 427 } else { 428 ((NotificationSbnAdapter) mSnoozedRv.getAdapter()).onRebuildComplete( 429 new ArrayList<>(Arrays.asList(snoozed))); 430 } 431 432 mDismissedRv = mDismissView.findViewById(R.id.notification_list); 433 LinearLayoutManager dismissLm = 434 new LinearLayoutManager(NotificationHistoryActivity.this); 435 mDismissedRv.setLayoutManager(dismissLm); 436 mDismissedRv.setAdapter( 437 new NotificationSbnAdapter(NotificationHistoryActivity.this, mPm, mUm, 438 false, mUiEventLogger)); 439 mDismissedRv.setNestedScrollingEnabled(false); 440 441 if (dismissed == null || dismissed.length == 0) { 442 mDismissView.setVisibility(GONE); 443 } else { 444 mDismissView.setVisibility(VISIBLE); 445 ((NotificationSbnAdapter) mDismissedRv.getAdapter()).onRebuildComplete( 446 new ArrayList<>(Arrays.asList(dismissed))); 447 } 448 449 mCountdownLatch.countDown(); 450 } 451 452 @Override 453 public void onNotificationPosted(StatusBarNotification sbn) { 454 // making lint happy 455 } 456 457 @Override 458 public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap, 459 int reason) { 460 if (reason == REASON_SNOOZED) { 461 ((NotificationSbnAdapter) mSnoozedRv.getAdapter()).addSbn(sbn); 462 mSnoozeView.setVisibility(VISIBLE); 463 } else { 464 ((NotificationSbnAdapter) mDismissedRv.getAdapter()).addSbn(sbn); 465 mDismissView.setVisibility(VISIBLE); 466 } 467 } 468 }; 469 } 470