Loading core/java/android/appwidget/AppWidgetHostView.java +127 −3 Original line number Diff line number Diff line Loading @@ -43,6 +43,7 @@ import android.os.Build; import android.os.Bundle; import android.os.CancellationSignal; import android.os.Parcelable; import android.os.SystemClock; import android.util.ArraySet; import android.util.AttributeSet; import android.util.Log; Loading @@ -53,6 +54,7 @@ import android.util.SparseIntArray; import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewParent; import android.view.accessibility.AccessibilityNodeInfo; import android.widget.AbsListView; import android.widget.Adapter; Loading Loading @@ -351,6 +353,9 @@ public class AppWidgetHostView extends FrameLayout implements AppWidgetHost.AppW 0 /* heightUsed */); } } if (changed) { post(mInteractionLogger::onPositionChanged); } super.onLayout(changed, left, top, right, bottom); } catch (final RuntimeException e) { Log.e(TAG, "Remote provider threw runtime exception, using error view instead.", e); Loading @@ -358,6 +363,12 @@ public class AppWidgetHostView extends FrameLayout implements AppWidgetHost.AppW } } @Override public void onWindowFocusChanged(boolean hasWindowFocus) { super.onWindowFocusChanged(hasWindowFocus); mInteractionLogger.onWindowFocusChanged(hasWindowFocus); } /** * Remove bad view and replace with error message view */ Loading Loading @@ -1022,6 +1033,7 @@ public class AppWidgetHostView extends FrameLayout implements AppWidgetHost.AppW protected void dispatchDraw(@NonNull Canvas canvas) { try { super.dispatchDraw(canvas); mInteractionLogger.onDraw(); } catch (Exception e) { // Catch draw exceptions that may be caused by RemoteViews Log.e(TAG, "Drawing view failed: " + e); Loading @@ -1036,6 +1048,8 @@ public class AppWidgetHostView extends FrameLayout implements AppWidgetHost.AppW public class InteractionLogger implements RemoteViews.InteractionHandler { // Max number of clicked and scrolled IDs stored per impression. public static final int MAX_NUM_ITEMS = 10; // Determines the minimum time between calls to updateVisibility(). private static final long UPDATE_VISIBILITY_DELAY_MS = 1000L; // Clicked views @NonNull private final Set<Integer> mClickedIds = new ArraySet<>(MAX_NUM_ITEMS); Loading @@ -1044,6 +1058,15 @@ public class AppWidgetHostView extends FrameLayout implements AppWidgetHost.AppW private final Set<Integer> mScrolledIds = new ArraySet<>(MAX_NUM_ITEMS); @Nullable private RemoteViews.InteractionHandler mInteractionHandler = null; // Last position this widget was laid out in @Nullable private Rect mPosition = null; // Total duration for the impression private long mDurationMs = 0L; // Last time the widget became visible in SystemClock.uptimeMillis() private long mVisibilityChangeMs = 0L; private boolean mIsVisible = false; private boolean mUpdateVisibilityScheduled = false; InteractionLogger() { } Loading @@ -1064,6 +1087,17 @@ public class AppWidgetHostView extends FrameLayout implements AppWidgetHost.AppW return mScrolledIds; } @VisibleForTesting public long getDurationMs() { return mDurationMs; } @VisibleForTesting @Nullable public Rect getPosition() { return mPosition; } @Override public boolean onInteraction(View view, PendingIntent pendingIntent, RemoteViews.RemoteResponse response) { Loading Loading @@ -1098,12 +1132,102 @@ public class AppWidgetHostView extends FrameLayout implements AppWidgetHost.AppW @FlaggedApi(FLAG_ENGAGEMENT_METRICS) private int getMetricsId(@NonNull View view) { int viewId = view.getId(); Object metricsTag = view.getTag(com.android.internal.R.id.remoteViewsMetricsId); if (metricsTag instanceof Integer tag) { viewId = tag; return tag; } else { return view.getId(); } } /** * Invoked when the root view is resized or moved. */ private void onPositionChanged() { if (!engagementMetrics()) return; mPosition = new Rect(); if (getGlobalVisibleRect(mPosition)) { applyScrollOffset(); } } /** * Finds the first parent with a scrollX or scrollY offset and applies it to the current * position Rect. This corresponds to the current "page" of this widget on its workspace. */ private void applyScrollOffset() { if (mPosition == null) return; int dx = 0; int dy = 0; for (ViewParent parent = getParent(); parent != null; parent = parent.getParent()) { if (parent instanceof View view && (view.getScrollX() != 0 || view.getScrollY() != 0)) { dx = view.getScrollX(); dy = view.getScrollY(); break; } } mPosition.offset(dx, dy); } return viewId; private void onDraw() { if (!engagementMetrics()) return; if (getParent() instanceof View view && view.isDirty()) { scheduleUpdateVisibility(); } } private void onWindowFocusChanged(boolean hasWindowFocus) { if (!engagementMetrics()) return; updateVisibility(hasWindowFocus); } /** * Schedule a delayed call to updateVisibility. Will skip if a call is already scheduled. */ private void scheduleUpdateVisibility() { if (mUpdateVisibilityScheduled) { return; } postDelayed(() -> updateVisibility(hasWindowFocus()), UPDATE_VISIBILITY_DELAY_MS); mUpdateVisibilityScheduled = true; } /** * Check if this view is currently visible, and update the duration if an impression has * finished. */ private void updateVisibility(boolean hasWindowFocus) { boolean wasVisible = mIsVisible; boolean isVisible = hasWindowFocus && testVisibility(AppWidgetHostView.this); if (isVisible) { // Test parent visibility. for (ViewParent parent = getParent(); parent != null && isVisible; parent = parent.getParent()) { if (parent instanceof View view) { isVisible = testVisibility(view); } else { break; } } } if (!wasVisible && isVisible) { // View has become visible, start the tracker. mVisibilityChangeMs = SystemClock.uptimeMillis(); } else if (wasVisible && !isVisible) { // View is no longer visible, add duration. mDurationMs += SystemClock.uptimeMillis() - mVisibilityChangeMs; } mIsVisible = isVisible; mUpdateVisibilityScheduled = false; } private boolean testVisibility(View view) { return view.isAggregatedVisible() && view.getGlobalVisibleRect(new Rect()) && view.getAlpha() != 0; } } } Loading core/tests/coretests/src/android/appwidget/AppWidgetEventsTest.kt +44 −0 Original line number Diff line number Diff line Loading @@ -16,6 +16,8 @@ package android.appwidget import android.app.Activity import android.app.EmptyActivity import android.app.PendingIntent import android.appwidget.AppWidgetHostView.InteractionLogger.MAX_NUM_ITEMS import android.content.Intent Loading @@ -23,10 +25,12 @@ import android.graphics.Rect import android.view.View import android.widget.ListView import android.widget.RemoteViews import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.android.frameworks.coretests.R import com.google.common.truth.Truth.assertThat import java.util.concurrent.CountDownLatch import org.junit.Test import org.junit.runner.RunWith Loading Loading @@ -186,4 +190,44 @@ class AppWidgetEventsTest { assertThat(hostView.interactionLogger.scrolledIds) .containsExactlyElementsIn(0..itemCount.minus(2)) } @Test fun interactionLogger_impression() { val remoteViews = RemoteViews(context.packageName, R.layout.remote_views_test) hostView.updateAppWidget(remoteViews) assertThat(hostView.interactionLogger.durationMs).isEqualTo(0) ActivityScenario<Activity>.launch(EmptyActivity::class.java).use { scenario -> scenario.onActivity { activity -> activity.setContentView(hostView) hostView.layout(0, 0, 500, 500) hostView.dispatchWindowFocusChanged(true) } Thread.sleep(2000L) hostView.dispatchWindowFocusChanged(false) assertThat(hostView.interactionLogger.durationMs).isGreaterThan(2000L) } } @Test fun interactionLogger_position() { val remoteViews = RemoteViews(context.packageName, R.layout.remote_views_test) hostView.updateAppWidget(remoteViews) assertThat(hostView.interactionLogger.position).isNull() ActivityScenario<Activity>.launch(EmptyActivity::class.java).use { scenario -> val latch = CountDownLatch(1) scenario.onActivity { activity -> activity.setContentView(hostView) hostView.layout(0, 0, 500, 500) hostView.post { val rect = Rect() assertThat(hostView.getGlobalVisibleRect(rect)).isTrue() assertThat(hostView.interactionLogger.position).isEqualTo(rect) latch.countDown() } } latch.await() } } } Loading
core/java/android/appwidget/AppWidgetHostView.java +127 −3 Original line number Diff line number Diff line Loading @@ -43,6 +43,7 @@ import android.os.Build; import android.os.Bundle; import android.os.CancellationSignal; import android.os.Parcelable; import android.os.SystemClock; import android.util.ArraySet; import android.util.AttributeSet; import android.util.Log; Loading @@ -53,6 +54,7 @@ import android.util.SparseIntArray; import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewParent; import android.view.accessibility.AccessibilityNodeInfo; import android.widget.AbsListView; import android.widget.Adapter; Loading Loading @@ -351,6 +353,9 @@ public class AppWidgetHostView extends FrameLayout implements AppWidgetHost.AppW 0 /* heightUsed */); } } if (changed) { post(mInteractionLogger::onPositionChanged); } super.onLayout(changed, left, top, right, bottom); } catch (final RuntimeException e) { Log.e(TAG, "Remote provider threw runtime exception, using error view instead.", e); Loading @@ -358,6 +363,12 @@ public class AppWidgetHostView extends FrameLayout implements AppWidgetHost.AppW } } @Override public void onWindowFocusChanged(boolean hasWindowFocus) { super.onWindowFocusChanged(hasWindowFocus); mInteractionLogger.onWindowFocusChanged(hasWindowFocus); } /** * Remove bad view and replace with error message view */ Loading Loading @@ -1022,6 +1033,7 @@ public class AppWidgetHostView extends FrameLayout implements AppWidgetHost.AppW protected void dispatchDraw(@NonNull Canvas canvas) { try { super.dispatchDraw(canvas); mInteractionLogger.onDraw(); } catch (Exception e) { // Catch draw exceptions that may be caused by RemoteViews Log.e(TAG, "Drawing view failed: " + e); Loading @@ -1036,6 +1048,8 @@ public class AppWidgetHostView extends FrameLayout implements AppWidgetHost.AppW public class InteractionLogger implements RemoteViews.InteractionHandler { // Max number of clicked and scrolled IDs stored per impression. public static final int MAX_NUM_ITEMS = 10; // Determines the minimum time between calls to updateVisibility(). private static final long UPDATE_VISIBILITY_DELAY_MS = 1000L; // Clicked views @NonNull private final Set<Integer> mClickedIds = new ArraySet<>(MAX_NUM_ITEMS); Loading @@ -1044,6 +1058,15 @@ public class AppWidgetHostView extends FrameLayout implements AppWidgetHost.AppW private final Set<Integer> mScrolledIds = new ArraySet<>(MAX_NUM_ITEMS); @Nullable private RemoteViews.InteractionHandler mInteractionHandler = null; // Last position this widget was laid out in @Nullable private Rect mPosition = null; // Total duration for the impression private long mDurationMs = 0L; // Last time the widget became visible in SystemClock.uptimeMillis() private long mVisibilityChangeMs = 0L; private boolean mIsVisible = false; private boolean mUpdateVisibilityScheduled = false; InteractionLogger() { } Loading @@ -1064,6 +1087,17 @@ public class AppWidgetHostView extends FrameLayout implements AppWidgetHost.AppW return mScrolledIds; } @VisibleForTesting public long getDurationMs() { return mDurationMs; } @VisibleForTesting @Nullable public Rect getPosition() { return mPosition; } @Override public boolean onInteraction(View view, PendingIntent pendingIntent, RemoteViews.RemoteResponse response) { Loading Loading @@ -1098,12 +1132,102 @@ public class AppWidgetHostView extends FrameLayout implements AppWidgetHost.AppW @FlaggedApi(FLAG_ENGAGEMENT_METRICS) private int getMetricsId(@NonNull View view) { int viewId = view.getId(); Object metricsTag = view.getTag(com.android.internal.R.id.remoteViewsMetricsId); if (metricsTag instanceof Integer tag) { viewId = tag; return tag; } else { return view.getId(); } } /** * Invoked when the root view is resized or moved. */ private void onPositionChanged() { if (!engagementMetrics()) return; mPosition = new Rect(); if (getGlobalVisibleRect(mPosition)) { applyScrollOffset(); } } /** * Finds the first parent with a scrollX or scrollY offset and applies it to the current * position Rect. This corresponds to the current "page" of this widget on its workspace. */ private void applyScrollOffset() { if (mPosition == null) return; int dx = 0; int dy = 0; for (ViewParent parent = getParent(); parent != null; parent = parent.getParent()) { if (parent instanceof View view && (view.getScrollX() != 0 || view.getScrollY() != 0)) { dx = view.getScrollX(); dy = view.getScrollY(); break; } } mPosition.offset(dx, dy); } return viewId; private void onDraw() { if (!engagementMetrics()) return; if (getParent() instanceof View view && view.isDirty()) { scheduleUpdateVisibility(); } } private void onWindowFocusChanged(boolean hasWindowFocus) { if (!engagementMetrics()) return; updateVisibility(hasWindowFocus); } /** * Schedule a delayed call to updateVisibility. Will skip if a call is already scheduled. */ private void scheduleUpdateVisibility() { if (mUpdateVisibilityScheduled) { return; } postDelayed(() -> updateVisibility(hasWindowFocus()), UPDATE_VISIBILITY_DELAY_MS); mUpdateVisibilityScheduled = true; } /** * Check if this view is currently visible, and update the duration if an impression has * finished. */ private void updateVisibility(boolean hasWindowFocus) { boolean wasVisible = mIsVisible; boolean isVisible = hasWindowFocus && testVisibility(AppWidgetHostView.this); if (isVisible) { // Test parent visibility. for (ViewParent parent = getParent(); parent != null && isVisible; parent = parent.getParent()) { if (parent instanceof View view) { isVisible = testVisibility(view); } else { break; } } } if (!wasVisible && isVisible) { // View has become visible, start the tracker. mVisibilityChangeMs = SystemClock.uptimeMillis(); } else if (wasVisible && !isVisible) { // View is no longer visible, add duration. mDurationMs += SystemClock.uptimeMillis() - mVisibilityChangeMs; } mIsVisible = isVisible; mUpdateVisibilityScheduled = false; } private boolean testVisibility(View view) { return view.isAggregatedVisible() && view.getGlobalVisibleRect(new Rect()) && view.getAlpha() != 0; } } } Loading
core/tests/coretests/src/android/appwidget/AppWidgetEventsTest.kt +44 −0 Original line number Diff line number Diff line Loading @@ -16,6 +16,8 @@ package android.appwidget import android.app.Activity import android.app.EmptyActivity import android.app.PendingIntent import android.appwidget.AppWidgetHostView.InteractionLogger.MAX_NUM_ITEMS import android.content.Intent Loading @@ -23,10 +25,12 @@ import android.graphics.Rect import android.view.View import android.widget.ListView import android.widget.RemoteViews import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.android.frameworks.coretests.R import com.google.common.truth.Truth.assertThat import java.util.concurrent.CountDownLatch import org.junit.Test import org.junit.runner.RunWith Loading Loading @@ -186,4 +190,44 @@ class AppWidgetEventsTest { assertThat(hostView.interactionLogger.scrolledIds) .containsExactlyElementsIn(0..itemCount.minus(2)) } @Test fun interactionLogger_impression() { val remoteViews = RemoteViews(context.packageName, R.layout.remote_views_test) hostView.updateAppWidget(remoteViews) assertThat(hostView.interactionLogger.durationMs).isEqualTo(0) ActivityScenario<Activity>.launch(EmptyActivity::class.java).use { scenario -> scenario.onActivity { activity -> activity.setContentView(hostView) hostView.layout(0, 0, 500, 500) hostView.dispatchWindowFocusChanged(true) } Thread.sleep(2000L) hostView.dispatchWindowFocusChanged(false) assertThat(hostView.interactionLogger.durationMs).isGreaterThan(2000L) } } @Test fun interactionLogger_position() { val remoteViews = RemoteViews(context.packageName, R.layout.remote_views_test) hostView.updateAppWidget(remoteViews) assertThat(hostView.interactionLogger.position).isNull() ActivityScenario<Activity>.launch(EmptyActivity::class.java).use { scenario -> val latch = CountDownLatch(1) scenario.onActivity { activity -> activity.setContentView(hostView) hostView.layout(0, 0, 500, 500) hostView.post { val rect = Rect() assertThat(hostView.getGlobalVisibleRect(rect)).isTrue() assertThat(hostView.interactionLogger.position).isEqualTo(rect) latch.countDown() } } latch.await() } } }