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