/* * Copyright (C) 2014 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License */ package com.android.server.notification; import static android.service.notification.NotificationListenerService.Ranking.IMPORTANCE_HIGH; import android.app.Notification; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.os.Handler; import android.os.HandlerThread; import android.os.Message; import android.os.SystemClock; import android.text.TextUtils; import android.util.ArraySet; import android.util.Log; import com.android.internal.logging.MetricsLogger; import com.android.server.notification.NotificationManagerService.DumpFilter; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.PrintWriter; import java.util.ArrayDeque; import java.util.Calendar; import java.util.GregorianCalendar; import java.util.HashMap; import java.util.Map; import java.util.Set; /** * Keeps track of notification activity, display, and user interaction. * *

This class receives signals from NoMan and keeps running stats of * notification usage. Some metrics are updated as events occur. Others, namely * those involving durations, are updated as the notification is canceled.

* *

This class is thread-safe.

* * {@hide} */ public class NotificationUsageStats { private static final String TAG = "NotificationUsageStats"; private static final boolean ENABLE_AGGREGATED_IN_MEMORY_STATS = true; private static final boolean ENABLE_SQLITE_LOG = true; private static final AggregatedStats[] EMPTY_AGGREGATED_STATS = new AggregatedStats[0]; private static final String DEVICE_GLOBAL_STATS = "__global"; // packages start with letters private static final int MSG_EMIT = 1; private static final boolean DEBUG = false; public static final int TEN_SECONDS = 1000 * 10; public static final int FOUR_HOURS = 1000 * 60 * 60 * 4; private static final long EMIT_PERIOD = DEBUG ? TEN_SECONDS : FOUR_HOURS; // Guarded by synchronized(this). private final Map mStats = new HashMap<>(); private final ArrayDeque mStatsArrays = new ArrayDeque<>(); private ArraySet mStatExpiredkeys = new ArraySet<>(); private final SQLiteLog mSQLiteLog; private final Context mContext; private final Handler mHandler; private long mLastEmitTime; public NotificationUsageStats(Context context) { mContext = context; mLastEmitTime = SystemClock.elapsedRealtime(); mSQLiteLog = ENABLE_SQLITE_LOG ? new SQLiteLog(context) : null; mHandler = new Handler(mContext.getMainLooper()) { @Override public void handleMessage(Message msg) { switch (msg.what) { case MSG_EMIT: emit(); break; default: Log.wtf(TAG, "Unknown message type: " + msg.what); break; } } }; mHandler.sendEmptyMessageDelayed(MSG_EMIT, EMIT_PERIOD); } /** * Called when a notification has been posted. */ public synchronized float getAppEnqueueRate(String packageName) { AggregatedStats stats = getOrCreateAggregatedStatsLocked(packageName); if (stats != null) { return stats.getEnqueueRate(SystemClock.elapsedRealtime()); } else { return 0f; } } /** * Called when a notification is tentatively enqueued by an app, before rate checking. */ public synchronized void registerEnqueuedByApp(String packageName) { AggregatedStats[] aggregatedStatsArray = getAggregatedStatsLocked(packageName); for (AggregatedStats stats : aggregatedStatsArray) { stats.numEnqueuedByApp++; } releaseAggregatedStatsLocked(aggregatedStatsArray); } /** * Called when a notification has been posted. */ public synchronized void registerPostedByApp(NotificationRecord notification) { final long now = SystemClock.elapsedRealtime(); notification.stats.posttimeElapsedMs = now; AggregatedStats[] aggregatedStatsArray = getAggregatedStatsLocked(notification); for (AggregatedStats stats : aggregatedStatsArray) { stats.numPostedByApp++; stats.updateInterarrivalEstimate(now); stats.countApiUse(notification); } releaseAggregatedStatsLocked(aggregatedStatsArray); if (ENABLE_SQLITE_LOG) { mSQLiteLog.logPosted(notification); } } /** * Called when a notification has been updated. */ public synchronized void registerUpdatedByApp(NotificationRecord notification, NotificationRecord old) { notification.stats.updateFrom(old.stats); AggregatedStats[] aggregatedStatsArray = getAggregatedStatsLocked(notification); for (AggregatedStats stats : aggregatedStatsArray) { stats.numUpdatedByApp++; stats.updateInterarrivalEstimate(SystemClock.elapsedRealtime()); stats.countApiUse(notification); } releaseAggregatedStatsLocked(aggregatedStatsArray); if (ENABLE_SQLITE_LOG) { mSQLiteLog.logPosted(notification); } } /** * Called when the originating app removed the notification programmatically. */ public synchronized void registerRemovedByApp(NotificationRecord notification) { notification.stats.onRemoved(); AggregatedStats[] aggregatedStatsArray = getAggregatedStatsLocked(notification); for (AggregatedStats stats : aggregatedStatsArray) { stats.numRemovedByApp++; } releaseAggregatedStatsLocked(aggregatedStatsArray); if (ENABLE_SQLITE_LOG) { mSQLiteLog.logRemoved(notification); } } /** * Called when the user dismissed the notification via the UI. */ public synchronized void registerDismissedByUser(NotificationRecord notification) { MetricsLogger.histogram(mContext, "note_dismiss_longevity", (int) (System.currentTimeMillis() - notification.getRankingTimeMs()) / (60 * 1000)); notification.stats.onDismiss(); if (ENABLE_SQLITE_LOG) { mSQLiteLog.logDismissed(notification); } } /** * Called when the user clicked the notification in the UI. */ public synchronized void registerClickedByUser(NotificationRecord notification) { MetricsLogger.histogram(mContext, "note_click_longevity", (int) (System.currentTimeMillis() - notification.getRankingTimeMs()) / (60 * 1000)); notification.stats.onClick(); if (ENABLE_SQLITE_LOG) { mSQLiteLog.logClicked(notification); } } public synchronized void registerPeopleAffinity(NotificationRecord notification, boolean valid, boolean starred, boolean cached) { AggregatedStats[] aggregatedStatsArray = getAggregatedStatsLocked(notification); for (AggregatedStats stats : aggregatedStatsArray) { if (valid) { stats.numWithValidPeople++; } if (starred) { stats.numWithStaredPeople++; } if (cached) { stats.numPeopleCacheHit++; } else { stats.numPeopleCacheMiss++; } } releaseAggregatedStatsLocked(aggregatedStatsArray); } public synchronized void registerBlocked(NotificationRecord notification) { AggregatedStats[] aggregatedStatsArray = getAggregatedStatsLocked(notification); for (AggregatedStats stats : aggregatedStatsArray) { stats.numBlocked++; } releaseAggregatedStatsLocked(aggregatedStatsArray); } public synchronized void registerSuspendedByAdmin(NotificationRecord notification) { AggregatedStats[] aggregatedStatsArray = getAggregatedStatsLocked(notification); for (AggregatedStats stats : aggregatedStatsArray) { stats.numSuspendedByAdmin++; } releaseAggregatedStatsLocked(aggregatedStatsArray); } public synchronized void registerOverRateQuota(String packageName) { AggregatedStats[] aggregatedStatsArray = getAggregatedStatsLocked(packageName); for (AggregatedStats stats : aggregatedStatsArray) { stats.numRateViolations++; } } public synchronized void registerOverCountQuota(String packageName) { AggregatedStats[] aggregatedStatsArray = getAggregatedStatsLocked(packageName); for (AggregatedStats stats : aggregatedStatsArray) { stats.numQuotaViolations++; } } // Locked by this. private AggregatedStats[] getAggregatedStatsLocked(NotificationRecord record) { return getAggregatedStatsLocked(record.sbn.getPackageName()); } // Locked by this. private AggregatedStats[] getAggregatedStatsLocked(String packageName) { if (!ENABLE_AGGREGATED_IN_MEMORY_STATS) { return EMPTY_AGGREGATED_STATS; } AggregatedStats[] array = mStatsArrays.poll(); if (array == null) { array = new AggregatedStats[2]; } array[0] = getOrCreateAggregatedStatsLocked(DEVICE_GLOBAL_STATS); array[1] = getOrCreateAggregatedStatsLocked(packageName); return array; } // Locked by this. private void releaseAggregatedStatsLocked(AggregatedStats[] array) { for(int i = 0; i < array.length; i++) { array[i] = null; } mStatsArrays.offer(array); } // Locked by this. private AggregatedStats getOrCreateAggregatedStatsLocked(String key) { AggregatedStats result = mStats.get(key); if (result == null) { result = new AggregatedStats(mContext, key); mStats.put(key, result); } result.mLastAccessTime = SystemClock.elapsedRealtime(); return result; } public synchronized JSONObject dumpJson(DumpFilter filter) { JSONObject dump = new JSONObject(); if (ENABLE_AGGREGATED_IN_MEMORY_STATS) { try { JSONArray aggregatedStats = new JSONArray(); for (AggregatedStats as : mStats.values()) { if (filter != null && !filter.matches(as.key)) continue; aggregatedStats.put(as.dumpJson()); } dump.put("current", aggregatedStats); } catch (JSONException e) { // pass } } if (ENABLE_SQLITE_LOG) { try { dump.put("historical", mSQLiteLog.dumpJson(filter)); } catch (JSONException e) { // pass } } return dump; } public synchronized void dump(PrintWriter pw, String indent, DumpFilter filter) { if (ENABLE_AGGREGATED_IN_MEMORY_STATS) { for (AggregatedStats as : mStats.values()) { if (filter != null && !filter.matches(as.key)) continue; as.dump(pw, indent); } pw.println(indent + "mStatsArrays.size(): " + mStatsArrays.size()); pw.println(indent + "mStats.size(): " + mStats.size()); } if (ENABLE_SQLITE_LOG) { mSQLiteLog.dump(pw, indent, filter); } } public synchronized void emit() { AggregatedStats stats = getOrCreateAggregatedStatsLocked(DEVICE_GLOBAL_STATS); stats.emit(); mHandler.removeMessages(MSG_EMIT); mHandler.sendEmptyMessageDelayed(MSG_EMIT, EMIT_PERIOD); for(String key: mStats.keySet()) { if (mStats.get(key).mLastAccessTime < mLastEmitTime) { mStatExpiredkeys.add(key); } } for(String key: mStatExpiredkeys) { mStats.remove(key); } mStatExpiredkeys.clear(); mLastEmitTime = SystemClock.elapsedRealtime(); } /** * Aggregated notification stats. */ private static class AggregatedStats { private final Context mContext; public final String key; private final long mCreated; private AggregatedStats mPrevious; // ---- Updated as the respective events occur. public int numEnqueuedByApp; public int numPostedByApp; public int numUpdatedByApp; public int numRemovedByApp; public int numPeopleCacheHit; public int numPeopleCacheMiss;; public int numWithStaredPeople; public int numWithValidPeople; public int numBlocked; public int numSuspendedByAdmin; public int numWithActions; public int numPrivate; public int numSecret; public int numWithBigText; public int numWithBigPicture; public int numForegroundService; public int numOngoing; public int numAutoCancel; public int numWithLargeIcon; public int numWithInbox; public int numWithMediaSession; public int numWithTitle; public int numWithText; public int numWithSubText; public int numWithInfoText; public int numInterrupt; public ImportanceHistogram noisyImportance; public ImportanceHistogram quietImportance; public ImportanceHistogram finalImportance; public RateEstimator enqueueRate; public int numRateViolations; public int numQuotaViolations; public long mLastAccessTime; public AggregatedStats(Context context, String key) { this.key = key; mContext = context; mCreated = SystemClock.elapsedRealtime(); noisyImportance = new ImportanceHistogram(context, "note_imp_noisy_"); quietImportance = new ImportanceHistogram(context, "note_imp_quiet_"); finalImportance = new ImportanceHistogram(context, "note_importance_"); enqueueRate = new RateEstimator(); } public AggregatedStats getPrevious() { if (mPrevious == null) { mPrevious = new AggregatedStats(mContext, key); } return mPrevious; } public void countApiUse(NotificationRecord record) { final Notification n = record.getNotification(); if (n.actions != null) { numWithActions++; } if ((n.flags & Notification.FLAG_FOREGROUND_SERVICE) != 0) { numForegroundService++; } if ((n.flags & Notification.FLAG_ONGOING_EVENT) != 0) { numOngoing++; } if ((n.flags & Notification.FLAG_AUTO_CANCEL) != 0) { numAutoCancel++; } if ((n.defaults & Notification.DEFAULT_SOUND) != 0 || (n.defaults & Notification.DEFAULT_VIBRATE) != 0 || n.sound != null || n.vibrate != null) { numInterrupt++; } switch (n.visibility) { case Notification.VISIBILITY_PRIVATE: numPrivate++; break; case Notification.VISIBILITY_SECRET: numSecret++; break; } if (record.stats.isNoisy) { noisyImportance.increment(record.stats.requestedImportance); } else { quietImportance.increment(record.stats.requestedImportance); } finalImportance.increment(record.getImportance()); final Set names = n.extras.keySet(); if (names.contains(Notification.EXTRA_BIG_TEXT)) { numWithBigText++; } if (names.contains(Notification.EXTRA_PICTURE)) { numWithBigPicture++; } if (names.contains(Notification.EXTRA_LARGE_ICON)) { numWithLargeIcon++; } if (names.contains(Notification.EXTRA_TEXT_LINES)) { numWithInbox++; } if (names.contains(Notification.EXTRA_MEDIA_SESSION)) { numWithMediaSession++; } if (names.contains(Notification.EXTRA_TITLE) && !TextUtils.isEmpty(n.extras.getCharSequence(Notification.EXTRA_TITLE))) { numWithTitle++; } if (names.contains(Notification.EXTRA_TEXT) && !TextUtils.isEmpty(n.extras.getCharSequence(Notification.EXTRA_TEXT))) { numWithText++; } if (names.contains(Notification.EXTRA_SUB_TEXT) && !TextUtils.isEmpty(n.extras.getCharSequence(Notification.EXTRA_SUB_TEXT))) { numWithSubText++; } if (names.contains(Notification.EXTRA_INFO_TEXT) && !TextUtils.isEmpty(n.extras.getCharSequence(Notification.EXTRA_INFO_TEXT))) { numWithInfoText++; } } public void emit() { AggregatedStats previous = getPrevious(); maybeCount("note_enqueued", (numEnqueuedByApp - previous.numEnqueuedByApp)); maybeCount("note_post", (numPostedByApp - previous.numPostedByApp)); maybeCount("note_update", (numUpdatedByApp - previous.numUpdatedByApp)); maybeCount("note_remove", (numRemovedByApp - previous.numRemovedByApp)); maybeCount("note_with_people", (numWithValidPeople - previous.numWithValidPeople)); maybeCount("note_with_stars", (numWithStaredPeople - previous.numWithStaredPeople)); maybeCount("people_cache_hit", (numPeopleCacheHit - previous.numPeopleCacheHit)); maybeCount("people_cache_miss", (numPeopleCacheMiss - previous.numPeopleCacheMiss)); maybeCount("note_blocked", (numBlocked - previous.numBlocked)); maybeCount("note_suspended", (numSuspendedByAdmin - previous.numSuspendedByAdmin)); maybeCount("note_with_actions", (numWithActions - previous.numWithActions)); maybeCount("note_private", (numPrivate - previous.numPrivate)); maybeCount("note_secret", (numSecret - previous.numSecret)); maybeCount("note_interupt", (numInterrupt - previous.numInterrupt)); maybeCount("note_big_text", (numWithBigText - previous.numWithBigText)); maybeCount("note_big_pic", (numWithBigPicture - previous.numWithBigPicture)); maybeCount("note_fg", (numForegroundService - previous.numForegroundService)); maybeCount("note_ongoing", (numOngoing - previous.numOngoing)); maybeCount("note_auto", (numAutoCancel - previous.numAutoCancel)); maybeCount("note_large_icon", (numWithLargeIcon - previous.numWithLargeIcon)); maybeCount("note_inbox", (numWithInbox - previous.numWithInbox)); maybeCount("note_media", (numWithMediaSession - previous.numWithMediaSession)); maybeCount("note_title", (numWithTitle - previous.numWithTitle)); maybeCount("note_text", (numWithText - previous.numWithText)); maybeCount("note_sub_text", (numWithSubText - previous.numWithSubText)); maybeCount("note_info_text", (numWithInfoText - previous.numWithInfoText)); maybeCount("note_over_rate", (numRateViolations - previous.numRateViolations)); maybeCount("note_over_quota", (numQuotaViolations - previous.numQuotaViolations)); noisyImportance.maybeCount(previous.noisyImportance); quietImportance.maybeCount(previous.quietImportance); finalImportance.maybeCount(previous.finalImportance); previous.numEnqueuedByApp = numEnqueuedByApp; previous.numPostedByApp = numPostedByApp; previous.numUpdatedByApp = numUpdatedByApp; previous.numRemovedByApp = numRemovedByApp; previous.numPeopleCacheHit = numPeopleCacheHit; previous.numPeopleCacheMiss = numPeopleCacheMiss; previous.numWithStaredPeople = numWithStaredPeople; previous.numWithValidPeople = numWithValidPeople; previous.numBlocked = numBlocked; previous.numSuspendedByAdmin = numSuspendedByAdmin; previous.numWithActions = numWithActions; previous.numPrivate = numPrivate; previous.numSecret = numSecret; previous.numInterrupt = numInterrupt; previous.numWithBigText = numWithBigText; previous.numWithBigPicture = numWithBigPicture; previous.numForegroundService = numForegroundService; previous.numOngoing = numOngoing; previous.numAutoCancel = numAutoCancel; previous.numWithLargeIcon = numWithLargeIcon; previous.numWithInbox = numWithInbox; previous.numWithMediaSession = numWithMediaSession; previous.numWithTitle = numWithTitle; previous.numWithText = numWithText; previous.numWithSubText = numWithSubText; previous.numWithInfoText = numWithInfoText; previous.numRateViolations = numRateViolations; previous.numQuotaViolations = numQuotaViolations; noisyImportance.update(previous.noisyImportance); quietImportance.update(previous.quietImportance); finalImportance.update(previous.finalImportance); } void maybeCount(String name, int value) { if (value > 0) { MetricsLogger.count(mContext, name, value); } } public void dump(PrintWriter pw, String indent) { pw.println(toStringWithIndent(indent)); } @Override public String toString() { return toStringWithIndent(""); } /** @return the enqueue rate if there were a new enqueue event right now. */ public float getEnqueueRate() { return getEnqueueRate(SystemClock.elapsedRealtime()); } public float getEnqueueRate(long now) { return enqueueRate.getRate(now); } public void updateInterarrivalEstimate(long now) { enqueueRate.update(now); } private String toStringWithIndent(String indent) { StringBuilder output = new StringBuilder(); output.append(indent).append("AggregatedStats{\n"); String indentPlusTwo = indent + " "; output.append(indentPlusTwo); output.append("key='").append(key).append("',\n"); output.append(indentPlusTwo); output.append("numEnqueuedByApp=").append(numEnqueuedByApp).append(",\n"); output.append(indentPlusTwo); output.append("numPostedByApp=").append(numPostedByApp).append(",\n"); output.append(indentPlusTwo); output.append("numUpdatedByApp=").append(numUpdatedByApp).append(",\n"); output.append(indentPlusTwo); output.append("numRemovedByApp=").append(numRemovedByApp).append(",\n"); output.append(indentPlusTwo); output.append("numPeopleCacheHit=").append(numPeopleCacheHit).append(",\n"); output.append(indentPlusTwo); output.append("numWithStaredPeople=").append(numWithStaredPeople).append(",\n"); output.append(indentPlusTwo); output.append("numWithValidPeople=").append(numWithValidPeople).append(",\n"); output.append(indentPlusTwo); output.append("numPeopleCacheMiss=").append(numPeopleCacheMiss).append(",\n"); output.append(indentPlusTwo); output.append("numBlocked=").append(numBlocked).append(",\n"); output.append(indentPlusTwo); output.append("numSuspendedByAdmin=").append(numSuspendedByAdmin).append(",\n"); output.append(indentPlusTwo); output.append("numWithActions=").append(numWithActions).append(",\n"); output.append(indentPlusTwo); output.append("numPrivate=").append(numPrivate).append(",\n"); output.append(indentPlusTwo); output.append("numSecret=").append(numSecret).append(",\n"); output.append(indentPlusTwo); output.append("numInterrupt=").append(numInterrupt).append(",\n"); output.append(indentPlusTwo); output.append("numWithBigText=").append(numWithBigText).append(",\n"); output.append(indentPlusTwo); output.append("numWithBigPicture=").append(numWithBigPicture).append("\n"); output.append(indentPlusTwo); output.append("numForegroundService=").append(numForegroundService).append("\n"); output.append(indentPlusTwo); output.append("numOngoing=").append(numOngoing).append("\n"); output.append(indentPlusTwo); output.append("numAutoCancel=").append(numAutoCancel).append("\n"); output.append(indentPlusTwo); output.append("numWithLargeIcon=").append(numWithLargeIcon).append("\n"); output.append(indentPlusTwo); output.append("numWithInbox=").append(numWithInbox).append("\n"); output.append(indentPlusTwo); output.append("numWithMediaSession=").append(numWithMediaSession).append("\n"); output.append(indentPlusTwo); output.append("numWithTitle=").append(numWithTitle).append("\n"); output.append(indentPlusTwo); output.append("numWithText=").append(numWithText).append("\n"); output.append(indentPlusTwo); output.append("numWithSubText=").append(numWithSubText).append("\n"); output.append(indentPlusTwo); output.append("numWithInfoText=").append(numWithInfoText).append("\n"); output.append("numRateViolations=").append(numRateViolations).append("\n"); output.append("numQuotaViolations=").append(numQuotaViolations).append("\n"); output.append(indentPlusTwo).append(noisyImportance.toString()).append("\n"); output.append(indentPlusTwo).append(quietImportance.toString()).append("\n"); output.append(indentPlusTwo).append(finalImportance.toString()).append("\n"); output.append(indent).append("}"); return output.toString(); } public JSONObject dumpJson() throws JSONException { AggregatedStats previous = getPrevious(); JSONObject dump = new JSONObject(); dump.put("key", key); dump.put("duration", SystemClock.elapsedRealtime() - mCreated); maybePut(dump, "numEnqueuedByApp", numEnqueuedByApp); maybePut(dump, "numPostedByApp", numPostedByApp); maybePut(dump, "numUpdatedByApp", numUpdatedByApp); maybePut(dump, "numRemovedByApp", numRemovedByApp); maybePut(dump, "numPeopleCacheHit", numPeopleCacheHit); maybePut(dump, "numPeopleCacheMiss", numPeopleCacheMiss); maybePut(dump, "numWithStaredPeople", numWithStaredPeople); maybePut(dump, "numWithValidPeople", numWithValidPeople); maybePut(dump, "numBlocked", numBlocked); maybePut(dump, "numSuspendedByAdmin", numSuspendedByAdmin); maybePut(dump, "numWithActions", numWithActions); maybePut(dump, "numPrivate", numPrivate); maybePut(dump, "numSecret", numSecret); maybePut(dump, "numInterrupt", numInterrupt); maybePut(dump, "numWithBigText", numWithBigText); maybePut(dump, "numWithBigPicture", numWithBigPicture); maybePut(dump, "numForegroundService", numForegroundService); maybePut(dump, "numOngoing", numOngoing); maybePut(dump, "numAutoCancel", numAutoCancel); maybePut(dump, "numWithLargeIcon", numWithLargeIcon); maybePut(dump, "numWithInbox", numWithInbox); maybePut(dump, "numWithMediaSession", numWithMediaSession); maybePut(dump, "numWithTitle", numWithTitle); maybePut(dump, "numWithText", numWithText); maybePut(dump, "numWithSubText", numWithSubText); maybePut(dump, "numWithInfoText", numWithInfoText); maybePut(dump, "numRateViolations", numRateViolations); maybePut(dump, "numQuotaLViolations", numQuotaViolations); maybePut(dump, "notificationEnqueueRate", getEnqueueRate()); noisyImportance.maybePut(dump, previous.noisyImportance); quietImportance.maybePut(dump, previous.quietImportance); finalImportance.maybePut(dump, previous.finalImportance); return dump; } private void maybePut(JSONObject dump, String name, int value) throws JSONException { if (value > 0) { dump.put(name, value); } } private void maybePut(JSONObject dump, String name, float value) throws JSONException { if (value > 0.0) { dump.put(name, value); } } } private static class ImportanceHistogram { // TODO define these somewhere else private static final int NUM_IMPORTANCES = 6; private static final String[] IMPORTANCE_NAMES = {"none", "min", "low", "default", "high", "max"}; private final Context mContext; private final String[] mCounterNames; private final String mPrefix; private int[] mCount; ImportanceHistogram(Context context, String prefix) { mContext = context; mCount = new int[NUM_IMPORTANCES]; mCounterNames = new String[NUM_IMPORTANCES]; mPrefix = prefix; for (int i = 0; i < NUM_IMPORTANCES; i++) { mCounterNames[i] = mPrefix + IMPORTANCE_NAMES[i]; } } void increment(int imp) { imp = imp < 0 ? 0 : imp > NUM_IMPORTANCES ? NUM_IMPORTANCES : imp; mCount[imp] ++; } void maybeCount(ImportanceHistogram prev) { for (int i = 0; i < NUM_IMPORTANCES; i++) { final int value = mCount[i] - prev.mCount[i]; if (value > 0) { MetricsLogger.count(mContext, mCounterNames[i], value); } } } void update(ImportanceHistogram that) { for (int i = 0; i < NUM_IMPORTANCES; i++) { mCount[i] = that.mCount[i]; } } public void maybePut(JSONObject dump, ImportanceHistogram prev) throws JSONException { dump.put(mPrefix, new JSONArray(mCount)); } @Override public String toString() { StringBuilder output = new StringBuilder(); output.append(mPrefix).append(": ["); for (int i = 0; i < NUM_IMPORTANCES; i++) { output.append(mCount[i]); if (i < (NUM_IMPORTANCES-1)) { output.append(", "); } } output.append("]"); return output.toString(); } } /** * Tracks usage of an individual notification that is currently active. */ public static class SingleNotificationStats { private boolean isVisible = false; private boolean isExpanded = false; /** SystemClock.elapsedRealtime() when the notification was posted. */ public long posttimeElapsedMs = -1; /** Elapsed time since the notification was posted until it was first clicked, or -1. */ public long posttimeToFirstClickMs = -1; /** Elpased time since the notification was posted until it was dismissed by the user. */ public long posttimeToDismissMs = -1; /** Number of times the notification has been made visible. */ public long airtimeCount = 0; /** Time in ms between the notification was posted and first shown; -1 if never shown. */ public long posttimeToFirstAirtimeMs = -1; /** * If currently visible, SystemClock.elapsedRealtime() when the notification was made * visible; -1 otherwise. */ public long currentAirtimeStartElapsedMs = -1; /** Accumulated visible time. */ public long airtimeMs = 0; /** * Time in ms between the notification being posted and when it first * became visible and expanded; -1 if it was never visibly expanded. */ public long posttimeToFirstVisibleExpansionMs = -1; /** * If currently visible, SystemClock.elapsedRealtime() when the notification was made * visible; -1 otherwise. */ public long currentAirtimeExpandedStartElapsedMs = -1; /** Accumulated visible expanded time. */ public long airtimeExpandedMs = 0; /** Number of times the notification has been expanded by the user. */ public long userExpansionCount = 0; /** Importance directly requested by the app. */ public int requestedImportance; /** Did the app include sound or vibration on the notificaiton. */ public boolean isNoisy; /** Importance after initial filtering for noise and other features */ public int naturalImportance; public long getCurrentPosttimeMs() { if (posttimeElapsedMs < 0) { return 0; } return SystemClock.elapsedRealtime() - posttimeElapsedMs; } public long getCurrentAirtimeMs() { long result = airtimeMs; // Add incomplete airtime if currently shown. if (currentAirtimeStartElapsedMs >= 0) { result += (SystemClock.elapsedRealtime() - currentAirtimeStartElapsedMs); } return result; } public long getCurrentAirtimeExpandedMs() { long result = airtimeExpandedMs; // Add incomplete expanded airtime if currently shown. if (currentAirtimeExpandedStartElapsedMs >= 0) { result += (SystemClock.elapsedRealtime() - currentAirtimeExpandedStartElapsedMs); } return result; } /** * Called when the user clicked the notification. */ public void onClick() { if (posttimeToFirstClickMs < 0) { posttimeToFirstClickMs = SystemClock.elapsedRealtime() - posttimeElapsedMs; } } /** * Called when the user removed the notification. */ public void onDismiss() { if (posttimeToDismissMs < 0) { posttimeToDismissMs = SystemClock.elapsedRealtime() - posttimeElapsedMs; } finish(); } public void onCancel() { finish(); } public void onRemoved() { finish(); } public void onVisibilityChanged(boolean visible) { long elapsedNowMs = SystemClock.elapsedRealtime(); final boolean wasVisible = isVisible; isVisible = visible; if (visible) { if (currentAirtimeStartElapsedMs < 0) { airtimeCount++; currentAirtimeStartElapsedMs = elapsedNowMs; } if (posttimeToFirstAirtimeMs < 0) { posttimeToFirstAirtimeMs = elapsedNowMs - posttimeElapsedMs; } } else { if (currentAirtimeStartElapsedMs >= 0) { airtimeMs += (elapsedNowMs - currentAirtimeStartElapsedMs); currentAirtimeStartElapsedMs = -1; } } if (wasVisible != isVisible) { updateVisiblyExpandedStats(); } } public void onExpansionChanged(boolean userAction, boolean expanded) { isExpanded = expanded; if (isExpanded && userAction) { userExpansionCount++; } updateVisiblyExpandedStats(); } private void updateVisiblyExpandedStats() { long elapsedNowMs = SystemClock.elapsedRealtime(); if (isExpanded && isVisible) { // expanded and visible if (currentAirtimeExpandedStartElapsedMs < 0) { currentAirtimeExpandedStartElapsedMs = elapsedNowMs; } if (posttimeToFirstVisibleExpansionMs < 0) { posttimeToFirstVisibleExpansionMs = elapsedNowMs - posttimeElapsedMs; } } else { // not-expanded or not-visible if (currentAirtimeExpandedStartElapsedMs >= 0) { airtimeExpandedMs += (elapsedNowMs - currentAirtimeExpandedStartElapsedMs); currentAirtimeExpandedStartElapsedMs = -1; } } } /** The notification is leaving the system. Finalize. */ public void finish() { onVisibilityChanged(false); } @Override public String toString() { StringBuilder output = new StringBuilder(); output.append("SingleNotificationStats{"); output.append("posttimeElapsedMs=").append(posttimeElapsedMs).append(", "); output.append("posttimeToFirstClickMs=").append(posttimeToFirstClickMs).append(", "); output.append("posttimeToDismissMs=").append(posttimeToDismissMs).append(", "); output.append("airtimeCount=").append(airtimeCount).append(", "); output.append("airtimeMs=").append(airtimeMs).append(", "); output.append("currentAirtimeStartElapsedMs=").append(currentAirtimeStartElapsedMs) .append(", "); output.append("airtimeExpandedMs=").append(airtimeExpandedMs).append(", "); output.append("posttimeToFirstVisibleExpansionMs=") .append(posttimeToFirstVisibleExpansionMs).append(", "); output.append("currentAirtimeExpandedStartElapsedMs=") .append(currentAirtimeExpandedStartElapsedMs).append(", "); output.append("requestedImportance=").append(requestedImportance).append(", "); output.append("naturalImportance=").append(naturalImportance).append(", "); output.append("isNoisy=").append(isNoisy); output.append('}'); return output.toString(); } /** Copy useful information out of the stats from the pre-update notifications. */ public void updateFrom(SingleNotificationStats old) { posttimeElapsedMs = old.posttimeElapsedMs; posttimeToFirstClickMs = old.posttimeToFirstClickMs; airtimeCount = old.airtimeCount; posttimeToFirstAirtimeMs = old.posttimeToFirstAirtimeMs; currentAirtimeStartElapsedMs = old.currentAirtimeStartElapsedMs; airtimeMs = old.airtimeMs; posttimeToFirstVisibleExpansionMs = old.posttimeToFirstVisibleExpansionMs; currentAirtimeExpandedStartElapsedMs = old.currentAirtimeExpandedStartElapsedMs; airtimeExpandedMs = old.airtimeExpandedMs; userExpansionCount = old.userExpansionCount; } } /** * Aggregates long samples to sum and averages. */ public static class Aggregate { long numSamples; double avg; double sum2; double var; public void addSample(long sample) { // Welford's "Method for Calculating Corrected Sums of Squares" // http://www.jstor.org/stable/1266577?seq=2 numSamples++; final double n = numSamples; final double delta = sample - avg; avg += (1.0 / n) * delta; sum2 += ((n - 1) / n) * delta * delta; final double divisor = numSamples == 1 ? 1.0 : n - 1.0; var = sum2 / divisor; } @Override public String toString() { return "Aggregate{" + "numSamples=" + numSamples + ", avg=" + avg + ", var=" + var + '}'; } } private static class SQLiteLog { private static final String TAG = "NotificationSQLiteLog"; // Message types passed to the background handler. private static final int MSG_POST = 1; private static final int MSG_CLICK = 2; private static final int MSG_REMOVE = 3; private static final int MSG_DISMISS = 4; private static final String DB_NAME = "notification_log.db"; private static final int DB_VERSION = 5; /** Age in ms after which events are pruned from the DB. */ private static final long HORIZON_MS = 7 * 24 * 60 * 60 * 1000L; // 1 week /** Delay between pruning the DB. Used to throttle pruning. */ private static final long PRUNE_MIN_DELAY_MS = 6 * 60 * 60 * 1000L; // 6 hours /** Mininum number of writes between pruning the DB. Used to throttle pruning. */ private static final long PRUNE_MIN_WRITES = 1024; // Table 'log' private static final String TAB_LOG = "log"; private static final String COL_EVENT_USER_ID = "event_user_id"; private static final String COL_EVENT_TYPE = "event_type"; private static final String COL_EVENT_TIME = "event_time_ms"; private static final String COL_KEY = "key"; private static final String COL_PKG = "pkg"; private static final String COL_NOTIFICATION_ID = "nid"; private static final String COL_TAG = "tag"; private static final String COL_WHEN_MS = "when_ms"; private static final String COL_DEFAULTS = "defaults"; private static final String COL_FLAGS = "flags"; private static final String COL_IMPORTANCE_REQ = "importance_request"; private static final String COL_IMPORTANCE_FINAL = "importance_final"; private static final String COL_NOISY = "noisy"; private static final String COL_MUTED = "muted"; private static final String COL_DEMOTED = "demoted"; private static final String COL_CATEGORY = "category"; private static final String COL_ACTION_COUNT = "action_count"; private static final String COL_POSTTIME_MS = "posttime_ms"; private static final String COL_AIRTIME_MS = "airtime_ms"; private static final String COL_FIRST_EXPANSIONTIME_MS = "first_expansion_time_ms"; private static final String COL_AIRTIME_EXPANDED_MS = "expansion_airtime_ms"; private static final String COL_EXPAND_COUNT = "expansion_count"; private static final int EVENT_TYPE_POST = 1; private static final int EVENT_TYPE_CLICK = 2; private static final int EVENT_TYPE_REMOVE = 3; private static final int EVENT_TYPE_DISMISS = 4; private static long sLastPruneMs; private static long sNumWrites; private final SQLiteOpenHelper mHelper; private final Handler mWriteHandler; private static final long DAY_MS = 24 * 60 * 60 * 1000; private static final String STATS_QUERY = "SELECT " + COL_EVENT_USER_ID + ", " + COL_PKG + ", " + // Bucket by day by looking at 'floor((midnight - eventTimeMs) / dayMs)' "CAST(((%d - " + COL_EVENT_TIME + ") / " + DAY_MS + ") AS int) " + "AS day, " + "COUNT(*) AS cnt, " + "SUM(" + COL_MUTED + ") as muted, " + "SUM(" + COL_NOISY + ") as noisy, " + "SUM(" + COL_DEMOTED + ") as demoted " + "FROM " + TAB_LOG + " " + "WHERE " + COL_EVENT_TYPE + "=" + EVENT_TYPE_POST + " AND " + COL_EVENT_TIME + " > %d " + " GROUP BY " + COL_EVENT_USER_ID + ", day, " + COL_PKG; public SQLiteLog(Context context) { HandlerThread backgroundThread = new HandlerThread("notification-sqlite-log", android.os.Process.THREAD_PRIORITY_BACKGROUND); backgroundThread.start(); mWriteHandler = new Handler(backgroundThread.getLooper()) { @Override public void handleMessage(Message msg) { NotificationRecord r = (NotificationRecord) msg.obj; long nowMs = System.currentTimeMillis(); switch (msg.what) { case MSG_POST: writeEvent(r.sbn.getPostTime(), EVENT_TYPE_POST, r); break; case MSG_CLICK: writeEvent(nowMs, EVENT_TYPE_CLICK, r); break; case MSG_REMOVE: writeEvent(nowMs, EVENT_TYPE_REMOVE, r); break; case MSG_DISMISS: writeEvent(nowMs, EVENT_TYPE_DISMISS, r); break; default: Log.wtf(TAG, "Unknown message type: " + msg.what); break; } } }; mHelper = new SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) { @Override public void onCreate(SQLiteDatabase db) { db.execSQL("CREATE TABLE " + TAB_LOG + " (" + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + COL_EVENT_USER_ID + " INT," + COL_EVENT_TYPE + " INT," + COL_EVENT_TIME + " INT," + COL_KEY + " TEXT," + COL_PKG + " TEXT," + COL_NOTIFICATION_ID + " INT," + COL_TAG + " TEXT," + COL_WHEN_MS + " INT," + COL_DEFAULTS + " INT," + COL_FLAGS + " INT," + COL_IMPORTANCE_REQ + " INT," + COL_IMPORTANCE_FINAL + " INT," + COL_NOISY + " INT," + COL_MUTED + " INT," + COL_DEMOTED + " INT," + COL_CATEGORY + " TEXT," + COL_ACTION_COUNT + " INT," + COL_POSTTIME_MS + " INT," + COL_AIRTIME_MS + " INT," + COL_FIRST_EXPANSIONTIME_MS + " INT," + COL_AIRTIME_EXPANDED_MS + " INT," + COL_EXPAND_COUNT + " INT" + ")"); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion != newVersion) { db.execSQL("DROP TABLE IF EXISTS " + TAB_LOG); onCreate(db); } } }; } public void logPosted(NotificationRecord notification) { mWriteHandler.sendMessage(mWriteHandler.obtainMessage(MSG_POST, notification)); } public void logClicked(NotificationRecord notification) { mWriteHandler.sendMessage(mWriteHandler.obtainMessage(MSG_CLICK, notification)); } public void logRemoved(NotificationRecord notification) { mWriteHandler.sendMessage(mWriteHandler.obtainMessage(MSG_REMOVE, notification)); } public void logDismissed(NotificationRecord notification) { mWriteHandler.sendMessage(mWriteHandler.obtainMessage(MSG_DISMISS, notification)); } private JSONArray jsonPostFrequencies(DumpFilter filter) throws JSONException { JSONArray frequencies = new JSONArray(); SQLiteDatabase db = mHelper.getReadableDatabase(); long midnight = getMidnightMs(); String q = String.format(STATS_QUERY, midnight, filter.since); Cursor cursor = db.rawQuery(q, null); try { for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) { int userId = cursor.getInt(0); String pkg = cursor.getString(1); if (filter != null && !filter.matches(pkg)) continue; int day = cursor.getInt(2); int count = cursor.getInt(3); int muted = cursor.getInt(4); int noisy = cursor.getInt(5); int demoted = cursor.getInt(6); JSONObject row = new JSONObject(); row.put("user_id", userId); row.put("package", pkg); row.put("day", day); row.put("count", count); row.put("noisy", noisy); row.put("muted", muted); row.put("demoted", demoted); frequencies.put(row); } } finally { cursor.close(); } return frequencies; } public void printPostFrequencies(PrintWriter pw, String indent, DumpFilter filter) { SQLiteDatabase db = mHelper.getReadableDatabase(); long midnight = getMidnightMs(); String q = String.format(STATS_QUERY, midnight, filter.since); Cursor cursor = db.rawQuery(q, null); try { for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) { int userId = cursor.getInt(0); String pkg = cursor.getString(1); if (filter != null && !filter.matches(pkg)) continue; int day = cursor.getInt(2); int count = cursor.getInt(3); int muted = cursor.getInt(4); int noisy = cursor.getInt(5); int demoted = cursor.getInt(6); pw.println(indent + "post_frequency{user_id=" + userId + ",pkg=" + pkg + ",day=" + day + ",count=" + count + ",muted=" + muted + "/" + noisy + ",demoted=" + demoted + "}"); } } finally { cursor.close(); } } private long getMidnightMs() { GregorianCalendar midnight = new GregorianCalendar(); midnight.set(midnight.get(Calendar.YEAR), midnight.get(Calendar.MONTH), midnight.get(Calendar.DATE), 23, 59, 59); return midnight.getTimeInMillis(); } private void writeEvent(long eventTimeMs, int eventType, NotificationRecord r) { ContentValues cv = new ContentValues(); cv.put(COL_EVENT_USER_ID, r.sbn.getUser().getIdentifier()); cv.put(COL_EVENT_TIME, eventTimeMs); cv.put(COL_EVENT_TYPE, eventType); putNotificationIdentifiers(r, cv); if (eventType == EVENT_TYPE_POST) { putNotificationDetails(r, cv); } else { putPosttimeVisibility(r, cv); } SQLiteDatabase db = mHelper.getWritableDatabase(); if (db.insert(TAB_LOG, null, cv) < 0) { Log.wtf(TAG, "Error while trying to insert values: " + cv); } sNumWrites++; pruneIfNecessary(db); } private void pruneIfNecessary(SQLiteDatabase db) { // Prune if we haven't in a while. long nowMs = System.currentTimeMillis(); if (sNumWrites > PRUNE_MIN_WRITES || nowMs - sLastPruneMs > PRUNE_MIN_DELAY_MS) { sNumWrites = 0; sLastPruneMs = nowMs; long horizonStartMs = nowMs - HORIZON_MS; int deletedRows = db.delete(TAB_LOG, COL_EVENT_TIME + " < ?", new String[] { String.valueOf(horizonStartMs) }); Log.d(TAG, "Pruned event entries: " + deletedRows); } } private static void putNotificationIdentifiers(NotificationRecord r, ContentValues outCv) { outCv.put(COL_KEY, r.sbn.getKey()); outCv.put(COL_PKG, r.sbn.getPackageName()); } private static void putNotificationDetails(NotificationRecord r, ContentValues outCv) { outCv.put(COL_NOTIFICATION_ID, r.sbn.getId()); if (r.sbn.getTag() != null) { outCv.put(COL_TAG, r.sbn.getTag()); } outCv.put(COL_WHEN_MS, r.sbn.getPostTime()); outCv.put(COL_FLAGS, r.getNotification().flags); final int before = r.stats.requestedImportance; final int after = r.getImportance(); final boolean noisy = r.stats.isNoisy; outCv.put(COL_IMPORTANCE_REQ, before); outCv.put(COL_IMPORTANCE_FINAL, after); outCv.put(COL_DEMOTED, after < before ? 1 : 0); outCv.put(COL_NOISY, noisy); if (noisy && after < IMPORTANCE_HIGH) { outCv.put(COL_MUTED, 1); } else { outCv.put(COL_MUTED, 0); } if (r.getNotification().category != null) { outCv.put(COL_CATEGORY, r.getNotification().category); } outCv.put(COL_ACTION_COUNT, r.getNotification().actions != null ? r.getNotification().actions.length : 0); } private static void putPosttimeVisibility(NotificationRecord r, ContentValues outCv) { outCv.put(COL_POSTTIME_MS, r.stats.getCurrentPosttimeMs()); outCv.put(COL_AIRTIME_MS, r.stats.getCurrentAirtimeMs()); outCv.put(COL_EXPAND_COUNT, r.stats.userExpansionCount); outCv.put(COL_AIRTIME_EXPANDED_MS, r.stats.getCurrentAirtimeExpandedMs()); outCv.put(COL_FIRST_EXPANSIONTIME_MS, r.stats.posttimeToFirstVisibleExpansionMs); } public void dump(PrintWriter pw, String indent, DumpFilter filter) { printPostFrequencies(pw, indent, filter); } public JSONObject dumpJson(DumpFilter filter) { JSONObject dump = new JSONObject(); try { dump.put("post_frequency", jsonPostFrequencies(filter)); dump.put("since", filter.since); dump.put("now", System.currentTimeMillis()); } catch (JSONException e) { // pass } return dump; } } }