summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Willie Koomson <wvk@google.com> 2025-03-24 11:18:37 -0700
committer Android (Google) Code Review <android-gerrit@google.com> 2025-03-24 11:18:37 -0700
commitec460d2bbd57ec3b4eb5e968eecc4b5f6c6bb387 (patch)
tree4a5cc01b782a0cb9f29374b67988fbd8dc5d496c
parent4a76e99c28ad5235b8cf09272c35b3d2001938f5 (diff)
parent86af446266ffee8a231f9f1bc25f66b64b94d9dc (diff)
Merge "Log impression and resize events" into main
-rw-r--r--core/java/android/appwidget/AppWidgetHostView.java130
-rw-r--r--core/tests/coretests/src/android/appwidget/AppWidgetEventsTest.kt44
2 files changed, 171 insertions, 3 deletions
diff --git a/core/java/android/appwidget/AppWidgetHostView.java b/core/java/android/appwidget/AppWidgetHostView.java
index 33326347fda0..a73b1e873338 100644
--- a/core/java/android/appwidget/AppWidgetHostView.java
+++ b/core/java/android/appwidget/AppWidgetHostView.java
@@ -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;
@@ -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;
@@ -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);
@@ -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
*/
@@ -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);
@@ -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);
@@ -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() {
}
@@ -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) {
@@ -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();
}
- return viewId;
+ }
+
+ /**
+ * 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);
+ }
+
+ 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;
}
}
}
diff --git a/core/tests/coretests/src/android/appwidget/AppWidgetEventsTest.kt b/core/tests/coretests/src/android/appwidget/AppWidgetEventsTest.kt
index 0135378ba681..91a060e6e45f 100644
--- a/core/tests/coretests/src/android/appwidget/AppWidgetEventsTest.kt
+++ b/core/tests/coretests/src/android/appwidget/AppWidgetEventsTest.kt
@@ -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
@@ -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
@@ -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()
+ }
+ }
}