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