NotificationUsageStats.java revision ffeb0c38145709b51dee0254b770bccc9083ea5c
1/* 2 * Copyright (C) 2014 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 17package com.android.server.notification; 18 19import com.android.server.notification.NotificationManagerService.NotificationRecord; 20 21import android.content.ContentValues; 22import android.content.Context; 23import android.database.Cursor; 24import android.database.sqlite.SQLiteDatabase; 25import android.database.sqlite.SQLiteOpenHelper; 26import android.os.Handler; 27import android.os.HandlerThread; 28import android.os.Message; 29import android.os.SystemClock; 30import android.service.notification.StatusBarNotification; 31import android.util.Log; 32 33import java.io.PrintWriter; 34import java.util.HashMap; 35import java.util.Map; 36 37/** 38 * Keeps track of notification activity, display, and user interaction. 39 * 40 * <p>This class receives signals from NoMan and keeps running stats of 41 * notification usage. Some metrics are updated as events occur. Others, namely 42 * those involving durations, are updated as the notification is canceled.</p> 43 * 44 * <p>This class is thread-safe.</p> 45 * 46 * {@hide} 47 */ 48public class NotificationUsageStats { 49 // Guarded by synchronized(this). 50 private final Map<String, AggregatedStats> mStats = new HashMap<String, AggregatedStats>(); 51 private final SQLiteLog mSQLiteLog; 52 53 public NotificationUsageStats(Context context) { 54 mSQLiteLog = new SQLiteLog(context); 55 } 56 57 /** 58 * Called when a notification has been posted. 59 */ 60 public synchronized void registerPostedByApp(NotificationRecord notification) { 61 notification.stats = new SingleNotificationStats(); 62 notification.stats.posttimeElapsedMs = SystemClock.elapsedRealtime(); 63 for (AggregatedStats stats : getAggregatedStatsLocked(notification)) { 64 stats.numPostedByApp++; 65 } 66 mSQLiteLog.logPosted(notification); 67 } 68 69 /** 70 * Called when a notification has been updated. 71 */ 72 public void registerUpdatedByApp(NotificationRecord notification, NotificationRecord old) { 73 notification.stats = old.stats; 74 for (AggregatedStats stats : getAggregatedStatsLocked(notification)) { 75 stats.numUpdatedByApp++; 76 } 77 } 78 79 /** 80 * Called when the originating app removed the notification programmatically. 81 */ 82 public synchronized void registerRemovedByApp(NotificationRecord notification) { 83 notification.stats.onRemoved(); 84 for (AggregatedStats stats : getAggregatedStatsLocked(notification)) { 85 stats.numRemovedByApp++; 86 stats.collect(notification.stats); 87 } 88 mSQLiteLog.logRemoved(notification); 89 } 90 91 /** 92 * Called when the user dismissed the notification via the UI. 93 */ 94 public synchronized void registerDismissedByUser(NotificationRecord notification) { 95 notification.stats.onDismiss(); 96 for (AggregatedStats stats : getAggregatedStatsLocked(notification)) { 97 stats.numDismissedByUser++; 98 stats.collect(notification.stats); 99 } 100 mSQLiteLog.logDismissed(notification); 101 } 102 103 /** 104 * Called when the user clicked the notification in the UI. 105 */ 106 public synchronized void registerClickedByUser(NotificationRecord notification) { 107 notification.stats.onClick(); 108 for (AggregatedStats stats : getAggregatedStatsLocked(notification)) { 109 stats.numClickedByUser++; 110 } 111 mSQLiteLog.logClicked(notification); 112 } 113 114 /** 115 * Called when the notification is canceled because the user clicked it. 116 * 117 * <p>Called after {@link #registerClickedByUser(NotificationRecord)}.</p> 118 */ 119 public synchronized void registerCancelDueToClick(NotificationRecord notification) { 120 notification.stats.onCancel(); 121 for (AggregatedStats stats : getAggregatedStatsLocked(notification)) { 122 stats.collect(notification.stats); 123 } 124 } 125 126 /** 127 * Called when the notification is canceled due to unknown reasons. 128 * 129 * <p>Called for notifications of apps being uninstalled, for example.</p> 130 */ 131 public synchronized void registerCancelUnknown(NotificationRecord notification) { 132 notification.stats.onCancel(); 133 for (AggregatedStats stats : getAggregatedStatsLocked(notification)) { 134 stats.collect(notification.stats); 135 } 136 } 137 138 // Locked by this. 139 private AggregatedStats[] getAggregatedStatsLocked(NotificationRecord record) { 140 StatusBarNotification n = record.sbn; 141 142 String user = String.valueOf(n.getUserId()); 143 String userPackage = user + ":" + n.getPackageName(); 144 145 // TODO: Use pool of arrays. 146 return new AggregatedStats[] { 147 getOrCreateAggregatedStatsLocked(user), 148 getOrCreateAggregatedStatsLocked(userPackage), 149 getOrCreateAggregatedStatsLocked(n.getKey()), 150 }; 151 } 152 153 // Locked by this. 154 private AggregatedStats getOrCreateAggregatedStatsLocked(String key) { 155 AggregatedStats result = mStats.get(key); 156 if (result == null) { 157 result = new AggregatedStats(key); 158 mStats.put(key, result); 159 } 160 return result; 161 } 162 163 public synchronized void dump(PrintWriter pw, String indent) { 164 for (AggregatedStats as : mStats.values()) { 165 as.dump(pw, indent); 166 } 167 mSQLiteLog.dump(pw, indent); 168 } 169 170 /** 171 * Aggregated notification stats. 172 */ 173 private static class AggregatedStats { 174 public final String key; 175 176 // ---- Updated as the respective events occur. 177 public int numPostedByApp; 178 public int numUpdatedByApp; 179 public int numRemovedByApp; 180 public int numClickedByUser; 181 public int numDismissedByUser; 182 183 // ---- Updated when a notification is canceled. 184 public final Aggregate posttimeMs = new Aggregate(); 185 public final Aggregate posttimeToDismissMs = new Aggregate(); 186 public final Aggregate posttimeToFirstClickMs = new Aggregate(); 187 public final Aggregate airtimeCount = new Aggregate(); 188 public final Aggregate airtimeMs = new Aggregate(); 189 public final Aggregate posttimeToFirstAirtimeMs = new Aggregate(); 190 191 public AggregatedStats(String key) { 192 this.key = key; 193 } 194 195 public void collect(SingleNotificationStats singleNotificationStats) { 196 posttimeMs.addSample( 197 SystemClock.elapsedRealtime() - singleNotificationStats.posttimeElapsedMs); 198 if (singleNotificationStats.posttimeToDismissMs >= 0) { 199 posttimeToDismissMs.addSample(singleNotificationStats.posttimeToDismissMs); 200 } 201 if (singleNotificationStats.posttimeToFirstClickMs >= 0) { 202 posttimeToFirstClickMs.addSample(singleNotificationStats.posttimeToFirstClickMs); 203 } 204 airtimeCount.addSample(singleNotificationStats.airtimeCount); 205 if (singleNotificationStats.airtimeMs >= 0) { 206 airtimeMs.addSample(singleNotificationStats.airtimeMs); 207 } 208 if (singleNotificationStats.posttimeToFirstAirtimeMs >= 0) { 209 posttimeToFirstAirtimeMs.addSample( 210 singleNotificationStats.posttimeToFirstAirtimeMs); 211 } 212 } 213 214 public void dump(PrintWriter pw, String indent) { 215 pw.println(toStringWithIndent(indent)); 216 } 217 218 @Override 219 public String toString() { 220 return toStringWithIndent(""); 221 } 222 223 private String toStringWithIndent(String indent) { 224 return indent + "AggregatedStats{\n" + 225 indent + " key='" + key + "',\n" + 226 indent + " numPostedByApp=" + numPostedByApp + ",\n" + 227 indent + " numUpdatedByApp=" + numUpdatedByApp + ",\n" + 228 indent + " numRemovedByApp=" + numRemovedByApp + ",\n" + 229 indent + " numClickedByUser=" + numClickedByUser + ",\n" + 230 indent + " numDismissedByUser=" + numDismissedByUser + ",\n" + 231 indent + " posttimeMs=" + posttimeMs + ",\n" + 232 indent + " posttimeToDismissMs=" + posttimeToDismissMs + ",\n" + 233 indent + " posttimeToFirstClickMs=" + posttimeToFirstClickMs + ",\n" + 234 indent + " airtimeCount=" + airtimeCount + ",\n" + 235 indent + " airtimeMs=" + airtimeMs + ",\n" + 236 indent + " posttimeToFirstAirtimeMs=" + posttimeToFirstAirtimeMs + ",\n" + 237 indent + "}"; 238 } 239 } 240 241 /** 242 * Tracks usage of an individual notification that is currently active. 243 */ 244 public static class SingleNotificationStats { 245 /** SystemClock.elapsedRealtime() when the notification was posted. */ 246 public long posttimeElapsedMs = -1; 247 /** Elapsed time since the notification was posted until it was first clicked, or -1. */ 248 public long posttimeToFirstClickMs = -1; 249 /** Elpased time since the notification was posted until it was dismissed by the user. */ 250 public long posttimeToDismissMs = -1; 251 /** Number of times the notification has been made visible. */ 252 public long airtimeCount = 0; 253 /** Time in ms between the notification was posted and first shown; -1 if never shown. */ 254 public long posttimeToFirstAirtimeMs = -1; 255 /** 256 * If currently visible, SystemClock.elapsedRealtime() when the notification was made 257 * visible; -1 otherwise. 258 */ 259 public long currentAirtimeStartElapsedMs = -1; 260 /** Accumulated visible time. */ 261 public long airtimeMs = 0; 262 263 public long getCurrentPosttimeMs() { 264 if (posttimeElapsedMs < 0) { 265 return 0; 266 } 267 return SystemClock.elapsedRealtime() - posttimeElapsedMs; 268 } 269 270 public long getCurrentAirtimeMs() { 271 long result = airtimeMs; 272 // Add incomplete airtime if currently shown. 273 if (currentAirtimeStartElapsedMs >= 0) { 274 result+= (SystemClock.elapsedRealtime() - currentAirtimeStartElapsedMs); 275 } 276 return result; 277 } 278 279 /** 280 * Called when the user clicked the notification. 281 */ 282 public void onClick() { 283 if (posttimeToFirstClickMs < 0) { 284 posttimeToFirstClickMs = SystemClock.elapsedRealtime() - posttimeElapsedMs; 285 } 286 } 287 288 /** 289 * Called when the user removed the notification. 290 */ 291 public void onDismiss() { 292 if (posttimeToDismissMs < 0) { 293 posttimeToDismissMs = SystemClock.elapsedRealtime() - posttimeElapsedMs; 294 } 295 finish(); 296 } 297 298 public void onCancel() { 299 finish(); 300 } 301 302 public void onRemoved() { 303 finish(); 304 } 305 306 public void onVisibilityChanged(boolean visible) { 307 long elapsedNowMs = SystemClock.elapsedRealtime(); 308 if (visible) { 309 if (currentAirtimeStartElapsedMs < 0) { 310 airtimeCount++; 311 currentAirtimeStartElapsedMs = elapsedNowMs; 312 } 313 if (posttimeToFirstAirtimeMs < 0) { 314 posttimeToFirstAirtimeMs = elapsedNowMs - posttimeElapsedMs; 315 } 316 } else { 317 if (currentAirtimeStartElapsedMs >= 0) { 318 airtimeMs += (elapsedNowMs - currentAirtimeStartElapsedMs); 319 currentAirtimeStartElapsedMs = -1; 320 } 321 } 322 } 323 324 /** The notification is leaving the system. Finalize. */ 325 public void finish() { 326 onVisibilityChanged(false); 327 } 328 329 @Override 330 public String toString() { 331 return "SingleNotificationStats{" + 332 "posttimeElapsedMs=" + posttimeElapsedMs + 333 ", posttimeToFirstClickMs=" + posttimeToFirstClickMs + 334 ", posttimeToDismissMs=" + posttimeToDismissMs + 335 ", airtimeCount=" + airtimeCount + 336 ", airtimeMs=" + airtimeMs + 337 ", currentAirtimeStartElapsedMs=" + currentAirtimeStartElapsedMs + 338 '}'; 339 } 340 } 341 342 /** 343 * Aggregates long samples to sum and averages. 344 */ 345 public static class Aggregate { 346 long numSamples; 347 double avg; 348 double sum2; 349 double var; 350 351 public void addSample(long sample) { 352 // Welford's "Method for Calculating Corrected Sums of Squares" 353 // http://www.jstor.org/stable/1266577?seq=2 354 numSamples++; 355 final double n = numSamples; 356 final double delta = sample - avg; 357 avg += (1.0 / n) * delta; 358 sum2 += ((n - 1) / n) * delta * delta; 359 final double divisor = numSamples == 1 ? 1.0 : n - 1.0; 360 var = sum2 / divisor; 361 } 362 363 @Override 364 public String toString() { 365 return "Aggregate{" + 366 "numSamples=" + numSamples + 367 ", avg=" + avg + 368 ", var=" + var + 369 '}'; 370 } 371 } 372 373 private static class SQLiteLog { 374 private static final String TAG = "NotificationSQLiteLog"; 375 376 // Message types passed to the background handler. 377 private static final int MSG_POST = 1; 378 private static final int MSG_CLICK = 2; 379 private static final int MSG_REMOVE = 3; 380 private static final int MSG_DISMISS = 4; 381 382 private static final String DB_NAME = "notification_log.db"; 383 private static final int DB_VERSION = 2; 384 385 /** Age in ms after which events are pruned from the DB. */ 386 private static final long HORIZON_MS = 7 * 24 * 60 * 60 * 1000L; // 1 week 387 /** Delay between pruning the DB. Used to throttle pruning. */ 388 private static final long PRUNE_MIN_DELAY_MS = 6 * 60 * 60 * 1000L; // 6 hours 389 /** Mininum number of writes between pruning the DB. Used to throttle pruning. */ 390 private static final long PRUNE_MIN_WRITES = 1024; 391 392 // Table 'log' 393 private static final String TAB_LOG = "log"; 394 private static final String COL_EVENT_USER_ID = "event_user_id"; 395 private static final String COL_EVENT_TYPE = "event_type"; 396 private static final String COL_EVENT_TIME = "event_time_ms"; 397 private static final String COL_KEY = "key"; 398 private static final String COL_PKG = "pkg"; 399 private static final String COL_NOTIFICATION_ID = "nid"; 400 private static final String COL_TAG = "tag"; 401 private static final String COL_WHEN_MS = "when_ms"; 402 private static final String COL_DEFAULTS = "defaults"; 403 private static final String COL_FLAGS = "flags"; 404 private static final String COL_PRIORITY = "priority"; 405 private static final String COL_CATEGORY = "category"; 406 private static final String COL_ACTION_COUNT = "action_count"; 407 private static final String COL_POSTTIME_MS = "posttime_ms"; 408 private static final String COL_AIRTIME_MS = "airtime_ms"; 409 410 private static final int EVENT_TYPE_POST = 1; 411 private static final int EVENT_TYPE_CLICK = 2; 412 private static final int EVENT_TYPE_REMOVE = 3; 413 private static final int EVENT_TYPE_DISMISS = 4; 414 415 private static long sLastPruneMs; 416 private static long sNumWrites; 417 418 private final SQLiteOpenHelper mHelper; 419 private final Handler mWriteHandler; 420 421 private static final long DAY_MS = 24 * 60 * 60 * 1000; 422 423 public SQLiteLog(Context context) { 424 HandlerThread backgroundThread = new HandlerThread("notification-sqlite-log", 425 android.os.Process.THREAD_PRIORITY_BACKGROUND); 426 backgroundThread.start(); 427 mWriteHandler = new Handler(backgroundThread.getLooper()) { 428 @Override 429 public void handleMessage(Message msg) { 430 NotificationRecord r = (NotificationRecord) msg.obj; 431 long nowMs = System.currentTimeMillis(); 432 switch (msg.what) { 433 case MSG_POST: 434 writeEvent(r.sbn.getPostTime(), EVENT_TYPE_POST, r); 435 break; 436 case MSG_CLICK: 437 writeEvent(nowMs, EVENT_TYPE_CLICK, r); 438 break; 439 case MSG_REMOVE: 440 writeEvent(nowMs, EVENT_TYPE_REMOVE, r); 441 break; 442 case MSG_DISMISS: 443 writeEvent(nowMs, EVENT_TYPE_DISMISS, r); 444 break; 445 default: 446 Log.wtf(TAG, "Unknown message type: " + msg.what); 447 break; 448 } 449 } 450 }; 451 mHelper = new SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) { 452 @Override 453 public void onCreate(SQLiteDatabase db) { 454 db.execSQL("CREATE TABLE " + TAB_LOG + " (" + 455 "_id INTEGER PRIMARY KEY AUTOINCREMENT," + 456 COL_EVENT_USER_ID + " INT," + 457 COL_EVENT_TYPE + " INT," + 458 COL_EVENT_TIME + " INT," + 459 COL_KEY + " TEXT," + 460 COL_PKG + " TEXT," + 461 COL_NOTIFICATION_ID + " INT," + 462 COL_TAG + " TEXT," + 463 COL_WHEN_MS + " INT," + 464 COL_DEFAULTS + " INT," + 465 COL_FLAGS + " INT," + 466 COL_PRIORITY + " INT," + 467 COL_CATEGORY + " TEXT," + 468 COL_ACTION_COUNT + " INT," + 469 COL_POSTTIME_MS + " INT," + 470 COL_AIRTIME_MS + " INT" + 471 ")"); 472 } 473 474 @Override 475 public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { 476 switch (oldVersion) { 477 case 1: 478 // Add COL_POSTTIME_MS, COL_AIRTIME_MS columns, 479 db.execSQL("ALTER TABLE " + TAB_LOG + " ADD COLUMN " + 480 COL_POSTTIME_MS + " INT"); 481 db.execSQL("ALTER TABLE " + TAB_LOG + " ADD COLUMN " + 482 COL_AIRTIME_MS + " INT"); 483 } 484 } 485 }; 486 } 487 488 public void logPosted(NotificationRecord notification) { 489 mWriteHandler.sendMessage(mWriteHandler.obtainMessage(MSG_POST, notification)); 490 } 491 492 public void logClicked(NotificationRecord notification) { 493 mWriteHandler.sendMessage(mWriteHandler.obtainMessage(MSG_CLICK, notification)); 494 } 495 496 public void logRemoved(NotificationRecord notification) { 497 mWriteHandler.sendMessage(mWriteHandler.obtainMessage(MSG_REMOVE, notification)); 498 } 499 500 public void logDismissed(NotificationRecord notification) { 501 mWriteHandler.sendMessage(mWriteHandler.obtainMessage(MSG_DISMISS, notification)); 502 } 503 504 public void printPostFrequencies(PrintWriter pw, String indent) { 505 SQLiteDatabase db = mHelper.getReadableDatabase(); 506 long nowMs = System.currentTimeMillis(); 507 String q = "SELECT " + 508 COL_EVENT_USER_ID + ", " + 509 COL_PKG + ", " + 510 // Bucket by day by looking at 'floor((nowMs - eventTimeMs) / dayMs)' 511 "CAST(((" + nowMs + " - " + COL_EVENT_TIME + ") / " + DAY_MS + ") AS int) " + 512 "AS day, " + 513 "COUNT(*) AS cnt " + 514 "FROM " + TAB_LOG + " " + 515 "WHERE " + 516 COL_EVENT_TYPE + "=" + EVENT_TYPE_POST + " " + 517 "GROUP BY " + COL_EVENT_USER_ID + ", day, " + COL_PKG; 518 Cursor cursor = db.rawQuery(q, null); 519 try { 520 for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) { 521 int userId = cursor.getInt(0); 522 String pkg = cursor.getString(1); 523 int day = cursor.getInt(2); 524 int count = cursor.getInt(3); 525 pw.println(indent + "post_frequency{user_id=" + userId + ",pkg=" + pkg + 526 ",day=" + day + ",count=" + count + "}"); 527 } 528 } finally { 529 cursor.close(); 530 } 531 } 532 533 private void writeEvent(long eventTimeMs, int eventType, NotificationRecord r) { 534 ContentValues cv = new ContentValues(); 535 cv.put(COL_EVENT_USER_ID, r.sbn.getUser().getIdentifier()); 536 cv.put(COL_EVENT_TIME, eventTimeMs); 537 cv.put(COL_EVENT_TYPE, eventType); 538 putNotificationIdentifiers(r, cv); 539 if (eventType == EVENT_TYPE_POST) { 540 putNotificationDetails(r, cv); 541 } else { 542 putPosttimeAirtime(r, cv); 543 } 544 SQLiteDatabase db = mHelper.getWritableDatabase(); 545 if (db.insert(TAB_LOG, null, cv) < 0) { 546 Log.wtf(TAG, "Error while trying to insert values: " + cv); 547 } 548 sNumWrites++; 549 pruneIfNecessary(db); 550 } 551 552 private void pruneIfNecessary(SQLiteDatabase db) { 553 // Prune if we haven't in a while. 554 long nowMs = System.currentTimeMillis(); 555 if (sNumWrites > PRUNE_MIN_WRITES || 556 nowMs - sLastPruneMs > PRUNE_MIN_DELAY_MS) { 557 sNumWrites = 0; 558 sLastPruneMs = nowMs; 559 long horizonStartMs = nowMs - HORIZON_MS; 560 int deletedRows = db.delete(TAB_LOG, COL_EVENT_TIME + " < ?", 561 new String[] { String.valueOf(horizonStartMs) }); 562 Log.d(TAG, "Pruned event entries: " + deletedRows); 563 } 564 } 565 566 private static void putNotificationIdentifiers(NotificationRecord r, ContentValues outCv) { 567 outCv.put(COL_KEY, r.sbn.getKey()); 568 outCv.put(COL_PKG, r.sbn.getPackageName()); 569 } 570 571 private static void putNotificationDetails(NotificationRecord r, ContentValues outCv) { 572 outCv.put(COL_NOTIFICATION_ID, r.sbn.getId()); 573 if (r.sbn.getTag() != null) { 574 outCv.put(COL_TAG, r.sbn.getTag()); 575 } 576 outCv.put(COL_WHEN_MS, r.sbn.getPostTime()); 577 outCv.put(COL_FLAGS, r.getNotification().flags); 578 outCv.put(COL_PRIORITY, r.getNotification().priority); 579 if (r.getNotification().category != null) { 580 outCv.put(COL_CATEGORY, r.getNotification().category); 581 } 582 outCv.put(COL_ACTION_COUNT, r.getNotification().actions != null ? 583 r.getNotification().actions.length : 0); 584 } 585 586 private static void putPosttimeAirtime(NotificationRecord r, ContentValues outCv) { 587 outCv.put(COL_POSTTIME_MS, r.stats.getCurrentPosttimeMs()); 588 outCv.put(COL_AIRTIME_MS, r.stats.getCurrentAirtimeMs()); 589 } 590 591 public void dump(PrintWriter pw, String indent) { 592 printPostFrequencies(pw, indent); 593 } 594 } 595} 596