NotificationUsageStats.java revision 061dee22df6af640db6f236d0929d6ccf9b455af
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 for (AggregatedStats stats : getAggregatedStatsLocked(notification)) { 84 stats.numRemovedByApp++; 85 stats.collect(notification.stats); 86 } 87 mSQLiteLog.logRemoved(notification); 88 } 89 90 /** 91 * Called when the user dismissed the notification via the UI. 92 */ 93 public synchronized void registerDismissedByUser(NotificationRecord notification) { 94 notification.stats.onDismiss(); 95 for (AggregatedStats stats : getAggregatedStatsLocked(notification)) { 96 stats.numDismissedByUser++; 97 stats.collect(notification.stats); 98 } 99 mSQLiteLog.logDismissed(notification); 100 } 101 102 /** 103 * Called when the user clicked the notification in the UI. 104 */ 105 public synchronized void registerClickedByUser(NotificationRecord notification) { 106 notification.stats.onClick(); 107 for (AggregatedStats stats : getAggregatedStatsLocked(notification)) { 108 stats.numClickedByUser++; 109 } 110 mSQLiteLog.logClicked(notification); 111 } 112 113 /** 114 * Called when the notification is canceled because the user clicked it. 115 * 116 * <p>Called after {@link #registerClickedByUser(NotificationRecord)}.</p> 117 */ 118 public synchronized void registerCancelDueToClick(NotificationRecord notification) { 119 // No explicit stats for this (the click has already been registered in 120 // registerClickedByUser), just make sure the single notification stats 121 // are folded up into aggregated stats. 122 for (AggregatedStats stats : getAggregatedStatsLocked(notification)) { 123 stats.collect(notification.stats); 124 } 125 } 126 127 /** 128 * Called when the notification is canceled due to unknown reasons. 129 * 130 * <p>Called for notifications of apps being uninstalled, for example.</p> 131 */ 132 public synchronized void registerCancelUnknown(NotificationRecord notification) { 133 // Fold up individual stats. 134 for (AggregatedStats stats : getAggregatedStatsLocked(notification)) { 135 stats.collect(notification.stats); 136 } 137 } 138 139 // Locked by this. 140 private AggregatedStats[] getAggregatedStatsLocked(NotificationRecord record) { 141 StatusBarNotification n = record.sbn; 142 143 String user = String.valueOf(n.getUserId()); 144 String userPackage = user + ":" + n.getPackageName(); 145 146 // TODO: Use pool of arrays. 147 return new AggregatedStats[] { 148 getOrCreateAggregatedStatsLocked(user), 149 getOrCreateAggregatedStatsLocked(userPackage), 150 getOrCreateAggregatedStatsLocked(n.getKey()), 151 }; 152 } 153 154 // Locked by this. 155 private AggregatedStats getOrCreateAggregatedStatsLocked(String key) { 156 AggregatedStats result = mStats.get(key); 157 if (result == null) { 158 result = new AggregatedStats(key); 159 mStats.put(key, result); 160 } 161 return result; 162 } 163 164 public synchronized void dump(PrintWriter pw, String indent) { 165 for (AggregatedStats as : mStats.values()) { 166 as.dump(pw, indent); 167 } 168 mSQLiteLog.dump(pw, indent); 169 } 170 171 /** 172 * Aggregated notification stats. 173 */ 174 private static class AggregatedStats { 175 public final String key; 176 177 // ---- Updated as the respective events occur. 178 public int numPostedByApp; 179 public int numUpdatedByApp; 180 public int numRemovedByApp; 181 public int numClickedByUser; 182 public int numDismissedByUser; 183 184 // ---- Updated when a notification is canceled. 185 public final Aggregate posttimeMs = new Aggregate(); 186 public final Aggregate posttimeToDismissMs = new Aggregate(); 187 public final Aggregate posttimeToFirstClickMs = new Aggregate(); 188 189 public AggregatedStats(String key) { 190 this.key = key; 191 } 192 193 public void collect(SingleNotificationStats singleNotificationStats) { 194 posttimeMs.addSample( 195 SystemClock.elapsedRealtime() - singleNotificationStats.posttimeElapsedMs); 196 if (singleNotificationStats.posttimeToDismissMs >= 0) { 197 posttimeToDismissMs.addSample(singleNotificationStats.posttimeToDismissMs); 198 } 199 if (singleNotificationStats.posttimeToFirstClickMs >= 0) { 200 posttimeToFirstClickMs.addSample(singleNotificationStats.posttimeToFirstClickMs); 201 } 202 } 203 204 public void dump(PrintWriter pw, String indent) { 205 pw.println(toStringWithIndent(indent)); 206 } 207 208 @Override 209 public String toString() { 210 return toStringWithIndent(""); 211 } 212 213 private String toStringWithIndent(String indent) { 214 return indent + "AggregatedStats{\n" + 215 indent + " key='" + key + "',\n" + 216 indent + " numPostedByApp=" + numPostedByApp + ",\n" + 217 indent + " numUpdatedByApp=" + numUpdatedByApp + ",\n" + 218 indent + " numRemovedByApp=" + numRemovedByApp + ",\n" + 219 indent + " numClickedByUser=" + numClickedByUser + ",\n" + 220 indent + " numDismissedByUser=" + numDismissedByUser + ",\n" + 221 indent + " posttimeMs=" + posttimeMs + ",\n" + 222 indent + " posttimeToDismissMs=" + posttimeToDismissMs + ",\n" + 223 indent + " posttimeToFirstClickMs=" + posttimeToFirstClickMs + ",\n" + 224 indent + "}"; 225 } 226 } 227 228 /** 229 * Tracks usage of an individual notification that is currently active. 230 */ 231 public static class SingleNotificationStats { 232 /** SystemClock.elapsedRealtime() when the notification was posted. */ 233 public long posttimeElapsedMs = -1; 234 /** Elapsed time since the notification was posted until it was first clicked, or -1. */ 235 public long posttimeToFirstClickMs = -1; 236 /** Elpased time since the notification was posted until it was dismissed by the user. */ 237 public long posttimeToDismissMs = -1; 238 239 /** 240 * Called when the user clicked the notification. 241 */ 242 public void onClick() { 243 if (posttimeToFirstClickMs < 0) { 244 posttimeToFirstClickMs = SystemClock.elapsedRealtime() - posttimeElapsedMs; 245 } 246 } 247 248 /** 249 * Called when the user removed the notification. 250 */ 251 public void onDismiss() { 252 if (posttimeToDismissMs < 0) { 253 posttimeToDismissMs = SystemClock.elapsedRealtime() - posttimeElapsedMs; 254 } 255 } 256 257 @Override 258 public String toString() { 259 return "SingleNotificationStats{" + 260 "posttimeElapsedMs=" + posttimeElapsedMs + 261 ", posttimeToFirstClickMs=" + posttimeToFirstClickMs + 262 ", posttimeToDismissMs=" + posttimeToDismissMs + 263 '}'; 264 } 265 } 266 267 /** 268 * Aggregates long samples to sum and averages. 269 */ 270 public static class Aggregate { 271 long numSamples; 272 double avg; 273 double sum2; 274 double var; 275 276 public void addSample(long sample) { 277 // Welford's "Method for Calculating Corrected Sums of Squares" 278 // http://www.jstor.org/stable/1266577?seq=2 279 numSamples++; 280 final double n = numSamples; 281 final double delta = sample - avg; 282 avg += (1.0 / n) * delta; 283 sum2 += ((n - 1) / n) * delta * delta; 284 final double divisor = numSamples == 1 ? 1.0 : n - 1.0; 285 var = sum2 / divisor; 286 } 287 288 @Override 289 public String toString() { 290 return "Aggregate{" + 291 "numSamples=" + numSamples + 292 ", avg=" + avg + 293 ", var=" + var + 294 '}'; 295 } 296 } 297 298 private static class SQLiteLog { 299 private static final String TAG = "NotificationSQLiteLog"; 300 301 // Message types passed to the background handler. 302 private static final int MSG_POST = 1; 303 private static final int MSG_CLICK = 2; 304 private static final int MSG_REMOVE = 3; 305 private static final int MSG_DISMISS = 4; 306 307 private static final String DB_NAME = "notification_log.db"; 308 private static final int DB_VERSION = 1; 309 310 /** Age in ms after which events are pruned from the DB. */ 311 private static final long HORIZON_MS = 7 * 24 * 60 * 60 * 1000L; // 1 week 312 /** Delay between pruning the DB. Used to throttle pruning. */ 313 private static final long PRUNE_MIN_DELAY_MS = 6 * 60 * 60 * 1000L; // 6 hours 314 /** Mininum number of writes between pruning the DB. Used to throttle pruning. */ 315 private static final long PRUNE_MIN_WRITES = 1024; 316 317 // Table 'log' 318 private static final String TAB_LOG = "log"; 319 private static final String COL_EVENT_USER_ID = "event_user_id"; 320 private static final String COL_EVENT_TYPE = "event_type"; 321 private static final String COL_EVENT_TIME = "event_time_ms"; 322 private static final String COL_KEY = "key"; 323 private static final String COL_PKG = "pkg"; 324 private static final String COL_NOTIFICATION_ID = "nid"; 325 private static final String COL_TAG = "tag"; 326 private static final String COL_WHEN_MS = "when_ms"; 327 private static final String COL_DEFAULTS = "defaults"; 328 private static final String COL_FLAGS = "flags"; 329 private static final String COL_PRIORITY = "priority"; 330 private static final String COL_CATEGORY = "category"; 331 private static final String COL_ACTION_COUNT = "action_count"; 332 333 private static final int EVENT_TYPE_POST = 1; 334 private static final int EVENT_TYPE_CLICK = 2; 335 private static final int EVENT_TYPE_REMOVE = 3; 336 private static final int EVENT_TYPE_DISMISS = 4; 337 338 private static long sLastPruneMs; 339 private static long sNumWrites; 340 341 private final SQLiteOpenHelper mHelper; 342 private final Handler mWriteHandler; 343 344 private static final long DAY_MS = 24 * 60 * 60 * 1000; 345 346 public SQLiteLog(Context context) { 347 HandlerThread backgroundThread = new HandlerThread("notification-sqlite-log", 348 android.os.Process.THREAD_PRIORITY_BACKGROUND); 349 backgroundThread.start(); 350 mWriteHandler = new Handler(backgroundThread.getLooper()) { 351 @Override 352 public void handleMessage(Message msg) { 353 NotificationRecord r = (NotificationRecord) msg.obj; 354 long nowMs = System.currentTimeMillis(); 355 switch (msg.what) { 356 case MSG_POST: 357 writeEvent(r.sbn.getPostTime(), EVENT_TYPE_POST, r, true); 358 break; 359 case MSG_CLICK: 360 writeEvent(nowMs, EVENT_TYPE_CLICK, r, false); 361 break; 362 case MSG_REMOVE: 363 writeEvent(nowMs, EVENT_TYPE_REMOVE, r, false); 364 break; 365 case MSG_DISMISS: 366 writeEvent(nowMs, EVENT_TYPE_DISMISS, r, false); 367 break; 368 default: 369 Log.wtf(TAG, "Unknown message type: " + msg.what); 370 break; 371 } 372 } 373 }; 374 mHelper = new SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) { 375 @Override 376 public void onCreate(SQLiteDatabase db) { 377 db.execSQL("CREATE TABLE " + TAB_LOG + " (" + 378 "_id INTEGER PRIMARY KEY AUTOINCREMENT," + 379 COL_EVENT_USER_ID + " INT," + 380 COL_EVENT_TYPE + " INT," + 381 COL_EVENT_TIME + " INT," + 382 COL_KEY + " TEXT," + 383 COL_PKG + " TEXT," + 384 COL_NOTIFICATION_ID + " INT," + 385 COL_TAG + " TEXT," + 386 COL_WHEN_MS + " INT," + 387 COL_DEFAULTS + " INT," + 388 COL_FLAGS + " INT," + 389 COL_PRIORITY + " INT," + 390 COL_CATEGORY + " TEXT," + 391 COL_ACTION_COUNT + " INT" + 392 ")"); 393 } 394 395 @Override 396 public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { 397 db.execSQL("DROP TABLE IF EXISTS " + TAB_LOG); 398 onCreate(db); 399 } 400 }; 401 } 402 403 public void logPosted(NotificationRecord notification) { 404 mWriteHandler.sendMessage(mWriteHandler.obtainMessage(MSG_POST, notification)); 405 } 406 407 public void logClicked(NotificationRecord notification) { 408 mWriteHandler.sendMessage(mWriteHandler.obtainMessage(MSG_CLICK, notification)); 409 } 410 411 public void logRemoved(NotificationRecord notification) { 412 mWriteHandler.sendMessage(mWriteHandler.obtainMessage(MSG_REMOVE, notification)); 413 } 414 415 public void logDismissed(NotificationRecord notification) { 416 mWriteHandler.sendMessage(mWriteHandler.obtainMessage(MSG_DISMISS, notification)); 417 } 418 419 public void printPostFrequencies(PrintWriter pw, String indent) { 420 SQLiteDatabase db = mHelper.getReadableDatabase(); 421 long nowMs = System.currentTimeMillis(); 422 String q = "SELECT " + 423 COL_EVENT_USER_ID + ", " + 424 COL_PKG + ", " + 425 // Bucket by day by looking at 'floor((nowMs - eventTimeMs) / dayMs)' 426 "CAST(((" + nowMs + " - " + COL_EVENT_TIME + ") / " + DAY_MS + ") AS int) " + 427 "AS day, " + 428 "COUNT(*) AS cnt " + 429 "FROM " + TAB_LOG + " " + 430 "WHERE " + 431 COL_EVENT_TYPE + "=" + EVENT_TYPE_POST + " " + 432 "GROUP BY " + COL_EVENT_USER_ID + ", day, " + COL_PKG; 433 Cursor cursor = db.rawQuery(q, null); 434 try { 435 for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) { 436 int userId = cursor.getInt(0); 437 String pkg = cursor.getString(1); 438 int day = cursor.getInt(2); 439 int count = cursor.getInt(3); 440 pw.println(indent + "post_frequency{user_id=" + userId + ",pkg=" + pkg + 441 ",day=" + day + ",count=" + count + "}"); 442 } 443 } finally { 444 cursor.close(); 445 } 446 } 447 448 private void writeEvent(long eventTimeMs, int eventType, NotificationRecord r, 449 boolean populateNotificationDetails) { 450 ContentValues cv = new ContentValues(); 451 cv.put(COL_EVENT_USER_ID, r.sbn.getUser().getIdentifier()); 452 cv.put(COL_EVENT_TIME, eventTimeMs); 453 cv.put(COL_EVENT_TYPE, eventType); 454 putNotificationIdentifiers(r, cv); 455 if (populateNotificationDetails) { 456 putNotificationDetails(r, cv); 457 } 458 SQLiteDatabase db = mHelper.getWritableDatabase(); 459 if (db.insert(TAB_LOG, null, cv) < 0) { 460 Log.wtf(TAG, "Error while trying to insert values: " + cv); 461 } 462 sNumWrites++; 463 pruneIfNecessary(db); 464 } 465 466 private void pruneIfNecessary(SQLiteDatabase db) { 467 // Prune if we haven't in a while. 468 long nowMs = System.currentTimeMillis(); 469 if (sNumWrites > PRUNE_MIN_WRITES || 470 nowMs - sLastPruneMs > PRUNE_MIN_DELAY_MS) { 471 sNumWrites = 0; 472 sLastPruneMs = nowMs; 473 long horizonStartMs = nowMs - HORIZON_MS; 474 int deletedRows = db.delete(TAB_LOG, COL_EVENT_TIME + " < ?", 475 new String[] { String.valueOf(horizonStartMs) }); 476 Log.d(TAG, "Pruned event entries: " + deletedRows); 477 } 478 } 479 480 private static void putNotificationIdentifiers(NotificationRecord r, ContentValues outCv) { 481 outCv.put(COL_KEY, r.sbn.getKey()); 482 outCv.put(COL_PKG, r.sbn.getPackageName()); 483 } 484 485 private static void putNotificationDetails(NotificationRecord r, ContentValues outCv) { 486 outCv.put(COL_NOTIFICATION_ID, r.sbn.getId()); 487 if (r.sbn.getTag() != null) { 488 outCv.put(COL_TAG, r.sbn.getTag()); 489 } 490 outCv.put(COL_WHEN_MS, r.sbn.getPostTime()); 491 outCv.put(COL_FLAGS, r.getNotification().flags); 492 outCv.put(COL_PRIORITY, r.getNotification().priority); 493 if (r.getNotification().category != null) { 494 outCv.put(COL_CATEGORY, r.getNotification().category); 495 } 496 outCv.put(COL_ACTION_COUNT, r.getNotification().actions != null ? 497 r.getNotification().actions.length : 0); 498 } 499 500 public void dump(PrintWriter pw, String indent) { 501 printPostFrequencies(pw, indent); 502 } 503 } 504} 505