diff options
author | 2025-03-24 11:18:37 -0700 | |
---|---|---|
committer | 2025-03-24 11:18:37 -0700 | |
commit | ec460d2bbd57ec3b4eb5e968eecc4b5f6c6bb387 (patch) | |
tree | 4a5cc01b782a0cb9f29374b67988fbd8dc5d496c | |
parent | 4a76e99c28ad5235b8cf09272c35b3d2001938f5 (diff) | |
parent | 86af446266ffee8a231f9f1bc25f66b64b94d9dc (diff) |
Merge "Log impression and resize events" into main
-rw-r--r-- | core/java/android/appwidget/AppWidgetHostView.java | 130 | ||||
-rw-r--r-- | core/tests/coretests/src/android/appwidget/AppWidgetEventsTest.kt | 44 |
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() + } + } } |