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