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