Add APIs for querying broadcast response stats.
Bug: 206518114
Test: atest tests/tests/app.usage/src/android/app/usage/cts/UsageStatsTest.java
CTS-Coverage-Bug: 206518114
Change-Id: Idcefc143215a372a7bd786fcb2208a7c26f2f7e2
diff --git a/core/api/system-current.txt b/core/api/system-current.txt
index 9002b46..ee18478 100644
--- a/core/api/system-current.txt
+++ b/core/api/system-current.txt
@@ -2161,6 +2161,18 @@
package android.app.usage {
+ public final class BroadcastResponseStats implements android.os.Parcelable {
+ ctor public BroadcastResponseStats(@NonNull String);
+ method public int describeContents();
+ method @IntRange(from=0) public int getBroadcastsDispatchedCount();
+ method @IntRange(from=0) public int getNotificationsCancelledCount();
+ method @IntRange(from=0) public int getNotificationsPostedCount();
+ method @IntRange(from=0) public int getNotificationsUpdatedCount();
+ method @NonNull public String getPackageName();
+ method public void writeToParcel(@NonNull android.os.Parcel, int);
+ field @NonNull public static final android.os.Parcelable.Creator<android.app.usage.BroadcastResponseStats> CREATOR;
+ }
+
public final class CacheQuotaHint implements android.os.Parcelable {
ctor public CacheQuotaHint(@NonNull android.app.usage.CacheQuotaHint.Builder);
method public int describeContents();
@@ -2216,11 +2228,13 @@
}
public final class UsageStatsManager {
+ method @RequiresPermission(android.Manifest.permission.PACKAGE_USAGE_STATS) public void clearBroadcastResponseStats(@NonNull String, @IntRange(from=1) long);
method @RequiresPermission(android.Manifest.permission.PACKAGE_USAGE_STATS) public int getAppStandbyBucket(String);
method @RequiresPermission(android.Manifest.permission.PACKAGE_USAGE_STATS) public java.util.Map<java.lang.String,java.lang.Integer> getAppStandbyBuckets();
method @RequiresPermission(allOf={android.Manifest.permission.INTERACT_ACROSS_USERS, android.Manifest.permission.PACKAGE_USAGE_STATS}) public long getLastTimeAnyComponentUsed(@NonNull String);
method public int getUsageSource();
method @RequiresPermission(android.Manifest.permission.BIND_CARRIER_SERVICES) public void onCarrierPrivilegedAppsChanged();
+ method @NonNull @RequiresPermission(android.Manifest.permission.PACKAGE_USAGE_STATS) public android.app.usage.BroadcastResponseStats queryBroadcastResponseStats(@NonNull String, @IntRange(from=1) long);
method @RequiresPermission(allOf={android.Manifest.permission.SUSPEND_APPS, android.Manifest.permission.OBSERVE_APP_USAGE}) public void registerAppUsageLimitObserver(int, @NonNull String[], @NonNull java.time.Duration, @NonNull java.time.Duration, @Nullable android.app.PendingIntent);
method @RequiresPermission(android.Manifest.permission.OBSERVE_APP_USAGE) public void registerAppUsageObserver(int, @NonNull String[], long, @NonNull java.util.concurrent.TimeUnit, @NonNull android.app.PendingIntent);
method @RequiresPermission(android.Manifest.permission.OBSERVE_APP_USAGE) public void registerUsageSessionObserver(int, @NonNull String[], @NonNull java.time.Duration, @NonNull java.time.Duration, @NonNull android.app.PendingIntent, @Nullable android.app.PendingIntent);
diff --git a/core/java/android/app/usage/BroadcastResponseStats.aidl b/core/java/android/app/usage/BroadcastResponseStats.aidl
new file mode 100644
index 0000000..5992841
--- /dev/null
+++ b/core/java/android/app/usage/BroadcastResponseStats.aidl
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2022 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 android.app.usage;
+
+parcelable BroadcastResponseStats;
\ No newline at end of file
diff --git a/core/java/android/app/usage/BroadcastResponseStats.java b/core/java/android/app/usage/BroadcastResponseStats.java
new file mode 100644
index 0000000..5acc3dda
--- /dev/null
+++ b/core/java/android/app/usage/BroadcastResponseStats.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright (C) 2022 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 android.app.usage;
+
+import android.annotation.IntRange;
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.app.BroadcastOptions;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+/**
+ * Class containing a collection of stats related to response events started from an app
+ * after receiving a broadcast.
+ *
+ * @hide
+ */
+@SystemApi
+public final class BroadcastResponseStats implements Parcelable {
+ private final String mPackageName;
+ private int mBroadcastsDispatchedCount;
+ private int mNotificationsPostedCount;
+ private int mNotificationsUpdatedCount;
+ private int mNotificationsCancelledCount;
+
+ public BroadcastResponseStats(@NonNull String packageName) {
+ mPackageName = packageName;
+ }
+
+ private BroadcastResponseStats(@NonNull Parcel in) {
+ mPackageName = in.readString8();
+ mBroadcastsDispatchedCount = in.readInt();
+ mNotificationsPostedCount = in.readInt();
+ mNotificationsUpdatedCount = in.readInt();
+ mNotificationsCancelledCount = in.readInt();
+ }
+
+ /**
+ * @return the name of the package that the stats in this object correspond to.
+ */
+ @NonNull
+ public String getPackageName() {
+ return mPackageName;
+ }
+
+ /**
+ * Returns the total number of broadcasts that were dispatched to the app by the caller.
+ *
+ * <b> Note that the returned count will only include the broadcasts that the caller explicitly
+ * requested to record using
+ * {@link BroadcastOptions#recordResponseEventWhileInBackground(long)}.
+ *
+ * @return the total number of broadcasts that were dispatched to the app.
+ */
+ @IntRange(from = 0)
+ public int getBroadcastsDispatchedCount() {
+ return mBroadcastsDispatchedCount;
+ }
+
+ /**
+ * Returns the total number of notifications posted by the app soon after receiving a
+ * broadcast.
+ *
+ * <b> Note that the returned count will only include the notifications that correspond to the
+ * broadcasts that the caller explicitly requested to record using
+ * {@link BroadcastOptions#recordResponseEventWhileInBackground(long)}.
+ *
+ * @return the total number of notifications posted by the app soon after receiving
+ * a broadcast.
+ */
+ @IntRange(from = 0)
+ public int getNotificationsPostedCount() {
+ return mNotificationsPostedCount;
+ }
+
+ /**
+ * Returns the total number of notifications updated by the app soon after receiving a
+ * broadcast.
+ *
+ * <b> Note that the returned count will only include the notifications that correspond to the
+ * broadcasts that the caller explicitly requested to record using
+ * {@link BroadcastOptions#recordResponseEventWhileInBackground(long)}.
+ *
+ * @return the total number of notifications updated by the app soon after receiving
+ * a broadcast.
+ */
+ @IntRange(from = 0)
+ public int getNotificationsUpdatedCount() {
+ return mNotificationsUpdatedCount;
+ }
+
+ /**
+ * Returns the total number of notifications cancelled by the app soon after receiving a
+ * broadcast.
+ *
+ * <b> Note that the returned count will only include the notifications that correspond to the
+ * broadcasts that the caller explicitly requested to record using
+ * {@link BroadcastOptions#recordResponseEventWhileInBackground(long)}.
+ *
+ * @return the total number of notifications cancelled by the app soon after receiving
+ * a broadcast.
+ */
+ @IntRange(from = 0)
+ public int getNotificationsCancelledCount() {
+ return mNotificationsCancelledCount;
+ }
+
+ /** @hide */
+ public void incrementBroadcastsDispatchedCount(@IntRange(from = 0) int count) {
+ mBroadcastsDispatchedCount += count;
+ }
+
+ /** @hide */
+ public void incrementNotificationsPostedCount(@IntRange(from = 0) int count) {
+ mNotificationsPostedCount += count;
+ }
+
+ /** @hide */
+ public void incrementNotificationsUpdatedCount(@IntRange(from = 0) int count) {
+ mNotificationsUpdatedCount += count;
+ }
+
+ /** @hide */
+ public void incrementNotificationsCancelledCount(@IntRange(from = 0) int count) {
+ mNotificationsCancelledCount += count;
+ }
+
+ /** @hide */
+ public void addCounts(@NonNull BroadcastResponseStats stats) {
+ incrementBroadcastsDispatchedCount(stats.getBroadcastsDispatchedCount());
+ incrementNotificationsPostedCount(stats.getNotificationsPostedCount());
+ incrementNotificationsUpdatedCount(stats.getNotificationsUpdatedCount());
+ incrementNotificationsCancelledCount(stats.getNotificationsCancelledCount());
+ }
+
+ @Override
+ public @NonNull String toString() {
+ return "stats {"
+ + "broadcastsSent=" + mBroadcastsDispatchedCount
+ + ",notificationsPosted=" + mNotificationsPostedCount
+ + ",notificationsUpdated=" + mNotificationsUpdatedCount
+ + ",notificationsCancelled=" + mNotificationsCancelledCount
+ + "}";
+ }
+
+ @Override
+ public @ContentsFlags int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(@NonNull Parcel dest, @WriteFlags int flags) {
+ dest.writeString8(mPackageName);
+ dest.writeInt(mBroadcastsDispatchedCount);
+ dest.writeInt(mNotificationsPostedCount);
+ dest.writeInt(mNotificationsUpdatedCount);
+ dest.writeInt(mNotificationsCancelledCount);
+ }
+
+ public static final @NonNull Creator<BroadcastResponseStats> CREATOR =
+ new Creator<BroadcastResponseStats>() {
+ @Override
+ public @NonNull BroadcastResponseStats createFromParcel(@NonNull Parcel source) {
+ return new BroadcastResponseStats(source);
+ }
+
+ @Override
+ public @NonNull BroadcastResponseStats[] newArray(int size) {
+ return new BroadcastResponseStats[size];
+ }
+ };
+}
diff --git a/core/java/android/app/usage/IUsageStatsManager.aidl b/core/java/android/app/usage/IUsageStatsManager.aidl
index 170d766..6f8fea1 100644
--- a/core/java/android/app/usage/IUsageStatsManager.aidl
+++ b/core/java/android/app/usage/IUsageStatsManager.aidl
@@ -17,6 +17,7 @@
package android.app.usage;
import android.app.PendingIntent;
+import android.app.usage.BroadcastResponseStats;
import android.app.usage.UsageEvents;
import android.content.pm.ParceledListSlice;
@@ -71,4 +72,10 @@
int getUsageSource();
void forceUsageSourceSettingRead();
long getLastTimeAnyComponentUsed(String packageName, String callingPackage);
+ @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.PACKAGE_USAGE_STATS)")
+ BroadcastResponseStats queryBroadcastResponseStats(
+ String packageName, long id, String callingPackage, int userId);
+ @JavaPassthrough(annotation="@android.annotation.RequiresPermission(android.Manifest.permission.PACKAGE_USAGE_STATS)")
+ void clearBroadcastResponseStats(String packageName, long id, String callingPackage,
+ int userId);
}
diff --git a/core/java/android/app/usage/UsageStatsManager.java b/core/java/android/app/usage/UsageStatsManager.java
index d7e197e..f9f3476 100644
--- a/core/java/android/app/usage/UsageStatsManager.java
+++ b/core/java/android/app/usage/UsageStatsManager.java
@@ -18,6 +18,7 @@
import android.annotation.CurrentTimeMillisLong;
import android.annotation.IntDef;
+import android.annotation.IntRange;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.RequiresPermission;
@@ -26,6 +27,7 @@
import android.annotation.TestApi;
import android.annotation.UserHandleAware;
import android.app.Activity;
+import android.app.BroadcastOptions;
import android.app.PendingIntent;
import android.compat.annotation.UnsupportedAppUsage;
import android.content.Context;
@@ -1383,4 +1385,65 @@
throw re.rethrowFromSystemServer();
}
}
+
+ /**
+ * Returns the broadcast response stats since the last boot corresponding to
+ * {@code packageName} and {@code id}.
+ *
+ * <p>Broadcast response stats will include the aggregated data of what actions an app took upon
+ * receiving a broadcast. This data will consider the broadcasts that the caller sent to
+ * {@code packageName} and explicitly requested to record the response events using
+ * {@link BroadcastOptions#recordResponseEventWhileInBackground(long)}.
+ *
+ * @param packageName The name of the package that the caller wants to query for.
+ * @param id The ID corresponding to the broadcasts that the caller wants to query for. This is
+ * the ID the caller specifies when requesting a broadcast response event to be
+ * recorded using {@link BroadcastOptions#recordResponseEventWhileInBackground(long)}.
+ *
+ * @return the broadcast response stats corresponding to {@code packageName} and {@code id}.
+ *
+ * @hide
+ */
+ @SystemApi
+ @RequiresPermission(android.Manifest.permission.PACKAGE_USAGE_STATS)
+ @UserHandleAware
+ @NonNull
+ public BroadcastResponseStats queryBroadcastResponseStats(
+ @NonNull String packageName, @IntRange(from = 1) long id) {
+ try {
+ return mService.queryBroadcastResponseStats(packageName, id,
+ mContext.getOpPackageName(), mContext.getUserId());
+ } catch (RemoteException re) {
+ throw re.rethrowFromSystemServer();
+ }
+ }
+
+ /**
+ * Clears the broadcast response stats corresponding to {@code packageName} and {@code id}.
+ *
+ * When a caller uses this API, stats related to the events occurring till that point will be
+ * cleared and subsequent calls to {@link #queryBroadcastResponseStats(String, long)} will
+ * return stats related to events occurring after this.
+ *
+ * @param packageName The name of the package that the caller wants to clear the data for.
+ * @param id The ID corresponding to the broadcasts that the caller wants to clear the data for.
+ * This is the ID the caller specifies when requesting a broadcast response event
+ * to be recorded using
+ * {@link BroadcastOptions#recordResponseEventWhileInBackground(long)}.
+ *
+ * @see #queryBroadcastResponseStats(String, long)
+ * @hide
+ */
+ @SystemApi
+ @RequiresPermission(android.Manifest.permission.PACKAGE_USAGE_STATS)
+ @UserHandleAware
+ public void clearBroadcastResponseStats(@NonNull String packageName,
+ @IntRange(from = 1) long id) {
+ try {
+ mService.clearBroadcastResponseStats(packageName, id,
+ mContext.getOpPackageName(), mContext.getUserId());
+ } catch (RemoteException re) {
+ throw re.rethrowFromSystemServer();
+ }
+ }
}
diff --git a/services/usage/java/com/android/server/usage/BroadcastEvent.java b/services/usage/java/com/android/server/usage/BroadcastEvent.java
new file mode 100644
index 0000000..ceb79c1
--- /dev/null
+++ b/services/usage/java/com/android/server/usage/BroadcastEvent.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2022 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.usage;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.UserIdInt;
+
+import java.util.Objects;
+
+/**
+ * Contains the data needed to identify a broadcast event.
+ */
+class BroadcastEvent {
+ private int mSourceUid;
+ private String mTargetPackage;
+ private int mTargetUserId;
+ private long mIdForResponseEvent;
+
+ BroadcastEvent(int sourceUid, @NonNull String targetPackage, @UserIdInt int targetUserId,
+ long idForResponseEvent) {
+ mSourceUid = sourceUid;
+ mTargetPackage = targetPackage;
+ mTargetUserId = targetUserId;
+ mIdForResponseEvent = idForResponseEvent;
+ }
+
+ public int getSourceUid() {
+ return mSourceUid;
+ }
+
+ public @NonNull String getTargetPackage() {
+ return mTargetPackage;
+ }
+
+ public @UserIdInt int getTargetUserId() {
+ return mTargetUserId;
+ }
+
+ public long getIdForResponseEvent() {
+ return mIdForResponseEvent;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (obj == null || !(obj instanceof BroadcastEvent)) {
+ return false;
+ }
+ final BroadcastEvent other = (BroadcastEvent) obj;
+ return this.mSourceUid == other.mSourceUid
+ && this.mIdForResponseEvent == other.mIdForResponseEvent
+ && this.mTargetUserId == other.mTargetUserId
+ && this.mTargetPackage.equals(other.mTargetPackage);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mSourceUid, mTargetPackage, mTargetUserId,
+ mIdForResponseEvent);
+ }
+
+ @Override
+ public @NonNull String toString() {
+ return "BroadcastEvent {"
+ + "srcUid=" + mSourceUid
+ + ",tgtPkg=" + mTargetPackage
+ + ",tgtUser=" + mTargetUserId
+ + ",id=" + mIdForResponseEvent
+ + "}";
+ }
+}
diff --git a/services/usage/java/com/android/server/usage/BroadcastResponseStatsTracker.java b/services/usage/java/com/android/server/usage/BroadcastResponseStatsTracker.java
new file mode 100644
index 0000000..aa01f31
--- /dev/null
+++ b/services/usage/java/com/android/server/usage/BroadcastResponseStatsTracker.java
@@ -0,0 +1,343 @@
+/*
+ * Copyright (C) 2022 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.usage;
+
+import android.annotation.ElapsedRealtimeLong;
+import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.UserIdInt;
+import android.app.usage.BroadcastResponseStats;
+import android.os.UserHandle;
+import android.util.LongSparseArray;
+import android.util.Slog;
+import android.util.SparseArray;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.util.IndentingPrintWriter;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+class BroadcastResponseStatsTracker {
+ private static final String TAG = "ResponseStatsTracker";
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(prefix = {"NOTIFICATION_EVENT"}, value = {
+ NOTIFICATION_EVENT_POSTED,
+ NOTIFICATION_EVENT_UPDATED,
+ NOTIFICATION_EVENT_CANCELLED
+ })
+ public @interface NotificationEvent {}
+
+ private static final int NOTIFICATION_EVENT_POSTED = 0;
+ private static final int NOTIFICATION_EVENT_UPDATED = 1;
+ private static final int NOTIFICATION_EVENT_CANCELLED = 2;
+
+ private final Object mLock = new Object();
+
+ /**
+ * Contains the mapping of user -> UserBroadcastEvents data.
+ */
+ @GuardedBy("mLock")
+ private SparseArray<UserBroadcastEvents> mUserBroadcastEvents = new SparseArray<>();
+
+ /**
+ * Contains the mapping of sourceUid -> {targetUser -> UserBroadcastResponseStats} data.
+ * Here sourceUid refers to the uid that sent a broadcast and targetUser is the user that the
+ * broadcast was directed to.
+ */
+ @GuardedBy("mLock")
+ private SparseArray<SparseArray<UserBroadcastResponseStats>> mUserResponseStats =
+ new SparseArray<>();
+
+ // TODO (206518114): Move all callbacks handling to a handler thread.
+ void reportBroadcastDispatchEvent(int sourceUid, @NonNull String targetPackage,
+ UserHandle targetUser, long idForResponseEvent,
+ @ElapsedRealtimeLong long timestampMs) {
+ synchronized (mLock) {
+ final LongSparseArray<BroadcastEvent> broadcastEvents =
+ getOrCreateBroadcastEventsLocked(targetPackage, targetUser);
+ final BroadcastEvent broadcastEvent = new BroadcastEvent(
+ sourceUid, targetPackage, targetUser.getIdentifier(), idForResponseEvent);
+ broadcastEvents.append(timestampMs, broadcastEvent);
+ final BroadcastResponseStats responseStats =
+ getOrCreateBroadcastResponseStats(broadcastEvent);
+ responseStats.incrementBroadcastsDispatchedCount(1);
+ }
+ }
+
+ void reportNotificationPosted(@NonNull String packageName, UserHandle user,
+ @ElapsedRealtimeLong long timestampMs) {
+ reportNotificationEvent(NOTIFICATION_EVENT_POSTED, packageName, user, timestampMs);
+ }
+
+ void reportNotificationUpdated(@NonNull String packageName, UserHandle user,
+ @ElapsedRealtimeLong long timestampMs) {
+ reportNotificationEvent(NOTIFICATION_EVENT_UPDATED, packageName, user, timestampMs);
+
+ }
+
+ void reportNotificationCancelled(@NonNull String packageName, UserHandle user,
+ @ElapsedRealtimeLong long timestampMs) {
+ reportNotificationEvent(NOTIFICATION_EVENT_CANCELLED, packageName, user, timestampMs);
+ }
+
+ private void reportNotificationEvent(@NotificationEvent int event,
+ @NonNull String packageName, UserHandle user, @ElapsedRealtimeLong long timestampMs) {
+ // TODO (206518114): Store last N events to dump for debugging purposes.
+ synchronized (mLock) {
+ final LongSparseArray<BroadcastEvent> broadcastEvents =
+ getBroadcastEventsLocked(packageName, user);
+ if (broadcastEvents == null) {
+ return;
+ }
+ // TODO (206518114): Add LongSparseArray.removeAtRange()
+ for (int i = broadcastEvents.size() - 1; i >= 0; --i) {
+ final long dispatchTimestampMs = broadcastEvents.keyAt(i);
+ final long elapsedDurationMs = timestampMs - dispatchTimestampMs;
+ if (elapsedDurationMs <= 0) {
+ continue;
+ }
+ if (dispatchTimestampMs >= timestampMs) {
+ continue;
+ }
+ // TODO (206518114): Make the constant configurable.
+ if (elapsedDurationMs <= 2 * 60 * 1000) {
+ final BroadcastEvent broadcastEvent = broadcastEvents.valueAt(i);
+ final BroadcastResponseStats responseStats =
+ getBroadcastResponseStats(broadcastEvent);
+ if (responseStats == null) {
+ continue;
+ }
+ switch (event) {
+ case NOTIFICATION_EVENT_POSTED:
+ responseStats.incrementNotificationsPostedCount(1);
+ break;
+ case NOTIFICATION_EVENT_UPDATED:
+ responseStats.incrementNotificationsUpdatedCount(1);
+ break;
+ case NOTIFICATION_EVENT_CANCELLED:
+ responseStats.incrementNotificationsCancelledCount(1);
+ break;
+ default:
+ Slog.wtf(TAG, "Unknown event: " + event);
+ }
+ }
+ broadcastEvents.removeAt(i);
+ }
+ }
+ }
+
+ @NonNull BroadcastResponseStats queryBroadcastResponseStats(int callingUid,
+ @NonNull String packageName, long id, @UserIdInt int userId) {
+ final BroadcastResponseStats aggregatedResponseStats =
+ new BroadcastResponseStats(packageName);
+ synchronized (mLock) {
+ final SparseArray<UserBroadcastResponseStats> responseStatsForCaller =
+ mUserResponseStats.get(callingUid);
+ if (responseStatsForCaller == null) {
+ return aggregatedResponseStats;
+ }
+ final UserBroadcastResponseStats responseStatsForUser =
+ responseStatsForCaller.get(userId);
+ if (responseStatsForUser == null) {
+ return aggregatedResponseStats;
+ }
+ responseStatsForUser.aggregateBroadcastResponseStats(aggregatedResponseStats,
+ packageName, id);
+ }
+ return aggregatedResponseStats;
+ }
+
+ void clearBroadcastResponseStats(int callingUid, @NonNull String packageName, long id,
+ @UserIdInt int userId) {
+ synchronized (mLock) {
+ final SparseArray<UserBroadcastResponseStats> responseStatsForCaller =
+ mUserResponseStats.get(callingUid);
+ if (responseStatsForCaller == null) {
+ return;
+ }
+ final UserBroadcastResponseStats responseStatsForUser =
+ responseStatsForCaller.get(userId);
+ if (responseStatsForUser == null) {
+ return;
+ }
+ responseStatsForUser.clearBroadcastResponseStats(packageName, id);
+ }
+ }
+
+ void onUserRemoved(@UserIdInt int userId) {
+ synchronized (mLock) {
+ mUserBroadcastEvents.remove(userId);
+
+ for (int i = mUserResponseStats.size() - 1; i >= 0; --i) {
+ mUserResponseStats.valueAt(i).remove(userId);
+ }
+ }
+ }
+
+ void onPackageRemoved(@NonNull String packageName, @UserIdInt int userId) {
+ synchronized (mLock) {
+ final UserBroadcastEvents userBroadcastEvents = mUserBroadcastEvents.get(userId);
+ if (userBroadcastEvents != null) {
+ userBroadcastEvents.onPackageRemoved(packageName);
+ }
+
+ for (int i = mUserResponseStats.size() - 1; i >= 0; --i) {
+ final UserBroadcastResponseStats userResponseStats =
+ mUserResponseStats.valueAt(i).get(userId);
+ if (userResponseStats != null) {
+ userResponseStats.onPackageRemoved(packageName);
+ }
+ }
+ }
+ }
+
+ void onUidRemoved(int uid) {
+ synchronized (mLock) {
+ for (int i = mUserBroadcastEvents.size() - 1; i >= 0; --i) {
+ mUserBroadcastEvents.valueAt(i).onUidRemoved(uid);
+ }
+
+ mUserResponseStats.remove(uid);
+ }
+ }
+
+ @GuardedBy("mLock")
+ @Nullable
+ private LongSparseArray<BroadcastEvent> getBroadcastEventsLocked(
+ @NonNull String packageName, UserHandle user) {
+ final UserBroadcastEvents userBroadcastEvents = mUserBroadcastEvents.get(
+ user.getIdentifier());
+ if (userBroadcastEvents == null) {
+ return null;
+ }
+ return userBroadcastEvents.getBroadcastEvents(packageName);
+ }
+
+ @GuardedBy("mLock")
+ @NonNull
+ private LongSparseArray<BroadcastEvent> getOrCreateBroadcastEventsLocked(
+ @NonNull String packageName, UserHandle user) {
+ UserBroadcastEvents userBroadcastEvents = mUserBroadcastEvents.get(user.getIdentifier());
+ if (userBroadcastEvents == null) {
+ userBroadcastEvents = new UserBroadcastEvents();
+ mUserBroadcastEvents.put(user.getIdentifier(), userBroadcastEvents);
+ }
+ return userBroadcastEvents.getOrCreateBroadcastEvents(packageName);
+ }
+
+ @GuardedBy("mLock")
+ @Nullable
+ private BroadcastResponseStats getBroadcastResponseStats(
+ @NonNull BroadcastEvent broadcastEvent) {
+ final int sourceUid = broadcastEvent.getSourceUid();
+ final SparseArray<UserBroadcastResponseStats> responseStatsForUid =
+ mUserResponseStats.get(sourceUid);
+ return getBroadcastResponseStats(responseStatsForUid, broadcastEvent);
+ }
+
+ @GuardedBy("mLock")
+ @Nullable
+ private BroadcastResponseStats getBroadcastResponseStats(
+ @Nullable SparseArray<UserBroadcastResponseStats> responseStatsForUid,
+ @NonNull BroadcastEvent broadcastEvent) {
+ if (responseStatsForUid == null) {
+ return null;
+ }
+ final UserBroadcastResponseStats userResponseStats = responseStatsForUid.get(
+ broadcastEvent.getTargetUserId());
+ if (userResponseStats == null) {
+ return null;
+ }
+ return userResponseStats.getBroadcastResponseStats(broadcastEvent);
+ }
+
+ @GuardedBy("mLock")
+ @NonNull
+ private BroadcastResponseStats getOrCreateBroadcastResponseStats(
+ @NonNull BroadcastEvent broadcastEvent) {
+ final int sourceUid = broadcastEvent.getSourceUid();
+ SparseArray<UserBroadcastResponseStats> userResponseStatsForUid =
+ mUserResponseStats.get(sourceUid);
+ if (userResponseStatsForUid == null) {
+ userResponseStatsForUid = new SparseArray<>();
+ mUserResponseStats.put(sourceUid, userResponseStatsForUid);
+ }
+ UserBroadcastResponseStats userResponseStats = userResponseStatsForUid.get(
+ broadcastEvent.getTargetUserId());
+ if (userResponseStats == null) {
+ userResponseStats = new UserBroadcastResponseStats();
+ userResponseStatsForUid.put(broadcastEvent.getTargetUserId(), userResponseStats);
+ }
+ return userResponseStats.getOrCreateBroadcastResponseStats(broadcastEvent);
+ }
+
+ void dump(@NonNull IndentingPrintWriter ipw) {
+ ipw.println("Broadcast response stats:");
+ ipw.increaseIndent();
+
+ synchronized (mLock) {
+ dumpBroadcastEventsLocked(ipw);
+ ipw.println();
+ dumpResponseStatsLocked(ipw);
+ }
+
+ ipw.decreaseIndent();
+ }
+
+ @GuardedBy("mLock")
+ private void dumpBroadcastEventsLocked(@NonNull IndentingPrintWriter ipw) {
+ ipw.println("Broadcast events:");
+ ipw.increaseIndent();
+ for (int i = 0; i < mUserBroadcastEvents.size(); ++i) {
+ final int userId = mUserBroadcastEvents.keyAt(i);
+ final UserBroadcastEvents userBroadcastEvents = mUserBroadcastEvents.valueAt(i);
+ ipw.println("User " + userId + ":");
+ ipw.increaseIndent();
+ userBroadcastEvents.dump(ipw);
+ ipw.decreaseIndent();
+ }
+ ipw.decreaseIndent();
+ }
+
+ @GuardedBy("mLock")
+ private void dumpResponseStatsLocked(@NonNull IndentingPrintWriter ipw) {
+ ipw.println("Response stats:");
+ ipw.increaseIndent();
+ for (int i = 0; i < mUserResponseStats.size(); ++i) {
+ final int sourceUid = mUserResponseStats.keyAt(i);
+ final SparseArray<UserBroadcastResponseStats> userBroadcastResponseStats =
+ mUserResponseStats.valueAt(i);
+ ipw.println("Uid " + sourceUid + ":");
+ ipw.increaseIndent();
+ for (int j = 0; j < userBroadcastResponseStats.size(); ++j) {
+ final int userId = userBroadcastResponseStats.keyAt(j);
+ final UserBroadcastResponseStats broadcastResponseStats =
+ userBroadcastResponseStats.valueAt(j);
+ ipw.println("User " + userId + ":");
+ ipw.increaseIndent();
+ broadcastResponseStats.dump(ipw);
+ ipw.decreaseIndent();
+ }
+ ipw.decreaseIndent();
+ }
+ ipw.decreaseIndent();
+ }
+}
+
diff --git a/services/usage/java/com/android/server/usage/UsageStatsService.java b/services/usage/java/com/android/server/usage/UsageStatsService.java
index 559eb38..e28839e 100644
--- a/services/usage/java/com/android/server/usage/UsageStatsService.java
+++ b/services/usage/java/com/android/server/usage/UsageStatsService.java
@@ -29,13 +29,17 @@
import static android.app.usage.UsageEvents.Event.USER_UNLOCKED;
import static android.app.usage.UsageStatsManager.USAGE_SOURCE_CURRENT_ACTIVITY;
import static android.app.usage.UsageStatsManager.USAGE_SOURCE_TASK_ROOT_ACTIVITY;
+import static android.content.Intent.ACTION_UID_REMOVED;
+import static android.content.Intent.EXTRA_UID;
import static android.text.format.DateUtils.HOUR_IN_MILLIS;
import android.Manifest;
import android.annotation.CurrentTimeMillisLong;
import android.annotation.ElapsedRealtimeLong;
+import android.annotation.IntRange;
import android.annotation.NonNull;
import android.annotation.Nullable;
+import android.annotation.SuppressLint;
import android.annotation.UserIdInt;
import android.app.ActivityManager;
import android.app.AppOpsManager;
@@ -44,6 +48,7 @@
import android.app.admin.DevicePolicyManagerInternal;
import android.app.usage.AppLaunchEstimateInfo;
import android.app.usage.AppStandbyInfo;
+import android.app.usage.BroadcastResponseStats;
import android.app.usage.ConfigurationStats;
import android.app.usage.EventStats;
import android.app.usage.IUsageStatsManager;
@@ -217,6 +222,8 @@
private final CopyOnWriteArraySet<UsageStatsManagerInternal.EstimatedLaunchTimeChangedListener>
mEstimatedLaunchTimeChangedListeners = new CopyOnWriteArraySet<>();
+ private BroadcastResponseStatsTracker mResponseStatsTracker;
+
private static class ActivityData {
private final String mTaskRootPackage;
private final String mTaskRootClass;
@@ -263,6 +270,7 @@
}
@Override
+ @SuppressLint("AndroidFrameworkRequiresPermission")
public void onStart() {
mAppOps = (AppOpsManager) getContext().getSystemService(Context.APP_OPS_SERVICE);
mUserManager = (UserManager) getContext().getSystemService(Context.USER_SERVICE);
@@ -271,6 +279,7 @@
mHandler = new H(BackgroundThread.get().getLooper());
mAppStandby = mInjector.getAppStandbyController(getContext());
+ mResponseStatsTracker = new BroadcastResponseStatsTracker();
mAppTimeLimit = new AppTimeLimitController(getContext(),
new AppTimeLimitController.TimeLimitCallbackListener() {
@@ -315,6 +324,9 @@
getContext().registerReceiverAsUser(new UserActionsReceiver(), UserHandle.ALL, filter,
null, mHandler);
+ getContext().registerReceiverAsUser(new UidRemovedReceiver(), UserHandle.ALL,
+ new IntentFilter(ACTION_UID_REMOVED), null, mHandler);
+
mRealTimeSnapshot = SystemClock.elapsedRealtime();
mSystemTimeSnapshot = System.currentTimeMillis();
@@ -536,6 +548,7 @@
if (Intent.ACTION_USER_REMOVED.equals(action)) {
if (userId >= 0) {
mHandler.obtainMessage(MSG_REMOVE_USER, userId, 0).sendToTarget();
+ mResponseStatsTracker.onUserRemoved(userId);
}
} else if (Intent.ACTION_USER_STARTED.equals(action)) {
if (userId >= 0) {
@@ -545,6 +558,20 @@
}
}
+ private class UidRemovedReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ final int uid = intent.getIntExtra(EXTRA_UID, -1);
+ if (uid == -1) {
+ return;
+ }
+
+ synchronized (mLock) {
+ mResponseStatsTracker.onUidRemoved(uid);
+ }
+ }
+ }
+
private final IUidObserver mUidObserver = new IUidObserver.Stub() {
@Override
public void onUidStateChanged(int uid, int procState, long procStateSeq, int capability) {
@@ -1780,6 +1807,10 @@
}
return;
}
+ } else if ("broadcast-response-stats".equals(arg)) {
+ synchronized (mLock) {
+ mResponseStatsTracker.dump(idpw);
+ }
} else if (arg != null && !arg.startsWith("-")) {
// Anything else that doesn't start with '-' is a pkg to filter
pkgs.add(arg);
@@ -1813,6 +1844,9 @@
idpw.println();
mAppTimeLimit.dump(null, pw);
+
+ idpw.println();
+ mResponseStatsTracker.dump(idpw);
}
mAppStandby.dumpUsers(idpw, userIds, pkgs);
@@ -2645,6 +2679,60 @@
/ TimeUnit.DAYS.toMillis(1) * TimeUnit.DAYS.toMillis(1);
}
}
+
+ @Override
+ @NonNull
+ public BroadcastResponseStats queryBroadcastResponseStats(
+ @NonNull String packageName,
+ @IntRange(from = 1) long id,
+ @NonNull String callingPackage,
+ @UserIdInt int userId) {
+ Objects.requireNonNull(packageName);
+ Objects.requireNonNull(callingPackage);
+ // TODO: Move to Preconditions utility class
+ if (id <= 0) {
+ throw new IllegalArgumentException("id needs to be >0");
+ }
+
+ final int callingUid = Binder.getCallingUid();
+ if (!hasPermission(callingPackage)) {
+ throw new SecurityException(
+ "Caller does not have the permission needed to call this API; "
+ + "callingPackage=" + callingPackage
+ + ", callingUid=" + callingUid);
+ }
+ userId = ActivityManager.handleIncomingUser(Binder.getCallingPid(), callingUid,
+ userId, false /* allowAll */, false /* requireFull */,
+ "queryBroadcastResponseStats" /* name */, callingPackage);
+ return mResponseStatsTracker.queryBroadcastResponseStats(
+ callingUid, packageName, id, userId);
+ }
+
+ @Override
+ public void clearBroadcastResponseStats(
+ @NonNull String packageName,
+ @IntRange(from = 1) long id,
+ @NonNull String callingPackage,
+ @UserIdInt int userId) {
+ Objects.requireNonNull(packageName);
+ Objects.requireNonNull(callingPackage);
+ if (id <= 0) {
+ throw new IllegalArgumentException("id needs to be >0");
+ }
+
+ final int callingUid = Binder.getCallingUid();
+ if (!hasPermission(callingPackage)) {
+ throw new SecurityException(
+ "Caller does not have the permission needed to call this API; "
+ + "callingPackage=" + callingPackage
+ + ", callingUid=" + callingUid);
+ }
+ userId = ActivityManager.handleIncomingUser(Binder.getCallingPid(), callingUid,
+ userId, false /* allowAll */, false /* requireFull */,
+ "clearBroadcastResponseStats" /* name */, callingPackage);
+ mResponseStatsTracker.clearBroadcastResponseStats(callingUid,
+ packageName, id, userId);
+ }
}
void registerAppUsageObserver(int callingUid, int observerId, String[] packages,
@@ -2953,21 +3041,26 @@
public void reportBroadcastDispatched(int sourceUid, @NonNull String targetPackage,
@NonNull UserHandle targetUser, long idForResponseEvent,
@ElapsedRealtimeLong long timestampMs) {
+ mResponseStatsTracker.reportBroadcastDispatchEvent(sourceUid, targetPackage,
+ targetUser, idForResponseEvent, timestampMs);
}
@Override
public void reportNotificationPosted(@NonNull String packageName,
@NonNull UserHandle user, @ElapsedRealtimeLong long timestampMs) {
+ mResponseStatsTracker.reportNotificationPosted(packageName, user, timestampMs);
}
@Override
public void reportNotificationUpdated(@NonNull String packageName,
@NonNull UserHandle user, @ElapsedRealtimeLong long timestampMs) {
+ mResponseStatsTracker.reportNotificationUpdated(packageName, user, timestampMs);
}
@Override
public void reportNotificationRemoved(@NonNull String packageName,
@NonNull UserHandle user, @ElapsedRealtimeLong long timestampMs) {
+ mResponseStatsTracker.reportNotificationCancelled(packageName, user, timestampMs);
}
}
@@ -2980,6 +3073,7 @@
mHandler.obtainMessage(MSG_PACKAGE_REMOVED, changingUserId, 0, packageName)
.sendToTarget();
}
+ mResponseStatsTracker.onPackageRemoved(packageName, UserHandle.getUserId(uid));
super.onPackageRemoved(packageName, uid);
}
}
diff --git a/services/usage/java/com/android/server/usage/UserBroadcastEvents.java b/services/usage/java/com/android/server/usage/UserBroadcastEvents.java
new file mode 100644
index 0000000..819644846
--- /dev/null
+++ b/services/usage/java/com/android/server/usage/UserBroadcastEvents.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2022 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.usage;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.util.ArrayMap;
+import android.util.LongSparseArray;
+import android.util.TimeUtils;
+
+import com.android.internal.util.IndentingPrintWriter;
+
+class UserBroadcastEvents {
+ /**
+ * Contains the mapping of targetPackage -> {BroadcastEvent} data.
+ * Here targetPackage refers to the package receiving the broadcast and BroadcastEvent objects
+ * corresponding to each broadcast it is receiving.
+ */
+ private ArrayMap<String, LongSparseArray<BroadcastEvent>> mBroadcastEvents = new ArrayMap();
+
+ @Nullable LongSparseArray<BroadcastEvent> getBroadcastEvents(@NonNull String packageName) {
+ return mBroadcastEvents.get(packageName);
+ }
+
+ @NonNull LongSparseArray<BroadcastEvent> getOrCreateBroadcastEvents(
+ @NonNull String packageName) {
+ LongSparseArray<BroadcastEvent> broadcastEvents = mBroadcastEvents.get(packageName);
+ if (broadcastEvents == null) {
+ broadcastEvents = new LongSparseArray<>();
+ mBroadcastEvents.put(packageName, broadcastEvents);
+ }
+ return broadcastEvents;
+ }
+
+ void onPackageRemoved(@NonNull String packageName) {
+ mBroadcastEvents.remove(packageName);
+ }
+
+ void onUidRemoved(int uid) {
+ for (int i = mBroadcastEvents.size() - 1; i >= 0; --i) {
+ final LongSparseArray<BroadcastEvent> broadcastEvents = mBroadcastEvents.valueAt(i);
+ for (int j = broadcastEvents.size() - 1; j >= 0; --j) {
+ if (broadcastEvents.valueAt(j).getSourceUid() == uid) {
+ broadcastEvents.removeAt(j);
+ }
+ }
+ }
+ }
+
+ void dump(@NonNull IndentingPrintWriter ipw) {
+ for (int i = 0; i < mBroadcastEvents.size(); ++i) {
+ final String packageName = mBroadcastEvents.keyAt(i);
+ final LongSparseArray<BroadcastEvent> broadcastEvents = mBroadcastEvents.valueAt(i);
+ ipw.println(packageName + ":");
+ ipw.increaseIndent();
+ if (broadcastEvents.size() == 0) {
+ ipw.println("<empty>");
+ } else {
+ for (int j = 0; j < broadcastEvents.size(); ++j) {
+ final long timestampMs = broadcastEvents.keyAt(j);
+ final BroadcastEvent broadcastEvent = broadcastEvents.valueAt(j);
+ TimeUtils.formatDuration(timestampMs, ipw);
+ ipw.print(": ");
+ ipw.println(broadcastEvent);
+ }
+ }
+ ipw.decreaseIndent();
+ }
+ }
+}
diff --git a/services/usage/java/com/android/server/usage/UserBroadcastResponseStats.java b/services/usage/java/com/android/server/usage/UserBroadcastResponseStats.java
new file mode 100644
index 0000000..ac2a320
--- /dev/null
+++ b/services/usage/java/com/android/server/usage/UserBroadcastResponseStats.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2022 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.usage;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.usage.BroadcastResponseStats;
+import android.util.ArrayMap;
+
+import com.android.internal.util.IndentingPrintWriter;
+
+class UserBroadcastResponseStats {
+ /**
+ * Contains the mapping of a BroadcastEvent type to it's aggregated stats.
+ */
+ private ArrayMap<BroadcastEvent, BroadcastResponseStats> mResponseStats =
+ new ArrayMap<>();
+
+ @Nullable BroadcastResponseStats getBroadcastResponseStats(
+ BroadcastEvent broadcastEvent) {
+ return mResponseStats.get(broadcastEvent);
+ }
+
+ @NonNull BroadcastResponseStats getOrCreateBroadcastResponseStats(
+ BroadcastEvent broadcastEvent) {
+ BroadcastResponseStats responseStats = mResponseStats.get(broadcastEvent);
+ if (responseStats == null) {
+ responseStats = new BroadcastResponseStats(broadcastEvent.getTargetPackage());
+ mResponseStats.put(broadcastEvent, responseStats);
+ }
+ return responseStats;
+ }
+
+ void aggregateBroadcastResponseStats(
+ @NonNull BroadcastResponseStats responseStats,
+ @NonNull String packageName, long id) {
+ for (int i = mResponseStats.size() - 1; i >= 0; --i) {
+ final BroadcastEvent broadcastEvent = mResponseStats.keyAt(i);
+ if (broadcastEvent.getIdForResponseEvent() == id
+ && broadcastEvent.getTargetPackage().equals(packageName)) {
+ responseStats.addCounts(mResponseStats.valueAt(i));
+ }
+ }
+ }
+
+ void clearBroadcastResponseStats(@NonNull String packageName, long id) {
+ for (int i = mResponseStats.size() - 1; i >= 0; --i) {
+ final BroadcastEvent broadcastEvent = mResponseStats.keyAt(i);
+ if (broadcastEvent.getIdForResponseEvent() == id
+ && broadcastEvent.getTargetPackage().equals(packageName)) {
+ mResponseStats.removeAt(i);
+ }
+ }
+ }
+
+ void onPackageRemoved(@NonNull String packageName) {
+ for (int i = mResponseStats.size() - 1; i >= 0; --i) {
+ if (mResponseStats.keyAt(i).getTargetPackage().equals(packageName)) {
+ mResponseStats.removeAt(i);
+ }
+ }
+ }
+
+ void dump(@NonNull IndentingPrintWriter ipw) {
+ for (int i = 0; i < mResponseStats.size(); ++i) {
+ final BroadcastEvent broadcastEvent = mResponseStats.keyAt(i);
+ final BroadcastResponseStats responseStats = mResponseStats.valueAt(i);
+ ipw.print(broadcastEvent);
+ ipw.print(" -> ");
+ ipw.println(responseStats);
+ }
+ }
+}