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