Merge "Add internal TestViewConfiguration" into androidx-main
diff --git a/car/app/app-activity/src/main/java/androidx/car/app/activity/ActivityLifecycleDelegate.java b/car/app/app-activity/src/main/java/androidx/car/app/activity/ActivityLifecycleDelegate.java
index 66bad88..0df1174 100644
--- a/car/app/app-activity/src/main/java/androidx/car/app/activity/ActivityLifecycleDelegate.java
+++ b/car/app/app-activity/src/main/java/androidx/car/app/activity/ActivityLifecycleDelegate.java
@@ -32,7 +32,6 @@
  * IRendererCallback}.
  */
 final class ActivityLifecycleDelegate implements ActivityLifecycleCallbacks {
-    public static final String TAG = "ActivityLifecycleListener";
     @NonNull
     private ServiceDispatcher mServiceDispatcher;
     @Nullable
@@ -88,8 +87,7 @@
 
     @Override
     public void onActivityPreDestroyed(@NonNull Activity activity) {
-        requireNonNull(activity);
-        notifyEvent(Event.ON_DESTROY);
+        // No-op as the view model will call terminate on the service when needed.
     }
 
     @Override
diff --git a/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppActivity.java b/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppActivity.java
index f87ebec..1130a27 100644
--- a/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppActivity.java
+++ b/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppActivity.java
@@ -22,15 +22,10 @@
 
 import android.annotation.SuppressLint;
 import android.content.ComponentName;
-import android.content.Context;
 import android.content.Intent;
-import android.content.ServiceConnection;
 import android.content.pm.ActivityInfo;
 import android.content.pm.PackageManager;
-import android.content.pm.ResolveInfo;
 import android.os.Bundle;
-import android.os.IBinder;
-import android.os.RemoteException;
 import android.util.Log;
 import android.view.View;
 
@@ -49,8 +44,7 @@
 import androidx.car.app.serialization.BundlerException;
 import androidx.car.app.utils.ThreadUtils;
 import androidx.fragment.app.FragmentActivity;
-
-import java.util.List;
+import androidx.lifecycle.ViewModelProvider;
 
 /**
  * The class representing a car app activity.
@@ -96,20 +90,16 @@
 public final class CarAppActivity extends FragmentActivity {
     @VisibleForTesting
     static final String SERVICE_METADATA_KEY = "androidx.car.app.CAR_APP_SERVICE";
-    private static final String TAG = "CarAppActivity";
 
     @SuppressLint({"ActionValue"})
     @VisibleForTesting
     static final String ACTION_RENDER = "android.car.template.host.RendererService";
 
-    @Nullable
-    private ComponentName mServiceComponentName;
     TemplateSurfaceView mSurfaceView;
-    SurfaceHolderListener mSurfaceHolderListener;
-    ActivityLifecycleDelegate mActivityLifecycleDelegate;
-    @Nullable
-    OnBackPressedListener mOnBackPressedListener;
-    ServiceDispatcher mServiceDispatcher;
+    @Nullable SurfaceHolderListener mSurfaceHolderListener;
+    @Nullable ActivityLifecycleDelegate mActivityLifecycleDelegate;
+    @Nullable OnBackPressedListener mOnBackPressedListener;
+    @Nullable CarAppViewModel mViewModel;
     private int mDisplayId;
 
     /**
@@ -121,7 +111,7 @@
 
         Log.e(LogTags.TAG, "Service error: " + errorType, exception);
 
-        unbindService();
+        requireNonNull(mViewModel).unbind();
 
         ThreadUtils.runOnMain(() -> {
             Log.d(LogTags.TAG, "Showing error fragment");
@@ -162,14 +152,16 @@
                     ThreadUtils.runOnMain(
                             () -> {
                                 mSurfaceView.setOnCreateInputConnectionListener(editorInfo ->
-                                        mServiceDispatcher.fetch(null, () ->
+                                        getServiceDispatcher().fetch(null, () ->
                                                 callback.onCreateInputConnection(
                                                         editorInfo)));
 
                                 mOnBackPressedListener = () ->
-                                        mServiceDispatcher.dispatch(callback::onBackPressed);
+                                        getServiceDispatcher().dispatch(callback::onBackPressed);
 
-                                mActivityLifecycleDelegate.registerRendererCallback(callback);
+                                requireNonNull(mActivityLifecycleDelegate)
+                                        .registerRendererCallback(callback);
+                                requireNonNull(mViewModel).setRendererCallback(callback);
                             });
                 }
 
@@ -177,7 +169,8 @@
                 public void setSurfaceListener(@NonNull ISurfaceListener listener) {
                     requireNonNull(listener);
                     ThreadUtils.runOnMain(
-                            () -> mSurfaceHolderListener.setSurfaceListener(listener));
+                            () -> requireNonNull(mSurfaceHolderListener)
+                                    .setSurfaceListener(listener));
                 }
 
                 @Override
@@ -201,87 +194,41 @@
                 }
             };
 
-    /** The service connection for the renderer service. */
-    private ServiceConnection mServiceConnectionImpl =
-            new ServiceConnection() {
-                @Override
-                public void onServiceConnected(
-                        @NonNull ComponentName name, @NonNull IBinder service) {
-                    requireNonNull(name);
-                    requireNonNull(service);
-                    Log.i(LogTags.TAG, String.format("Host service %s is connected",
-                            name.flattenToShortString()));
-                    IRendererService rendererService = IRendererService.Stub.asInterface(service);
-                    if (rendererService == null) {
-                        mErrorHandler.onError(ErrorHandler.ErrorType.HOST_INCOMPATIBLE,
-                                new Exception("Failed to get IRenderService binder from host: "
-                                        + name));
-                        return;
-                    }
-
-                    mServiceDispatcher.setRendererService(rendererService);
-                    verifyServiceVersion(rendererService);
-                    initializeService(rendererService);
-                    updateIntent(getIntent());
-                }
-
-                @Override
-                public void onServiceDisconnected(@NonNull ComponentName name) {
-                    requireNonNull(name);
-
-                    // Connection lost, but it might reconnect.
-                    Log.w(LogTags.TAG, String.format("Host service %s is disconnected",
-                            name.flattenToShortString()));
-                }
-
-                @Override
-                public void onBindingDied(@NonNull ComponentName name) {
-                    requireNonNull(name);
-
-                    // Connection permanently lost
-                    mErrorHandler.onError(ErrorHandler.ErrorType.HOST_CONNECTION_LOST,
-                            new Exception("Host service " + name + " is permanently disconnected"));
-                }
-
-                @Override
-                public void onNullBinding(@NonNull ComponentName name) {
-                    requireNonNull(name);
-
-                    // Host rejected the binding.
-                    mErrorHandler.onError(ErrorHandler.ErrorType.HOST_INCOMPATIBLE,
-                            new Exception("Host service " + name + " rejected the binding "
-                                    + "request"));
-                }
-            };
-
     @SuppressWarnings("deprecation")
     @Override
     protected void onCreate(@Nullable Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
-
-        mServiceDispatcher = new ServiceDispatcher(mErrorHandler);
         setContentView(R.layout.activity_template);
         mSurfaceView = requireViewById(R.id.template_view_surface);
-        mActivityLifecycleDelegate = new ActivityLifecycleDelegate(mServiceDispatcher);
-        mSurfaceHolderListener = new SurfaceHolderListener(mServiceDispatcher,
-                new SurfaceWrapperProvider(mSurfaceView));
 
-        mServiceComponentName = retrieveServiceComponentName();
-        if (mServiceComponentName == null) {
-            Log.e(TAG, "Unspecified service class name");
+        ComponentName serviceComponentName = retrieveServiceComponentName();
+        if (serviceComponentName == null) {
+            Log.e(LogTags.TAG, "Unspecified service class name");
             finish();
             return;
         }
+        mDisplayId = getWindowManager().getDefaultDisplay().getDisplayId();
 
-        registerActivityLifecycleCallbacks(mActivityLifecycleDelegate);
+        CarAppViewModelFactory factory = CarAppViewModelFactory.getInstance(getApplication(),
+                serviceComponentName);
+        mViewModel = new ViewModelProvider(this, factory).get(CarAppViewModel.class);
+        mViewModel.getErrorEvent().observe(this,
+                errorEvent -> mErrorHandler.onError(errorEvent.getErrorType(),
+                        errorEvent.getException()));
+
+        mActivityLifecycleDelegate = new ActivityLifecycleDelegate(getServiceDispatcher());
+        mSurfaceHolderListener = new SurfaceHolderListener(getServiceDispatcher(),
+                new SurfaceWrapperProvider(mSurfaceView));
+
+        registerActivityLifecycleCallbacks(requireNonNull(mActivityLifecycleDelegate));
 
         // Set the z-order to receive the UI events on the surface.
         mSurfaceView.setZOrderOnTop(true);
-        mSurfaceView.setServiceDispatcher(mServiceDispatcher);
+        mSurfaceView.setServiceDispatcher(getServiceDispatcher());
         mSurfaceView.setErrorHandler(mErrorHandler);
         mSurfaceView.getHolder().addCallback(mSurfaceHolderListener);
-        mDisplayId = getWindowManager().getDefaultDisplay().getDisplayId();
-        bindService();
+
+        mViewModel.bind(getIntent(), mCarActivity, mDisplayId);
     }
 
     @Override
@@ -300,13 +247,6 @@
         }
     }
 
-    @Override
-    protected void onDestroy() {
-        if (isFinishing()) {
-            unbindService();
-        }
-        super.onDestroy();
-    }
 
     @Override
     public void onBackPressed() {
@@ -318,21 +258,7 @@
     @Override
     protected void onNewIntent(@NonNull Intent intent) {
         super.onNewIntent(intent);
-        if (!mServiceDispatcher.isBound()) {
-            bindService();
-        } else {
-            updateIntent(intent);
-        }
-    }
-
-    @VisibleForTesting
-    ServiceConnection getServiceConnection() {
-        return mServiceConnectionImpl;
-    }
-
-    @VisibleForTesting
-    void setServiceConnection(ServiceConnection serviceConnection) {
-        mServiceConnectionImpl = serviceConnection;
+        requireNonNull(mViewModel).bind(intent, mCarActivity, mDisplayId);
     }
 
     @VisibleForTesting
@@ -340,6 +266,11 @@
         return mDisplayId;
     }
 
+    @VisibleForTesting
+    ServiceDispatcher getServiceDispatcher() {
+        return requireNonNull(mViewModel).getServiceDispatcher();
+    }
+
     @Nullable
     private ComponentName retrieveServiceComponentName() {
         ActivityInfo activityInfo = null;
@@ -348,7 +279,7 @@
                     getPackageManager()
                             .getActivityInfo(getComponentName(), PackageManager.GET_META_DATA);
         } catch (NameNotFoundException e) {
-            Log.e(TAG, "Unable to find component: " + getComponentName(), e);
+            Log.e(LogTags.TAG, "Unable to find component: " + getComponentName(), e);
         }
 
         if (activityInfo == null) {
@@ -358,7 +289,7 @@
         String serviceName = activityInfo.metaData.getString(SERVICE_METADATA_KEY);
         if (serviceName == null) {
             Log.e(
-                    TAG,
+                    LogTags.TAG,
                     "Unable to find required metadata tag with name "
                             + SERVICE_METADATA_KEY
                             + ". App manifest must include metadata tag with name "
@@ -369,113 +300,4 @@
 
         return new ComponentName(this, serviceName);
     }
-
-    /** Binds to the renderer service. */
-    private void bindService() {
-        Intent rendererIntent = new Intent(ACTION_RENDER);
-        List<ResolveInfo> resolveInfoList =
-                getPackageManager()
-                        .queryIntentServices(rendererIntent, PackageManager.GET_META_DATA);
-        if (resolveInfoList.size() == 1) {
-            rendererIntent.setPackage(resolveInfoList.get(0).serviceInfo.packageName);
-            if (!bindService(
-                    rendererIntent,
-                    mServiceConnectionImpl,
-                    Context.BIND_AUTO_CREATE | Context.BIND_INCLUDE_CAPABILITIES)) {
-                mErrorHandler.onError(ErrorHandler.ErrorType.HOST_INCOMPATIBLE,
-                        new Exception("Cannot bind to the renderer host with intent: "
-                                + rendererIntent));
-            }
-        } else if (resolveInfoList.isEmpty()) {
-            mErrorHandler.onError(ErrorHandler.ErrorType.HOST_NOT_FOUND, new Exception("No "
-                    + "handlers found for intent: " + rendererIntent));
-        } else {
-            StringBuilder logMessage =
-                    new StringBuilder("Multiple hosts found, only one is allowed");
-            for (ResolveInfo resolveInfo : resolveInfoList) {
-                logMessage.append(
-                        String.format("\nFound host %s", resolveInfo.serviceInfo.packageName));
-            }
-            mErrorHandler.onError(ErrorHandler.ErrorType.MULTIPLE_HOSTS,
-                    new Exception(logMessage.toString()));
-        }
-    }
-
-    /**
-     * Verifies that the renderer service supports the current version.
-     *
-     * @param rendererService the renderer service which should verify the version
-     */
-    void verifyServiceVersion(IRendererService rendererService) {
-        // TODO(169604451) Add version support logic
-        boolean isCompatible = true;
-
-        if (!isCompatible) {
-            mErrorHandler.onError(ErrorHandler.ErrorType.HOST_INCOMPATIBLE,
-                    new Exception("Renderer service unsupported"));
-        }
-    }
-
-    /**
-     * Initializes the {@code rendererService} for the current activity with {@code carActivity},
-     * {@code serviceComponentName} and {@code displayId}.
-     *
-     * @param rendererService the renderer service that needs to be initialized
-     */
-    void initializeService(@NonNull IRendererService rendererService) {
-        requireNonNull(rendererService);
-        requireNonNull(mServiceComponentName);
-        ComponentName serviceComponentName = mServiceComponentName;
-        Boolean success = mServiceDispatcher.fetch(false,
-                () -> rendererService.initialize(mCarActivity,
-                        serviceComponentName, mDisplayId));
-        if (success == null || !success) {
-            mErrorHandler.onError(ErrorHandler.ErrorType.HOST_ERROR,
-                    new Exception("Cannot create renderer for" + mServiceComponentName));
-        }
-    }
-
-    /** Closes the connection to the connected {@code rendererService} if any. */
-    void unbindService() {
-        // Remove the renderer callback since there is no need to communicate the state with
-        // the host.
-        mActivityLifecycleDelegate.registerRendererCallback(null);
-        // Stop sending SurfaceView updates
-        mSurfaceView.getHolder().removeCallback(mSurfaceHolderListener);
-        // If host has already disconnected, there is no need for an unbind.
-        IRendererService rendererService = mServiceDispatcher.getRendererService();
-        if (rendererService == null) {
-            return;
-        }
-        try {
-            rendererService.terminate(requireNonNull(mServiceComponentName));
-        } catch (RemoteException e) {
-            // We are already unbinding (maybe because the host has already cut the connection)
-            // Let's not log more errors unnecessarily.
-        }
-
-        Log.i(LogTags.TAG, "Unbinding from " + mServiceComponentName);
-        unbindService(mServiceConnectionImpl);
-        mServiceDispatcher.setRendererService(null);
-    }
-
-    /**
-     * Updates the activity intent for the {@code rendererService}.
-     */
-    void updateIntent(Intent intent) {
-        requireNonNull(mServiceComponentName);
-        IRendererService service = mServiceDispatcher.getRendererService();
-        if (service == null) {
-            mErrorHandler.onError(ErrorHandler.ErrorType.CLIENT_SIDE_ERROR,
-                    new Exception("Service dispatcher is not connected"));
-            return;
-        }
-        ComponentName serviceComponentName = mServiceComponentName;
-        Boolean success = mServiceDispatcher.fetch(false, () ->
-                service.onNewIntent(intent, serviceComponentName, mDisplayId));
-        if (success == null || !success) {
-            mErrorHandler.onError(ErrorHandler.ErrorType.HOST_ERROR, new Exception("Renderer "
-                    + "cannot handle the intent: " + intent));
-        }
-    }
 }
diff --git a/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppViewModel.java b/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppViewModel.java
new file mode 100644
index 0000000..aa48ca9
--- /dev/null
+++ b/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppViewModel.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2021 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 androidx.car.app.activity;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY;
+
+import android.app.Application;
+import android.content.ComponentName;
+import android.content.Intent;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.activity.renderer.ICarAppActivity;
+import androidx.car.app.activity.renderer.IRendererCallback;
+import androidx.lifecycle.AndroidViewModel;
+import androidx.lifecycle.MutableLiveData;
+
+/**
+ * The view model to keep track of the CarAppActivity data.
+ *
+ * This main role of this class is to extent the life of a service connection beyond the regular
+ * lifecycle of an activity. This is done by making sure the unbind happens when the view model
+ * clears instead of when the activity calls onDestroy.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY)
+public class CarAppViewModel extends AndroidViewModel implements ErrorHandler {
+    /** Holds the information about an error event. */
+    public static class ErrorEvent {
+        private final ErrorType mErrorType;
+        private final Throwable mException;
+
+        public ErrorEvent(@NonNull ErrorType errorType, @NonNull Throwable exception) {
+            mErrorType = errorType;
+            mException = exception;
+        }
+
+        /** Returns the type of error. */
+        @NonNull ErrorType getErrorType() {
+            return mErrorType;
+        }
+
+        /** Returns the exception associated with this error event. */
+        @NonNull Throwable getException() {
+            return mException;
+        }
+    }
+
+    private final MutableLiveData<ErrorEvent> mErrorEvent = new MutableLiveData<>();
+    private ServiceConnectionManager mServiceConnectionManager;
+    @Nullable private IRendererCallback mIRendererCallback;
+
+    public CarAppViewModel(@NonNull Application application, @NonNull ComponentName componentName) {
+        super(application);
+
+        mServiceConnectionManager = new ServiceConnectionManager(application, componentName, this);
+    }
+
+    @VisibleForTesting
+    @NonNull ServiceConnectionManager getServiceConnectionManager() {
+        return mServiceConnectionManager;
+    }
+
+    @VisibleForTesting
+    void setServiceConnectionManager(ServiceConnectionManager serviceConnectionManager) {
+        mServiceConnectionManager = serviceConnectionManager;
+    }
+
+    @NonNull ServiceDispatcher getServiceDispatcher() {
+        return mServiceConnectionManager.getServiceDispatcher();
+    }
+
+    /** Updates the rendeer callback. */
+    void setRendererCallback(@NonNull IRendererCallback rendererCallback) {
+        mIRendererCallback = rendererCallback;
+    }
+
+    /**
+     * Binds to the renderer service and initializes the service if not bound already.
+     *
+     * Initializes the renderer service with given properties if already bound to the renderer
+     * service.
+     */
+    void bind(@NonNull Intent intent, @NonNull ICarAppActivity iCarAppActivity,
+            int displayId) {
+        mServiceConnectionManager.bind(intent, iCarAppActivity, displayId);
+    }
+
+    /** Closes the connection to the renderer service if any. */
+    void unbind() {
+        mServiceConnectionManager.unbind();
+    }
+
+    @NonNull
+    MutableLiveData<ErrorEvent> getErrorEvent() {
+        return mErrorEvent;
+    }
+
+    @Override
+    protected void onCleared() {
+        super.onCleared();
+        if (mIRendererCallback != null) {
+            mServiceConnectionManager.getServiceDispatcher()
+                    .dispatch(mIRendererCallback::onDestroyed);
+        }
+        mServiceConnectionManager.unbind();
+    }
+
+    @Override
+    public void onError(@NonNull ErrorHandler.ErrorType errorType, @NonNull Throwable exception) {
+        mErrorEvent.setValue(new ErrorEvent(errorType, exception));
+    }
+}
diff --git a/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppViewModelFactory.java b/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppViewModelFactory.java
new file mode 100644
index 0000000..3c9627d
--- /dev/null
+++ b/car/app/app-activity/src/main/java/androidx/car/app/activity/CarAppViewModelFactory.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2021 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 androidx.car.app.activity;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY;
+
+import android.app.Application;
+import android.content.ComponentName;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.lifecycle.ViewModel;
+import androidx.lifecycle.ViewModelProvider;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A factory to provide a unique {@link CarAppViewModel} for each given {@link ComponentName}.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY)
+class CarAppViewModelFactory implements ViewModelProvider.Factory {
+    private static final Map<ComponentName, CarAppViewModelFactory> sInstances = new HashMap<>();
+
+    Application mApplication;
+    ComponentName mComponentName;
+
+    private CarAppViewModelFactory(@NonNull ComponentName componentName,
+            @NonNull Application application) {
+        mComponentName = componentName;
+        mApplication = application;
+    }
+
+    /**
+     * Retrieve a singleton instance of CarAppViewModelFactory for the given key.
+     *
+     * @return A valid {@link CarAppViewModelFactory}
+     */
+    @NonNull
+    static CarAppViewModelFactory getInstance(Application application,
+            ComponentName componentName) {
+        CarAppViewModelFactory instance = sInstances.get(componentName);
+        if (instance == null) {
+            instance = new CarAppViewModelFactory(componentName, application);
+            sInstances.put(componentName, instance);
+        }
+        return instance;
+    }
+
+    @SuppressWarnings("unchecked")
+    @NonNull
+    @Override
+    public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
+        return (T) new CarAppViewModel(mApplication, mComponentName);
+    }
+}
diff --git a/car/app/app-activity/src/main/java/androidx/car/app/activity/ServiceConnectionManager.java b/car/app/app-activity/src/main/java/androidx/car/app/activity/ServiceConnectionManager.java
new file mode 100644
index 0000000..93f9b5d
--- /dev/null
+++ b/car/app/app-activity/src/main/java/androidx/car/app/activity/ServiceConnectionManager.java
@@ -0,0 +1,267 @@
+/*
+ * Copyright 2021 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 androidx.car.app.activity;
+
+import static androidx.annotation.RestrictTo.Scope.LIBRARY;
+
+import static java.util.Objects.requireNonNull;
+
+import android.annotation.SuppressLint;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.VisibleForTesting;
+import androidx.car.app.activity.renderer.ICarAppActivity;
+import androidx.car.app.activity.renderer.IRendererService;
+
+import java.util.List;
+
+/**
+ * Manages the renderer service connection state.
+ *
+ * This class handles binding and unbinding to the renderer service and make sure the renderer
+ * service gets initialized and terminated properly.
+ *
+ * @hide
+ */
+@RestrictTo(LIBRARY)
+public class ServiceConnectionManager {
+    @SuppressLint({"ActionValue"})
+    @VisibleForTesting
+    static final String ACTION_RENDER = "android.car.template.host.RendererService";
+
+    final ErrorHandler mErrorHandler;
+    private final ComponentName mServiceComponentName;
+    private final Context mContext;
+    private final ServiceDispatcher mServiceDispatcher;
+    private int mDisplayId;
+    @Nullable private Intent mIntent;
+    @Nullable private ICarAppActivity mICarAppActivity;
+
+    @Nullable IRendererService mRendererService;
+
+    public ServiceConnectionManager(@NonNull Context context,
+            @NonNull ComponentName serviceComponentName, @NonNull ErrorHandler errorHandler) {
+        mContext = context;
+        mErrorHandler = errorHandler;
+        mServiceComponentName = serviceComponentName;
+        mServiceDispatcher = new ServiceDispatcher(mErrorHandler, this::isBound);
+    }
+
+    /**
+     * Returns a {@link ServiceDispatcher} that can be used to communicate with the renderer
+     * service.
+     */
+    @NonNull ServiceDispatcher getServiceDispatcher() {
+        return mServiceDispatcher;
+    }
+
+    @VisibleForTesting
+    ComponentName getServiceComponentName() {
+        return mServiceComponentName;
+    }
+
+    @VisibleForTesting
+    ErrorHandler getErrorHandler() {
+        return mErrorHandler;
+    }
+
+    @VisibleForTesting
+    ServiceConnection getServiceConnection() {
+        return mServiceConnectionImpl;
+    }
+
+    @VisibleForTesting
+    void setServiceConnection(ServiceConnection serviceConnection) {
+        mServiceConnectionImpl = serviceConnection;
+    }
+
+    @VisibleForTesting
+    void setRendererService(@Nullable IRendererService rendererService) {
+        mRendererService = rendererService;
+    }
+
+    /** Returns true if the service is currently bound and able to receive messages */
+    boolean isBound() {
+        return mRendererService != null;
+    }
+
+    /** The service connection for the renderer service. */
+    private ServiceConnection mServiceConnectionImpl =
+            new ServiceConnection() {
+                @Override
+                public void onServiceConnected(
+                        @NonNull ComponentName name, @NonNull IBinder service) {
+                    requireNonNull(name);
+                    requireNonNull(service);
+                    Log.i(LogTags.TAG, String.format("Host service %s is connected",
+                            name.flattenToShortString()));
+                    IRendererService rendererService = IRendererService.Stub.asInterface(service);
+                    if (rendererService == null) {
+                        mErrorHandler.onError(ErrorHandler.ErrorType.HOST_INCOMPATIBLE,
+                                new Exception("Failed to get IRenderService binder from host: "
+                                        + name));
+                        return;
+                    }
+
+                    mRendererService = rendererService;
+                    initializeService();
+                }
+
+                @Override
+                public void onServiceDisconnected(@NonNull ComponentName name) {
+                    requireNonNull(name);
+
+                    // Connection lost, but it might reconnect.
+                    Log.w(LogTags.TAG, String.format("Host service %s is disconnected",
+                            name.flattenToShortString()));
+                }
+
+                @Override
+                public void onBindingDied(@NonNull ComponentName name) {
+                    requireNonNull(name);
+
+                    // Connection permanently lost
+                    mErrorHandler.onError(ErrorHandler.ErrorType.HOST_CONNECTION_LOST,
+                            new Exception("Host service " + name + " is permanently disconnected"));
+                }
+
+                @Override
+                public void onNullBinding(@NonNull ComponentName name) {
+                    requireNonNull(name);
+
+                    // Host rejected the binding.
+                    mErrorHandler.onError(ErrorHandler.ErrorType.HOST_INCOMPATIBLE,
+                            new Exception("Host service " + name + " rejected the binding "
+                                    + "request"));
+                }
+            };
+
+    /**
+     * Binds to the renderer service and initializes the service if not bound already.
+     *
+     * Initializes the renderer service with given properties if already bound to the renderer
+     * service.
+     */
+    void bind(@NonNull Intent intent, @NonNull ICarAppActivity iCarAppActivity, int displayId) {
+        mIntent = requireNonNull(intent);
+        mICarAppActivity = requireNonNull(iCarAppActivity);
+        mDisplayId = displayId;
+
+        if (isBound()) {
+            initializeService();
+            return;
+        }
+
+        Intent rendererIntent = new Intent(ACTION_RENDER);
+        List<ResolveInfo> resolveInfoList =
+                mContext.getPackageManager()
+                        .queryIntentServices(rendererIntent, PackageManager.GET_META_DATA);
+        if (resolveInfoList.size() == 1) {
+            rendererIntent.setPackage(resolveInfoList.get(0).serviceInfo.packageName);
+            if (!mContext.bindService(
+                    rendererIntent,
+                    mServiceConnectionImpl,
+                    Context.BIND_AUTO_CREATE | Context.BIND_INCLUDE_CAPABILITIES)) {
+                mErrorHandler.onError(ErrorHandler.ErrorType.HOST_INCOMPATIBLE,
+                        new Exception("Cannot bind to the renderer host with intent: "
+                                + rendererIntent));
+            }
+        } else if (resolveInfoList.isEmpty()) {
+            mErrorHandler.onError(ErrorHandler.ErrorType.HOST_NOT_FOUND, new Exception("No "
+                    + "handlers found for intent: " + rendererIntent));
+        } else {
+            StringBuilder logMessage =
+                    new StringBuilder("Multiple hosts found, only one is allowed");
+            for (ResolveInfo resolveInfo : resolveInfoList) {
+                logMessage.append(
+                        String.format("\nFound host %s", resolveInfo.serviceInfo.packageName));
+            }
+            mErrorHandler.onError(ErrorHandler.ErrorType.MULTIPLE_HOSTS,
+                    new Exception(logMessage.toString()));
+        }
+    }
+
+    /** Closes the connection to the connected {@code rendererService} if any. */
+    void unbind() {
+        if (mRendererService == null) {
+            return;
+        }
+        try {
+            mRendererService.terminate(requireNonNull(mServiceComponentName));
+        } catch (RemoteException e) {
+            // We are already unbinding (maybe because the host has already cut the connection)
+            // Let's not log more errors unnecessarily.
+        }
+
+        Log.i(LogTags.TAG, "Unbinding from " + mServiceComponentName);
+        mContext.unbindService(mServiceConnectionImpl);
+        mRendererService = null;
+    }
+
+    /**
+     * Initializes the {@code rendererService} for the current {@code carIAppActivity},
+     * {@code serviceComponentName} and {@code displayId}.
+     */
+    void initializeService() {
+        ICarAppActivity carAppActivity = requireNonNull(mICarAppActivity);
+        IRendererService rendererService = requireNonNull(mRendererService);
+        ComponentName serviceComponentName = requireNonNull(mServiceComponentName);
+
+        Boolean success = mServiceDispatcher.fetch(false,
+                () -> rendererService.initialize(carAppActivity,
+                        serviceComponentName, mDisplayId));
+        if (success == null || !success) {
+            mErrorHandler.onError(ErrorHandler.ErrorType.HOST_ERROR,
+                    new Exception("Cannot create renderer for" + serviceComponentName));
+            return;
+        }
+        updateIntent();
+    }
+
+    /**
+     * Updates the activity intent for the connected {@code rendererService}.
+     */
+    private void updateIntent() {
+        ComponentName serviceComponentName = requireNonNull(mServiceComponentName);
+        Intent intent = requireNonNull(mIntent);
+
+        IRendererService service = mRendererService;
+        if (service == null) {
+            mErrorHandler.onError(ErrorHandler.ErrorType.CLIENT_SIDE_ERROR,
+                    new Exception("Service dispatcher is not connected"));
+            return;
+        }
+
+        Boolean success = mServiceDispatcher.fetch(false, () ->
+                service.onNewIntent(intent, serviceComponentName, mDisplayId));
+        if (success == null || !success) {
+            mErrorHandler.onError(ErrorHandler.ErrorType.HOST_ERROR, new Exception("Renderer "
+                    + "cannot handle the intent: " + intent));
+        }
+    }
+}
diff --git a/car/app/app-activity/src/main/java/androidx/car/app/activity/ServiceDispatcher.java b/car/app/app-activity/src/main/java/androidx/car/app/activity/ServiceDispatcher.java
index a77a3e5..83c575f 100644
--- a/car/app/app-activity/src/main/java/androidx/car/app/activity/ServiceDispatcher.java
+++ b/car/app/app-activity/src/main/java/androidx/car/app/activity/ServiceDispatcher.java
@@ -23,6 +23,7 @@
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.RestrictTo;
+import androidx.annotation.VisibleForTesting;
 import androidx.car.app.activity.renderer.IRendererService;
 import androidx.car.app.serialization.BundlerException;
 
@@ -33,9 +34,15 @@
  */
 @RestrictTo(LIBRARY)
 public class ServiceDispatcher {
+
+    /** An interface for monitoring the binding state of a service connection. */
+    public interface OnBindingListener {
+        /** Returns true if the service connection is bound. */
+        boolean isBound();
+    }
+
     private final ErrorHandler mErrorHandler;
-    @Nullable
-    private IRendererService mRendererService;
+    private OnBindingListener mOnBindingListener;
 
     /** A one way call to the service */
     public interface OneWayCall {
@@ -56,28 +63,15 @@
         T invoke() throws RemoteException, BundlerException;
     }
 
-    public ServiceDispatcher(@NonNull ErrorHandler errorHandler) {
+    public ServiceDispatcher(@NonNull ErrorHandler errorHandler,
+            @NonNull OnBindingListener onBindingListener) {
         mErrorHandler = errorHandler;
+        mOnBindingListener = onBindingListener;
     }
 
-    /**
-     * Updates the bound service reference
-     *
-     * @param rendererService bound service or {@code null} if the service is not bound.
-     */
-    public void setRendererService(@Nullable IRendererService rendererService) {
-        mRendererService = rendererService;
-    }
-
-    /** Returns the bound service, or null if the service is not bound */
-    @Nullable
-    public IRendererService getRendererService() {
-        return mRendererService;
-    }
-
-    /** Returns true if the service is currently bound and able to receive messages */
-    public boolean isBound() {
-        return mRendererService != null;
+    @VisibleForTesting
+    public void setOnBindingListener(@NonNull OnBindingListener onBindingListener) {
+        mOnBindingListener = onBindingListener;
     }
 
     /** Dispatches the given {@link OneWayCall}. This is a non-blocking call. */
@@ -99,7 +93,7 @@
     // TODO(b/184697399): Remove two-way calls as these are blocking.
     @Nullable
     public <T> T fetch(@Nullable T fallbackValue, @NonNull ReturnCall<T> call) {
-        if (mRendererService == null) {
+        if (!mOnBindingListener.isBound()) {
             // Avoid dispatching messages if we are not bound to the service
             return fallbackValue;
         }
diff --git a/car/app/app-activity/src/main/res/layout/activity_template.xml b/car/app/app-activity/src/main/res/layout/activity_template.xml
index 09c432f..7054403 100644
--- a/car/app/app-activity/src/main/res/layout/activity_template.xml
+++ b/car/app/app-activity/src/main/res/layout/activity_template.xml
@@ -1,6 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
 <merge xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
     android:id="@+id/fragment_container">
 
     <androidx.car.app.activity.renderer.surface.TemplateSurfaceView
@@ -11,6 +10,7 @@
         android:focusableInTouchMode="true" />
 
     <LinearLayout
+        android:background="#000000"
         android:id="@+id/error_message_container"
         android:layout_width="match_parent"
         android:layout_height="match_parent"
diff --git a/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppActivityTest.java b/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppActivityTest.java
index 8cb2e32..7ffef3b 100644
--- a/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppActivityTest.java
+++ b/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppActivityTest.java
@@ -239,8 +239,11 @@
                 CarAppActivity.class)) {
             scenario.onActivity(activity -> {
                 try {
-                    ServiceConnection serviceConnection = spy(activity.getServiceConnection());
-                    activity.setServiceConnection(serviceConnection);
+                    ServiceConnectionManager serviceConnectionManager =
+                            activity.mViewModel.getServiceConnectionManager();
+                    ServiceConnection serviceConnection =
+                            spy(serviceConnectionManager.getServiceConnection());
+                    serviceConnectionManager.setServiceConnection(serviceConnection);
 
                     // Destroy activity to force unbind.
                     scenario.moveToState(Lifecycle.State.DESTROYED);
@@ -251,7 +254,7 @@
                     // Verify service connection is closed.
                     verify(serviceConnection, times(1)).onServiceDisconnected(
                             mRendererComponent);
-                    assertThat(activity.mServiceDispatcher.isBound()).isFalse();
+                    assertThat(serviceConnectionManager.isBound()).isFalse();
                 } catch (RemoteException e) {
                     fail(Log.getStackTraceString(e));
                 }
diff --git a/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppViewModelFactoryTest.java b/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppViewModelFactoryTest.java
new file mode 100644
index 0000000..7650a2d
--- /dev/null
+++ b/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppViewModelFactoryTest.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2021 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 androidx.car.app.activity;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Application;
+import android.content.ComponentName;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Tests for {@link CarAppViewModelFactory} */
+@RunWith(RobolectricTestRunner.class)
+@DoNotInstrument
+public class CarAppViewModelFactoryTest {
+    private static final ComponentName TEST_COMPONENT_NAME_1 = new ComponentName(
+            ApplicationProvider.getApplicationContext(), "Class1");
+    private static final ComponentName TEST_COMPONENT_NAME_2 = new ComponentName(
+            ApplicationProvider.getApplicationContext(), "Class2");
+
+    private final Application mApplication = ApplicationProvider.getApplicationContext();
+
+    @Test
+    public void getInstance_sameKey_returnsSame() {
+        CarAppViewModelFactory factory1 = CarAppViewModelFactory.getInstance(mApplication,
+                TEST_COMPONENT_NAME_1);
+
+        CarAppViewModelFactory factory2 = CarAppViewModelFactory.getInstance(mApplication,
+                TEST_COMPONENT_NAME_1);
+
+        assertThat(factory1).isEqualTo(factory2);
+    }
+
+    @Test
+    public void getInstance_differentKeys_returnsDifferent() {
+        CarAppViewModelFactory factory1 = CarAppViewModelFactory.getInstance(mApplication,
+                TEST_COMPONENT_NAME_1);
+
+        CarAppViewModelFactory factory2 = CarAppViewModelFactory.getInstance(mApplication,
+                TEST_COMPONENT_NAME_2);
+
+        assertThat(factory1).isNotEqualTo(factory2);
+    }
+
+    @Test
+    public void create_correctComponentName() {
+        CarAppViewModelFactory factory = CarAppViewModelFactory.getInstance(mApplication,
+                TEST_COMPONENT_NAME_1);
+
+        CarAppViewModel viewModel = factory.create(CarAppViewModel.class);
+
+        assertThat(viewModel).isNotNull();
+        assertThat(viewModel.getServiceConnectionManager().getServiceComponentName())
+                .isEqualTo(TEST_COMPONENT_NAME_1);
+    }
+}
diff --git a/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppViewModelTest.java b/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppViewModelTest.java
new file mode 100644
index 0000000..9eda009
--- /dev/null
+++ b/car/app/app-activity/src/test/java/androidx/car/app/activity/CarAppViewModelTest.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2021 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 androidx.car.app.activity;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import android.app.Application;
+import android.content.ComponentName;
+import android.content.Intent;
+
+import androidx.car.app.activity.renderer.ICarAppActivity;
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.internal.DoNotInstrument;
+
+/** Tests for {@link CarAppViewModel} */
+@RunWith(RobolectricTestRunner.class)
+@DoNotInstrument
+public class CarAppViewModelTest {
+    private static final ComponentName TEST_COMPONENT_NAME = new ComponentName(
+            ApplicationProvider.getApplicationContext(), "Class1");
+    private static final int TEST_DISPLAY_ID = 123;
+    private static final Intent TEST_INTENT = new Intent("TestAction");
+
+    private final Application mApplication = ApplicationProvider.getApplicationContext();
+    private CarAppViewModel mCarAppViewModel;
+    private ICarAppActivity mICarAppActivity;
+
+    @Before
+    public void setUp() {
+        mCarAppViewModel = new CarAppViewModel(mApplication, TEST_COMPONENT_NAME);
+        mICarAppActivity = mock(ICarAppActivity.class);
+    }
+
+    @Test
+    public void testSetup() {
+        assertThat(mCarAppViewModel.getServiceConnectionManager()).isNotNull();
+        assertThat(mCarAppViewModel.getServiceConnectionManager().getServiceComponentName())
+                .isEqualTo(TEST_COMPONENT_NAME);
+        assertThat(mCarAppViewModel.getServiceDispatcher()).isNotNull();
+    }
+
+    @Test
+    public void testBind_serviceConnectionManager_invoke() {
+        ServiceConnectionManager serviceConnectionManager = mock(ServiceConnectionManager.class);
+        mCarAppViewModel.setServiceConnectionManager(serviceConnectionManager);
+        mCarAppViewModel.bind(TEST_INTENT, mICarAppActivity, TEST_DISPLAY_ID);
+
+        verify(serviceConnectionManager).bind(TEST_INTENT, mICarAppActivity, TEST_DISPLAY_ID);
+    }
+
+    @Test
+    public void testUnbind_serviceConnectionManager_invoke() {
+        ServiceConnectionManager serviceConnectionManager = mock(ServiceConnectionManager.class);
+        mCarAppViewModel.setServiceConnectionManager(serviceConnectionManager);
+        mCarAppViewModel.unbind();
+
+        verify(serviceConnectionManager).unbind();
+    }
+
+    @Test
+    public void testErrorHandler_capturesErrorEvent() {
+        ErrorHandler.ErrorType errorType = ErrorHandler.ErrorType.HOST_ERROR;
+        Throwable exception = new IllegalStateException();
+
+        ErrorHandler errorHandler =
+                mCarAppViewModel.getServiceConnectionManager().getErrorHandler();
+        errorHandler.onError(errorType, exception);
+
+        CarAppViewModel.ErrorEvent errorEvent = mCarAppViewModel.getErrorEvent().getValue();
+        assertThat(errorEvent).isNotNull();
+        assertThat(errorEvent.getErrorType()).isEqualTo(errorType);
+        assertThat(errorEvent.getException()).isEqualTo(exception);
+    }
+}
diff --git a/car/app/app-activity/src/test/java/androidx/car/app/activity/ServiceConnectionManagerTest.java b/car/app/app-activity/src/test/java/androidx/car/app/activity/ServiceConnectionManagerTest.java
new file mode 100644
index 0000000..82bc8c1
--- /dev/null
+++ b/car/app/app-activity/src/test/java/androidx/car/app/activity/ServiceConnectionManagerTest.java
@@ -0,0 +1,289 @@
+/*
+ * Copyright 2021 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 androidx.car.app.activity;
+
+import static android.os.Looper.getMainLooper;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.os.RemoteException;
+import android.util.Log;
+
+import androidx.car.app.activity.renderer.ICarAppActivity;
+import androidx.car.app.activity.renderer.IRendererService;
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.internal.DoNotInstrument;
+import org.robolectric.shadows.ShadowApplication;
+import org.robolectric.shadows.ShadowPackageManager;
+
+/** Tests for {@link ServiceConnectionManager}. */
+@RunWith(RobolectricTestRunner.class)
+@DoNotInstrument
+public class ServiceConnectionManagerTest {
+
+    private static final int TEST_DISPLAY_ID = 123;
+    private static final Intent TEST_INTENT = new Intent("Test");
+
+    private final ComponentName mRendererComponent = new ComponentName(
+            ApplicationProvider.getApplicationContext(), getClass().getName());
+
+    private final String mFakeCarAppServiceClass = "com.fake.FakeCarAppService";
+    private final ComponentName mFakeCarAppServiceComponent = new ComponentName(
+            ApplicationProvider.getApplicationContext(), mFakeCarAppServiceClass);
+    private final IRendererService mRenderService = mock(IRendererService.class);
+    private final RenderServiceDelegate mRenderServiceDelegate =
+            new RenderServiceDelegate(mRenderService);
+
+    private final ErrorHandler mErrorHandler = mock(ErrorHandler.class);
+    private final ServiceConnectionManager mServiceConnectionManager =
+            new ServiceConnectionManager(ApplicationProvider.getApplicationContext(),
+                    mFakeCarAppServiceComponent, mErrorHandler);
+
+    private void setupCarAppActivityForTesting() {
+        try {
+            Application app = ApplicationProvider.getApplicationContext();
+
+            PackageManager packageManager = app.getPackageManager();
+            ShadowPackageManager spm = shadowOf(packageManager);
+
+            // Register fake renderer service which will be simulated by {@code mRenderService}.
+            spm.addServiceIfNotPresent(mRendererComponent);
+            spm.addIntentFilterForService(mRendererComponent,
+                    new IntentFilter(CarAppActivity.ACTION_RENDER));
+
+            when(mRenderService.initialize(any(ICarAppActivity.class),
+                    any(ComponentName.class),
+                    anyInt())).thenReturn(true);
+            when(mRenderService.onNewIntent(any(Intent.class), any(ComponentName.class),
+                    anyInt())).thenReturn(true);
+
+            ShadowApplication sa = shadowOf(app);
+            sa.setComponentNameAndServiceForBindService(mRendererComponent, mRenderServiceDelegate);
+        } catch (Exception e) {
+            fail(Log.getStackTraceString(e));
+        }
+    }
+
+    @Test
+    public void testIsBound_serviceNotNull_returnsTrue() {
+        mServiceConnectionManager.setRendererService(mock(IRendererService.class));
+        assertThat(mServiceConnectionManager.isBound()).isTrue();
+    }
+
+    @Test
+    public void testIsBound_serviceIsNull_returnsFalse() {
+        mServiceConnectionManager.setRendererService(null);
+        assertThat(mServiceConnectionManager.isBound()).isFalse();
+    }
+
+    @Test
+    public void testBind_unbound_bindsToRenderer() {
+        setupCarAppActivityForTesting();
+        ICarAppActivity iCarAppActivity = mock(ICarAppActivity.class);
+
+        mServiceConnectionManager.bind(TEST_INTENT, iCarAppActivity, TEST_DISPLAY_ID);
+        shadowOf(getMainLooper()).idle();
+        try {
+            verify(mErrorHandler, never()).onError(any(), any());
+            verify(mRenderService).initialize(iCarAppActivity, mFakeCarAppServiceComponent,
+                    TEST_DISPLAY_ID);
+            verify(mRenderService).onNewIntent(TEST_INTENT, mFakeCarAppServiceComponent,
+                    TEST_DISPLAY_ID);
+        } catch (RemoteException e) {
+            fail(Log.getStackTraceString(e));
+        }
+        assertThat(mServiceConnectionManager.isBound()).isTrue();
+    }
+
+    @Test
+    public void testBind_bound_doesNotRebound() {
+        setupCarAppActivityForTesting();
+        ICarAppActivity iCarAppActivity = mock(ICarAppActivity.class);
+
+        IRendererService renderService = createMockRendererService();
+        mServiceConnectionManager.setRendererService(renderService);
+        mServiceConnectionManager.bind(TEST_INTENT, iCarAppActivity, TEST_DISPLAY_ID);
+        shadowOf(getMainLooper()).idle();
+        try {
+            verify(mErrorHandler, never()).onError(any(), any());
+            verify(mRenderService, never()).initialize(iCarAppActivity, mFakeCarAppServiceComponent,
+                    TEST_DISPLAY_ID);
+            verify(mRenderService, never()).onNewIntent(TEST_INTENT, mFakeCarAppServiceComponent,
+                    TEST_DISPLAY_ID);
+        } catch (RemoteException e) {
+            fail(Log.getStackTraceString(e));
+        }
+        assertThat(mServiceConnectionManager.isBound()).isTrue();
+    }
+
+    @Test
+    public void testBind_bound_initializes() {
+        setupCarAppActivityForTesting();
+        ICarAppActivity iCarAppActivity = mock(ICarAppActivity.class);
+
+        IRendererService renderService = createMockRendererService();
+
+        mServiceConnectionManager.setRendererService(renderService);
+        mServiceConnectionManager.bind(TEST_INTENT, iCarAppActivity, TEST_DISPLAY_ID);
+        shadowOf(getMainLooper()).idle();
+
+        try {
+            verify(mErrorHandler, never()).onError(any(), any());
+            verify(renderService).initialize(iCarAppActivity, mFakeCarAppServiceComponent,
+                    TEST_DISPLAY_ID);
+            verify(renderService).onNewIntent(TEST_INTENT, mFakeCarAppServiceComponent,
+                    TEST_DISPLAY_ID);
+        } catch (RemoteException e) {
+            fail(Log.getStackTraceString(e));
+        }
+        assertThat(mServiceConnectionManager.isBound()).isTrue();
+    }
+
+    @Test
+    public void testBind_bound_noRebound() {
+        setupCarAppActivityForTesting();
+        ICarAppActivity iCarAppActivity = mock(ICarAppActivity.class);
+        IRendererService renderService = createMockRendererService();
+
+        mServiceConnectionManager.setRendererService(renderService);
+        mServiceConnectionManager.bind(TEST_INTENT, iCarAppActivity, TEST_DISPLAY_ID);
+        shadowOf(getMainLooper()).idle();
+        try {
+            verify(mErrorHandler, never()).onError(any(), any());
+            verify(mRenderService, never()).initialize(iCarAppActivity, mFakeCarAppServiceComponent,
+                    TEST_DISPLAY_ID);
+            verify(mRenderService, never()).onNewIntent(TEST_INTENT, mFakeCarAppServiceComponent,
+                    TEST_DISPLAY_ID);
+        } catch (RemoteException e) {
+            fail(Log.getStackTraceString(e));
+        }
+        assertThat(mServiceConnectionManager.isBound()).isTrue();
+    }
+
+    @Test
+    public void testBind_unbound_failure() {
+        setupCarAppActivityForTesting();
+        ICarAppActivity iCarAppActivity = mock(ICarAppActivity.class);
+
+        try {
+            when(mRenderService.initialize(any(ICarAppActivity.class),
+                    any(ComponentName.class),
+                    anyInt())).thenReturn(false);
+        } catch (RemoteException e) {
+            fail(Log.getStackTraceString(e));
+        }
+
+        mServiceConnectionManager.bind(TEST_INTENT, iCarAppActivity, TEST_DISPLAY_ID);
+        shadowOf(getMainLooper()).idle();
+
+        verify(mErrorHandler).onError(eq(ErrorHandler.ErrorType.HOST_ERROR), any());
+    }
+
+    @Test
+    public void testUnBind_bound_terminate() {
+        setupCarAppActivityForTesting();
+
+        IRendererService renderService = mock(IRendererService.class);
+        mServiceConnectionManager.setRendererService(renderService);
+        mServiceConnectionManager.unbind();
+        shadowOf(getMainLooper()).idle();
+
+        try {
+            verify(renderService).terminate(mFakeCarAppServiceComponent);
+        } catch (RemoteException e) {
+            fail(Log.getStackTraceString(e));
+        }
+
+        assertThat(mServiceConnectionManager.isBound()).isFalse();
+    }
+
+    @Test
+    public void testUnBind_unbound_doNothing() {
+        setupCarAppActivityForTesting();
+
+        mServiceConnectionManager.unbind();
+        shadowOf(getMainLooper()).idle();
+
+        try {
+            verify(mRenderService, never()).terminate(mFakeCarAppServiceComponent);
+        } catch (RemoteException e) {
+            fail(Log.getStackTraceString(e));
+        }
+
+        assertThat(mServiceConnectionManager.isBound()).isFalse();
+    }
+
+    // Use delegate to forward events to a mock. Mockito interceptor is not maintained on
+    // top-level IBinder after call to IRenderService.Stub.asInterface() in CarAppActivity.
+    private static class RenderServiceDelegate extends IRendererService.Stub {
+        private final IRendererService mService;
+
+        RenderServiceDelegate(IRendererService service) {
+            mService = service;
+        }
+
+        @Override
+        public boolean initialize(ICarAppActivity carActivity, ComponentName serviceName,
+                int displayId) throws RemoteException {
+            return mService.initialize(carActivity, serviceName, displayId);
+        }
+
+        @Override
+        public boolean onNewIntent(Intent intent, ComponentName serviceName, int displayId)
+                throws RemoteException {
+            return mService.onNewIntent(intent, serviceName, displayId);
+        }
+
+        @Override
+        public void terminate(ComponentName serviceName) throws RemoteException {
+            mService.terminate(serviceName);
+        }
+    }
+
+    private IRendererService createMockRendererService() {
+        IRendererService renderService = mock(IRendererService.class);
+        try {
+            when(renderService.initialize(any(ICarAppActivity.class),
+                    any(ComponentName.class),
+                    anyInt())).thenReturn(true);
+            when(renderService.onNewIntent(any(Intent.class), any(ComponentName.class),
+                    anyInt())).thenReturn(true);
+        } catch (RemoteException e) {
+            fail(Log.getStackTraceString(e));
+        }
+        return renderService;
+    }
+}
diff --git a/car/app/app-activity/src/test/java/androidx/car/app/activity/ServiceDispatcherTest.java b/car/app/app-activity/src/test/java/androidx/car/app/activity/ServiceDispatcherTest.java
index bbb8137..ea22617 100644
--- a/car/app/app-activity/src/test/java/androidx/car/app/activity/ServiceDispatcherTest.java
+++ b/car/app/app-activity/src/test/java/androidx/car/app/activity/ServiceDispatcherTest.java
@@ -48,19 +48,7 @@
     @Before
     public void setup() {
         mErrorHandler = mock(ErrorHandler.class);
-        mServiceDispatcher = new ServiceDispatcher(mErrorHandler);
-    }
-
-    @Test
-    public void isBound_serviceNotNull_returnsTrue() {
-        mServiceDispatcher.setRendererService(mock(IRendererService.class));
-        assertThat(mServiceDispatcher.isBound()).isTrue();
-    }
-
-    @Test
-    public void isBound_serviceIsNull_returnsFalse() {
-        mServiceDispatcher.setRendererService(null);
-        assertThat(mServiceDispatcher.isBound()).isFalse();
+        mServiceDispatcher = new ServiceDispatcher(mErrorHandler, () -> false);
     }
 
     @Test
@@ -73,7 +61,7 @@
         ServiceDispatcher.OneWayCall call = () -> rendererService.onNewIntent(intent,
                 componentName, 0);
 
-        mServiceDispatcher.setRendererService(rendererService);
+        mServiceDispatcher.setOnBindingListener(() -> true);
         mServiceDispatcher.dispatch(call);
 
         verify(rendererService, times(1)).onNewIntent(intent, componentName, 0);
@@ -83,7 +71,7 @@
     public void dispatch_serviceNotBound_notInvoked() throws BundlerException, RemoteException {
         ServiceDispatcher.OneWayCall call = mock(ServiceDispatcher.OneWayCall.class);
 
-        mServiceDispatcher.setRendererService(null);
+        mServiceDispatcher.setOnBindingListener(() -> false);
         mServiceDispatcher.dispatch(call);
 
         verify(call, never()).invoke();
@@ -95,7 +83,7 @@
             throw new RemoteException();
         };
 
-        mServiceDispatcher.setRendererService(mock(IRendererService.class));
+        mServiceDispatcher.setOnBindingListener(() -> true);
         mServiceDispatcher.dispatch(call);
 
         verify(mErrorHandler, times(1))
@@ -104,10 +92,9 @@
 
     @Test
     public void fetch_serviceBound_valueReturned() {
-        IRendererService rendererService = mock(IRendererService.class);
         ServiceDispatcher.ReturnCall<Integer> call = () -> 123;
 
-        mServiceDispatcher.setRendererService(rendererService);
+        mServiceDispatcher.setOnBindingListener(() -> true);
         Integer result = mServiceDispatcher.fetch(234, call);
 
         assertThat(result).isEqualTo(123);
@@ -119,7 +106,7 @@
             throws BundlerException, RemoteException {
         ServiceDispatcher.ReturnCall<Integer> call = mock(ServiceDispatcher.ReturnCall.class);
 
-        mServiceDispatcher.setRendererService(null);
+        mServiceDispatcher.setOnBindingListener(() -> false);
         Integer result = mServiceDispatcher.fetch(234, call);
 
         verify(call, never()).invoke();
@@ -132,7 +119,7 @@
             throw new RemoteException();
         };
 
-        mServiceDispatcher.setRendererService(mock(IRendererService.class));
+        mServiceDispatcher.setOnBindingListener(() -> true);
         Integer result = mServiceDispatcher.fetch(234, call);
 
         verify(mErrorHandler, times(1))
diff --git a/compose/ui/ui-text/src/androidAndroidTest/kotlin/androidx/compose/ui/text/platform/AndroidParagraphTest.kt b/compose/ui/ui-text/src/androidAndroidTest/kotlin/androidx/compose/ui/text/platform/AndroidParagraphTest.kt
index fd62ae0..32d6216 100644
--- a/compose/ui/ui-text/src/androidAndroidTest/kotlin/androidx/compose/ui/text/platform/AndroidParagraphTest.kt
+++ b/compose/ui/ui-text/src/androidAndroidTest/kotlin/androidx/compose/ui/text/platform/AndroidParagraphTest.kt
@@ -9,8 +9,6 @@
 import android.text.style.LocaleSpan
 import android.text.style.RelativeSizeSpan
 import android.text.style.ScaleXSpan
-import android.text.style.StrikethroughSpan
-import android.text.style.UnderlineSpan
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Canvas
 import androidx.compose.ui.graphics.Color
@@ -29,6 +27,7 @@
 import androidx.compose.ui.text.android.style.LetterSpacingSpanPx
 import androidx.compose.ui.text.android.style.ShadowSpan
 import androidx.compose.ui.text.android.style.SkewXSpan
+import androidx.compose.ui.text.android.style.TextDecorationSpan
 import androidx.compose.ui.text.font.FontFamily
 import androidx.compose.ui.text.font.FontStyle
 import androidx.compose.ui.text.font.FontSynthesis
@@ -165,7 +164,13 @@
         )
 
         assertThat(paragraph.charSequence.toString()).isEqualTo(text)
-        assertThat(paragraph.charSequence).hasSpan(StrikethroughSpan::class, 0, text.length)
+        assertThat(paragraph.charSequence).hasSpan(
+            spanClazz = TextDecorationSpan::class,
+            start = 0,
+            end = text.length
+        ) {
+            !it.isUnderlineText && it.isStrikethroughText
+        }
     }
 
     @Test
@@ -180,7 +185,13 @@
         )
 
         assertThat(paragraph.charSequence.toString()).isEqualTo(text)
-        assertThat(paragraph.charSequence).hasSpan(UnderlineSpan::class, 0, text.length)
+        assertThat(paragraph.charSequence).hasSpan(
+            spanClazz = TextDecorationSpan::class,
+            start = 0,
+            end = text.length
+        ) {
+            it.isUnderlineText && !it.isStrikethroughText
+        }
     }
 
     @Test
@@ -195,7 +206,13 @@
         )
 
         assertThat(paragraph.charSequence.toString()).isEqualTo(text)
-        assertThat(paragraph.charSequence).hasSpan(StrikethroughSpan::class, 0, "abc".length)
+        assertThat(paragraph.charSequence).hasSpan(
+            spanClazz = TextDecorationSpan::class,
+            start = 0,
+            end = "abc".length
+        ) {
+            !it.isUnderlineText && it.isStrikethroughText
+        }
     }
 
     @Test
@@ -210,7 +227,13 @@
         )
 
         assertThat(paragraph.charSequence.toString()).isEqualTo(text)
-        assertThat(paragraph.charSequence).hasSpan(UnderlineSpan::class, 0, "abc".length)
+        assertThat(paragraph.charSequence).hasSpan(
+            spanClazz = TextDecorationSpan::class,
+            start = 0,
+            end = "abc".length
+        ) {
+            it.isUnderlineText && !it.isStrikethroughText
+        }
     }
 
     @Test
@@ -227,8 +250,13 @@
         )
 
         assertThat(paragraph.charSequence.toString()).isEqualTo(text)
-        assertThat(paragraph.charSequence).hasSpan(UnderlineSpan::class, 0, "abc".length)
-        assertThat(paragraph.charSequence).hasSpan(StrikethroughSpan::class, 0, "abc".length)
+        assertThat(paragraph.charSequence).hasSpan(
+            spanClazz = TextDecorationSpan::class,
+            start = 0,
+            end = "abc".length
+        ) {
+            it.isUnderlineText && it.isStrikethroughText
+        }
     }
 
     @Test
@@ -551,7 +579,7 @@
     }
 
     @Test
-    fun testAnnotatedString_setTextGeometricTransformWithNull_noSpanSet() {
+    fun testAnnotatedString_setDefaultTextGeometricTransform() {
         val text = "abcde"
         val spanStyle = SpanStyle(textGeometricTransform = TextGeometricTransform())
 
@@ -561,8 +589,16 @@
             width = 100.0f // width is not important
         )
 
-        assertThat(paragraph.charSequence).spans(ScaleXSpan::class).isEmpty()
-        assertThat(paragraph.charSequence).spans(SkewXSpan::class).isEmpty()
+        assertThat(paragraph.charSequence).hasSpan(ScaleXSpan::class, 0, text.length) {
+            it.scaleX == 1.0f
+        }
+        assertThat(paragraph.charSequence).hasSpan(
+            spanClazz = SkewXSpan::class,
+            start = 0,
+            end = text.length
+        ) {
+            it.skewX == 0.0f
+        }
     }
 
     @Test
@@ -584,7 +620,9 @@
         assertThat(paragraph.charSequence).hasSpan(ScaleXSpan::class, 0, text.length) {
             it.scaleX == scaleX
         }
-        assertThat(paragraph.charSequence).spans(SkewXSpan::class).isEmpty()
+        assertThat(paragraph.charSequence).hasSpan(SkewXSpan::class, 0, text.length) {
+            it.skewX == 0.0f
+        }
     }
 
     @Test
@@ -602,7 +640,9 @@
         assertThat(paragraph.charSequence).hasSpan(SkewXSpan::class, 0, text.length) {
             it.skewX == skewX
         }
-        assertThat(paragraph.charSequence).spans(ScaleXSpan::class).isEmpty()
+        assertThat(paragraph.charSequence).hasSpan(ScaleXSpan::class, 0, text.length) {
+            it.scaleX == 1.0f
+        }
     }
 
     @Test
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidAccessibilitySpannableString.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidAccessibilitySpannableString.android.kt
index fb3c45d5..8bc21b2 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidAccessibilitySpannableString.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/AndroidAccessibilitySpannableString.android.kt
@@ -21,8 +21,10 @@
 import android.text.SpannableString
 import android.text.Spanned
 import android.text.style.ScaleXSpan
+import android.text.style.StrikethroughSpan
 import android.text.style.StyleSpan
 import android.text.style.TypefaceSpan
+import android.text.style.UnderlineSpan
 import androidx.annotation.DoNotInline
 import androidx.annotation.RequiresApi
 import androidx.annotation.RestrictTo
@@ -39,8 +41,8 @@
 import androidx.compose.ui.text.platform.extensions.setColor
 import androidx.compose.ui.text.platform.extensions.setFontSize
 import androidx.compose.ui.text.platform.extensions.setLocaleList
-import androidx.compose.ui.text.platform.extensions.setTextDecoration
 import androidx.compose.ui.text.platform.extensions.toSpan
+import androidx.compose.ui.text.style.TextDecoration
 import androidx.compose.ui.unit.Density
 import androidx.compose.ui.util.fastForEach
 
@@ -126,12 +128,29 @@
         }
     }
 
-    setTextDecoration(spanStyle.textDecoration, start, end)
+    if (spanStyle.textDecoration != null) {
+        // This doesn't match how we rendering the styles. When TextDecoration.None is set, it
+        // should remove the underline and lineThrough effect on the given range. Here we didn't
+        // remove any previously applied spans yet.
+        if (TextDecoration.Underline in spanStyle.textDecoration) {
+            setSpan(
+                UnderlineSpan(),
+                start,
+                end,
+                Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+            )
+        }
+        if (TextDecoration.LineThrough in spanStyle.textDecoration) {
+            setSpan(
+                StrikethroughSpan(),
+                start,
+                end,
+                Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
+            )
+        }
+    }
 
-    if (
-        spanStyle.textGeometricTransform != null &&
-        spanStyle.textGeometricTransform.scaleX != 1f
-    ) {
+    if (spanStyle.textGeometricTransform != null) {
         setSpan(
             ScaleXSpan(spanStyle.textGeometricTransform.scaleX),
             start,
diff --git a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/extensions/SpannableExtensions.android.kt b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/extensions/SpannableExtensions.android.kt
index 17f672c..f109647 100644
--- a/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/extensions/SpannableExtensions.android.kt
+++ b/compose/ui/ui-text/src/androidMain/kotlin/androidx/compose/ui/text/platform/extensions/SpannableExtensions.android.kt
@@ -27,8 +27,6 @@
 import android.text.style.MetricAffectingSpan
 import android.text.style.RelativeSizeSpan
 import android.text.style.ScaleXSpan
-import android.text.style.StrikethroughSpan
-import android.text.style.UnderlineSpan
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Shadow
 import androidx.compose.ui.graphics.isSpecified
@@ -44,6 +42,7 @@
 import androidx.compose.ui.text.android.style.LineHeightSpan
 import androidx.compose.ui.text.android.style.ShadowSpan
 import androidx.compose.ui.text.android.style.SkewXSpan
+import androidx.compose.ui.text.android.style.TextDecorationSpan
 import androidx.compose.ui.text.android.style.TypefaceSpan
 import androidx.compose.ui.text.fastFilter
 import androidx.compose.ui.text.font.FontStyle
@@ -393,12 +392,8 @@
     end: Int
 ) {
     textGeometricTransform?.let {
-        if (it.scaleX != 1.0f) {
-            setSpan(ScaleXSpan(it.scaleX), start, end)
-        }
-        if (it.skewX != 0f) {
-            setSpan(SkewXSpan(it.skewX), start, end)
-        }
+        setSpan(ScaleXSpan(it.scaleX), start, end)
+        setSpan(SkewXSpan(it.skewX), start, end)
     }
 }
 
@@ -427,14 +422,14 @@
     }
 }
 
+@OptIn(InternalPlatformTextApi::class)
 internal fun Spannable.setTextDecoration(textDecoration: TextDecoration?, start: Int, end: Int) {
     textDecoration?.let {
-        if (TextDecoration.Underline in it) {
-            setSpan(UnderlineSpan(), start, end)
-        }
-        if (TextDecoration.LineThrough in it) {
-            setSpan(StrikethroughSpan(), start, end)
-        }
+        val textDecorationSpan = TextDecorationSpan(
+            isUnderlineText = TextDecoration.Underline in it,
+            isStrikethroughText = TextDecoration.LineThrough in it
+        )
+        setSpan(textDecorationSpan, start, end)
     }
 }
 
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
index 622151f..d74228e 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/HitPathTrackerTest.kt
@@ -443,10 +443,12 @@
 
     @Test
     fun dispatchChanges_noNodes_nothingChanges() {
-        val (result, _) = hitPathTracker.dispatchChanges(internalPointerEventOf(down(5)))
+        val internalPointerEvent = internalPointerEventOf(down(5))
+
+        hitPathTracker.dispatchChanges(internalPointerEvent)
 
         PointerInputChangeSubject
-            .assertThat(result.changes.values.first())
+            .assertThat(internalPointerEvent.changes.values.first())
             .isStructurallyEqualTo(down(5))
     }
 
@@ -463,10 +465,12 @@
 
         hitPathTracker.addHitPath(PointerId(13), listOf(pif1))
 
-        val (result, _) = hitPathTracker.dispatchChanges(internalPointerEventOf(down(13)))
+        val internalPointerEvent = internalPointerEventOf(down(13))
+
+        hitPathTracker.dispatchChanges(internalPointerEvent)
 
         PointerInputChangeSubject
-            .assertThat(result.changes.values.first())
+            .assertThat(internalPointerEvent.changes.values.first())
             .isStructurallyEqualTo(down(13).apply { consumeDownChange() })
     }
 
@@ -508,7 +512,9 @@
         val expectedChange = actualChange.deepCopy()
         val consumedExpectedChange = actualChange.deepCopy().apply { consumePositionChange() }
 
-        val (result, _) = hitPathTracker.dispatchChanges(internalPointerEventOf(actualChange))
+        val internalPointerEvent = internalPointerEventOf(actualChange)
+
+        hitPathTracker.dispatchChanges(internalPointerEvent)
 
         val log1 = log.getOnPointerEventLog()
             .filter { it.pass == PointerEventPass.Initial || it.pass == PointerEventPass.Main }
@@ -570,7 +576,7 @@
         assertThat(log1[5].pass).isEqualTo(PointerEventPass.Main)
 
         PointerInputChangeSubject
-            .assertThat(result.changes.values.first())
+            .assertThat(internalPointerEvent.changes.values.first())
             .isStructurallyEqualTo(
                 consumedExpectedChange
             )
@@ -628,9 +634,9 @@
         val consumedExpectedEvent1 = expectedEvent1.deepCopy().apply { consumePositionChange() }
         val consumedExpectedEvent2 = expectedEvent2.deepCopy().apply { consumePositionChange() }
 
-        val (result, _) = hitPathTracker.dispatchChanges(
-            internalPointerEventOf(actualEvent1, actualEvent2)
-        )
+        val internalPointerEvent = internalPointerEventOf(actualEvent1, actualEvent2)
+
+        hitPathTracker.dispatchChanges(internalPointerEvent)
 
         val log1 = log.getOnPointerEventLog()
             .filter { it.pass == PointerEventPass.Initial || it.pass == PointerEventPass.Main }
@@ -712,14 +718,14 @@
             )
         assertThat(log2[3].pass).isEqualTo(PointerEventPass.Main)
 
-        assertThat(result.changes).hasSize(2)
+        assertThat(internalPointerEvent.changes).hasSize(2)
         PointerInputChangeSubject
-            .assertThat(result.changes[actualEvent1.id])
+            .assertThat(internalPointerEvent.changes[actualEvent1.id])
             .isStructurallyEqualTo(
                 consumedExpectedEvent1
             )
         PointerInputChangeSubject
-            .assertThat(result.changes[actualEvent2.id])
+            .assertThat(internalPointerEvent.changes[actualEvent2.id])
             .isStructurallyEqualTo(
                 consumedExpectedEvent2
             )
@@ -770,9 +776,9 @@
         val consumedEvent1 = expectedEvent1.deepCopy().apply { consumePositionChange() }
         val consumedEvent2 = expectedEvent2.deepCopy().apply { consumePositionChange() }
 
-        val (result, _) = hitPathTracker.dispatchChanges(
-            internalPointerEventOf(actualEvent1, actualEvent2)
-        )
+        val internalPointerEvent = internalPointerEventOf(actualEvent1, actualEvent2)
+
+        hitPathTracker.dispatchChanges(internalPointerEvent)
 
         val log1 = log.getOnPointerEventLog()
             .filter { it.pass == PointerEventPass.Initial || it.pass == PointerEventPass.Main }
@@ -823,13 +829,13 @@
             )
         assertThat(log1[5].pass).isEqualTo(PointerEventPass.Main)
 
-        assertThat(result.changes).hasSize(2)
+        assertThat(internalPointerEvent.changes).hasSize(2)
         PointerInputChangeSubject
-            .assertThat(result.changes[actualEvent1.id])
+            .assertThat(internalPointerEvent.changes[actualEvent1.id])
             .isStructurallyEqualTo(consumedEvent1)
 
         PointerInputChangeSubject
-            .assertThat(result.changes[actualEvent2.id])
+            .assertThat(internalPointerEvent.changes[actualEvent2.id])
             .isStructurallyEqualTo(consumedEvent2)
     }
 
@@ -866,9 +872,9 @@
         val consumedEvent1 = expectedEvent1.deepCopy().apply { consumePositionChange() }
         val consumedEvent2 = expectedEvent2.deepCopy().apply { consumePositionChange() }
 
-        val (result, _) = hitPathTracker.dispatchChanges(
-            internalPointerEventOf(actualEvent1, actualEvent2)
-        )
+        val internalPointerEvent = internalPointerEventOf(actualEvent1, actualEvent2)
+
+        hitPathTracker.dispatchChanges(internalPointerEvent)
 
         val log1 = log.getOnPointerEventLog()
             .filter { it.pass == PointerEventPass.Initial || it.pass == PointerEventPass.Main }
@@ -912,14 +918,14 @@
             )
         assertThat(log1[3].pass).isEqualTo(PointerEventPass.Main)
 
-        assertThat(result.changes).hasSize(2)
+        assertThat(internalPointerEvent.changes).hasSize(2)
         PointerInputChangeSubject
-            .assertThat(result.changes[actualEvent1.id])
+            .assertThat(internalPointerEvent.changes[actualEvent1.id])
             .isStructurallyEqualTo(
                 consumedEvent1
             )
         PointerInputChangeSubject
-            .assertThat(result.changes[actualEvent2.id])
+            .assertThat(internalPointerEvent.changes[actualEvent2.id])
             .isStructurallyEqualTo(
                 consumedEvent2
             )
@@ -2842,7 +2848,7 @@
 
     @Test
     fun dispatchChanges_noNodes_reportsWasDispatchedToNothing() {
-        val (_, hitSomething) = hitPathTracker.dispatchChanges(internalPointerEventOf(down(0)))
+        val hitSomething = hitPathTracker.dispatchChanges(internalPointerEventOf(down(0)))
         assertThat(hitSomething).isFalse()
     }
 
@@ -2851,7 +2857,7 @@
         val pif = PointerInputFilterMock()
         hitPathTracker.addHitPath(PointerId(13), listOf(pif))
 
-        val (_, hitSomething) = hitPathTracker.dispatchChanges(internalPointerEventOf(down(13)))
+        val hitSomething = hitPathTracker.dispatchChanges(internalPointerEventOf(down(13)))
 
         assertThat(hitSomething).isTrue()
     }
@@ -2861,7 +2867,7 @@
         val pif = PointerInputFilterMock()
         hitPathTracker.addHitPath(PointerId(13), listOf(pif))
 
-        val (_, hitSomething) = hitPathTracker.dispatchChanges(internalPointerEventOf(down(69)))
+        val hitSomething = hitPathTracker.dispatchChanges(internalPointerEventOf(down(69)))
 
         assertThat(hitSomething).isFalse()
     }
@@ -3109,7 +3115,7 @@
         if (actualNode.children.size != expectedNode.children.size) {
             return false
         }
-        for (child in actualNode.children) {
+        actualNode.children.forEach { child ->
             check = check && expectedNode.children.any {
                 areEqual(child, it)
             }
@@ -3137,7 +3143,7 @@
         if (actualNode.children.size != expectedNode.children.size) {
             return false
         }
-        for (child in actualNode.children) {
+        actualNode.children.forEach { child ->
             check = check && expectedNode.children.any {
                 areEqual(child, it)
             }
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapterTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapterTest.kt
index b55dd28..db78ad7 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapterTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapterTest.kt
@@ -1050,25 +1050,32 @@
             arrayOf(PointerCoords(30f, 31f))
         )
 
+        // Test the different events sequentially, since the returned event contains a list that
+        // will be reused by convertToPointerInputEvent for performance, so it shouldn't be held
+        // for longer than needed during the sequential dispatch.
+
         val pointerInputEventDown1 = motionEventAdapter.convertToPointerInputEvent(down1)
-        val pointerInputEventUp1 = motionEventAdapter.convertToPointerInputEvent(up1)
-        val pointerInputEventDown2 = motionEventAdapter.convertToPointerInputEvent(down2)
-        val pointerInputEventUp2 = motionEventAdapter.convertToPointerInputEvent(up2)
-        val pointerInputEventDown3 = motionEventAdapter.convertToPointerInputEvent(down3)
-        val pointerInputEventUp3 = motionEventAdapter.convertToPointerInputEvent(up3)
-
         assertThat(pointerInputEventDown1).isNotNull()
-        assertThat(pointerInputEventUp1).isNotNull()
-        assertThat(pointerInputEventDown2).isNotNull()
-        assertThat(pointerInputEventUp2).isNotNull()
-        assertThat(pointerInputEventDown3).isNotNull()
-        assertThat(pointerInputEventUp3).isNotNull()
-
         assertThat(pointerInputEventDown1!!.pointers[0].id).isEqualTo(PointerId(0))
+
+        val pointerInputEventUp1 = motionEventAdapter.convertToPointerInputEvent(up1)
+        assertThat(pointerInputEventUp1).isNotNull()
         assertThat(pointerInputEventUp1!!.pointers[0].id).isEqualTo(PointerId(0))
+
+        val pointerInputEventDown2 = motionEventAdapter.convertToPointerInputEvent(down2)
+        assertThat(pointerInputEventDown2).isNotNull()
         assertThat(pointerInputEventDown2!!.pointers[0].id).isEqualTo(PointerId(1))
+
+        val pointerInputEventUp2 = motionEventAdapter.convertToPointerInputEvent(up2)
+        assertThat(pointerInputEventUp2).isNotNull()
         assertThat(pointerInputEventUp2!!.pointers[0].id).isEqualTo(PointerId(1))
+
+        val pointerInputEventDown3 = motionEventAdapter.convertToPointerInputEvent(down3)
+        assertThat(pointerInputEventDown3).isNotNull()
         assertThat(pointerInputEventDown3!!.pointers[0].id).isEqualTo(PointerId(2))
+
+        val pointerInputEventUp3 = motionEventAdapter.convertToPointerInputEvent(up3)
+        assertThat(pointerInputEventUp3).isNotNull()
         assertThat(pointerInputEventUp3!!.pointers[0].id).isEqualTo(PointerId(2))
     }
 
@@ -1119,21 +1126,26 @@
             )
         )
 
+        // Test the different events sequentially, since the returned event contains a list that
+        // will be reused by convertToPointerInputEvent for performance, so it shouldn't be held
+        // for longer than needed during the sequential dispatch.
+
         val pointerInputEventDown1 = motionEventAdapter.convertToPointerInputEvent(down1)
-        val pointerInputEventDown2 = motionEventAdapter.convertToPointerInputEvent(down2)
-        val pointerInputEventDown3 = motionEventAdapter.convertToPointerInputEvent(down3)
 
         assertThat(pointerInputEventDown1).isNotNull()
-        assertThat(pointerInputEventDown2).isNotNull()
-        assertThat(pointerInputEventDown3).isNotNull()
-
         assertThat(pointerInputEventDown1!!.pointers).hasSize(1)
         assertThat(pointerInputEventDown1.pointers[0].id).isEqualTo(PointerId(0))
 
+        val pointerInputEventDown2 = motionEventAdapter.convertToPointerInputEvent(down2)
+
+        assertThat(pointerInputEventDown2).isNotNull()
         assertThat(pointerInputEventDown2!!.pointers).hasSize(2)
         assertThat(pointerInputEventDown2.pointers[0].id).isEqualTo(PointerId(0))
         assertThat(pointerInputEventDown2.pointers[1].id).isEqualTo(PointerId(1))
 
+        val pointerInputEventDown3 = motionEventAdapter.convertToPointerInputEvent(down3)
+
+        assertThat(pointerInputEventDown3).isNotNull()
         assertThat(pointerInputEventDown3!!.pointers).hasSize(3)
         assertThat(pointerInputEventDown2.pointers[0].id).isEqualTo(PointerId(0))
         assertThat(pointerInputEventDown2.pointers[1].id).isEqualTo(PointerId(1))
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapter.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapter.android.kt
index 1284beb..7ebf120 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapter.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/input/pointer/MotionEventAdapter.android.kt
@@ -43,6 +43,8 @@
     @VisibleForTesting
     internal val motionEventToComposePointerIdMap: MutableMap<Int, PointerId> = mutableMapOf()
 
+    private val pointers: MutableList<PointerInputEventData> = mutableListOf()
+
     /**
      * Converts a single [MotionEvent] from an Android event stream into a [PointerInputEvent], or
      * null if the [MotionEvent.getActionMasked] is [ACTION_CANCEL].
@@ -76,12 +78,10 @@
             else -> null
         }
 
-        // TODO(shepshapard): Avoid allocating for every event.
-        val pointers: MutableList<PointerInputEventData> = mutableListOf()
+        pointers.clear()
 
         // This converts the MotionEvent into a list of PointerInputEventData, and updates
         // internal record keeping.
-        @Suppress("NAME_SHADOWING")
         for (i in 0 until motionEvent.pointerCount) {
             pointers.add(
                 createPointerInputEventData(
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/HitPathTracker.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/HitPathTracker.kt
index ea55590..8bf9f48 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/HitPathTracker.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/HitPathTracker.kt
@@ -16,6 +16,8 @@
 
 package androidx.compose.ui.input.pointer
 
+import androidx.compose.runtime.collection.MutableVector
+import androidx.compose.runtime.collection.mutableVectorOf
 import androidx.compose.ui.layout.LayoutCoordinates
 import androidx.compose.ui.node.InternalCoreApi
 
@@ -32,23 +34,6 @@
     /*@VisibleForTesting*/
     internal val root: NodeParent = NodeParent()
 
-    private val retainedHitPaths: MutableSet<PointerId> = mutableSetOf()
-
-    internal interface DispatchChangesRetVal {
-        operator fun component1(): InternalPointerEvent
-        operator fun component2(): Boolean
-    }
-
-    private class DispatchChangesRetValImpl : DispatchChangesRetVal {
-        lateinit var internalPointerEvent: InternalPointerEvent
-        var wasDispatchedToSomething: Boolean = false
-        override operator fun component1() = internalPointerEvent
-        override operator fun component2() = wasDispatchedToSomething
-    }
-
-    // See https://youtrack.jetbrains.com/issue/KT-39905.
-    private val dispatchChangesRetVal = DispatchChangesRetValImpl()
-
     /**
      * Associates a [pointerId] to a list of hit [pointerInputFilters] and keeps track of them.
      *
@@ -67,9 +52,11 @@
         eachPin@ for (i in pointerInputFilters.indices) {
             val pointerInputFilter = pointerInputFilters[i]
             if (merging) {
-                val node = parent.children.find { it.pointerInputFilter == pointerInputFilter }
+                val node = parent.children.firstOrNull {
+                    it.pointerInputFilter == pointerInputFilter
+                }
                 if (node != null) {
-                    node.pointerIds.add(pointerId)
+                    if (pointerId !in node.pointerIds) node.pointerIds.add(pointerId)
                     parent = node
                     continue@eachPin
                 } else {
@@ -89,26 +76,17 @@
      * therefore no longer associated with any pointer ids.
      */
     fun removeHitPath(pointerId: PointerId) {
-        removeHitPathInternal(pointerId)
-        removeHitPathInternal(pointerId)
+        root.recursivelyRemovePointerId(pointerId)
     }
 
     /**
      * Dispatches [internalPointerEvent] through the hierarchy.
      *
-     * Returns a [DispatchChangesRetVal] that should not be referenced directly, but instead
-     * should be destrutured immediately.  Each instance of [HitPathTracker] reuses a single
-     * [DispatchChangesRetVal] and mutates it for each return for performance reasons.
-     *
-     * [DispatchChangesRetVal.component1] references the resulting changes after dispatch.
-     * [DispatchChangesRetVal.component2] is true if the dispatch reached at least one
-     * [PointerInputModifier].
-     *
      * @param internalPointerEvent The change to dispatch.
      *
-     * @return The DispatchChangesRetVal that should be destructured immediately.
+     * @return whether this event was dispatched to a [PointerInputFilter]
      */
-    fun dispatchChanges(internalPointerEvent: InternalPointerEvent): DispatchChangesRetVal {
+    fun dispatchChanges(internalPointerEvent: InternalPointerEvent): Boolean {
         var dispatchHit = root.dispatchMainEventPass(
             internalPointerEvent.changes,
             rootCoordinates,
@@ -116,9 +94,7 @@
         )
         dispatchHit = root.dispatchFinalEventPass() || dispatchHit
 
-        dispatchChangesRetVal.wasDispatchedToSomething = dispatchHit
-        dispatchChangesRetVal.internalPointerEvent = internalPointerEvent
-        return dispatchChangesRetVal
+        return dispatchHit
     }
 
     /**
@@ -128,7 +104,6 @@
      * data.
      */
     fun processCancel() {
-        retainedHitPaths.clear()
         root.dispatchCancel()
         root.clear()
     }
@@ -142,13 +117,6 @@
     fun removeDetachedPointerInputFilters() {
         root.removeDetachedPointerInputFilters()
     }
-
-    /**
-     * Actually removes hit paths.
-     */
-    private fun removeHitPathInternal(pointerId: PointerId) {
-        root.removePointerId(pointerId)
-    }
 }
 
 /**
@@ -159,7 +127,7 @@
 /*@VisibleForTesting*/
 @OptIn(InternalCoreApi::class)
 internal open class NodeParent {
-    val children: MutableSet<Node> = mutableSetOf()
+    val children: MutableVector<Node> = mutableVectorOf()
 
     /**
      * Dispatches [changes] down the tree, for the initial and main pass.
@@ -221,54 +189,33 @@
      * Removes all child [Node]s that are no longer attached to the compose tree.
      */
     fun removeDetachedPointerInputFilters() {
-        children.removeAndProcess(
-            removeIf = {
-                !it.pointerInputFilter.isAttached
-            },
-            ifRemoved = {
-                it.dispatchCancel()
-            },
-            ifKept = {
-                it.removeDetachedPointerInputFilters()
+        var index = 0
+        while (index < children.size) {
+            val child = children[index]
+            if (!child.pointerInputFilter.isAttached) {
+                children.removeAt(index)
+                child.dispatchCancel()
+            } else {
+                index++
+                child.removeDetachedPointerInputFilters()
             }
-        )
+        }
     }
 
     /**
      * Removes the tracking of [pointerId] and removes all child [Node]s that are no longer
-     * tracking
-     * any [PointerId]s.
+     * tracking any [PointerId]s.
      */
-    fun removePointerId(pointerId: PointerId) {
-        children.forEach {
-            it.pointerIds.remove(pointerId)
-        }
-        children.removeAll {
-            it.pointerIds.isEmpty()
-        }
-        children.forEach {
-            it.removePointerId(pointerId)
-        }
-    }
-
-    /**
-     * With each item, if calling [removeIf] with it is true, removes the item from [this] and calls
-     * [ifRemoved] with it, otherwise calls [ifKept] with it.
-     */
-    private fun <T> MutableIterable<T>.removeAndProcess(
-        removeIf: (T) -> Boolean,
-        ifRemoved: (T) -> Unit,
-        ifKept: (T) -> Unit
-    ) {
-        with(iterator()) {
-            while (hasNext()) {
-                val next = next()
-                if (removeIf(next)) {
-                    remove()
-                    ifRemoved(next)
-                } else {
-                    ifKept(next)
-                }
+    fun recursivelyRemovePointerId(pointerId: PointerId) {
+        var index = 0
+        while (index < children.size) {
+            val child = children[index]
+            child.pointerIds.remove(pointerId)
+            if (child.pointerIds.isEmpty()) {
+                children.removeAt(index)
+            } else {
+                child.recursivelyRemovePointerId(pointerId)
+                index++
             }
         }
     }
@@ -282,7 +229,13 @@
 @OptIn(InternalCoreApi::class)
 internal class Node(val pointerInputFilter: PointerInputFilter) : NodeParent() {
 
-    val pointerIds: MutableSet<PointerId> = mutableSetOf()
+    // Note: this is essentially a set, and writes should be guarded accordingly. We use a
+    // MutableVector here instead since a set ends up being quite heavy, and calls to
+    // set.contains() show up noticeably (~1%) in traces. Since the maximum size of this vector
+    // is small (due to the limited amount of concurrent PointerIds there _could_ be), iterating
+    // through the small vector in most cases should have a lower performance impact than using a
+    // set.
+    val pointerIds: MutableVector<PointerId> = mutableVectorOf()
 
     /**
      * Cached properties that will be set before the main event pass, and reset after the final
diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessor.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessor.kt
index 54db299..35ccd12 100644
--- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessor.kt
+++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/input/pointer/PointerInputEventProcessor.kt
@@ -34,6 +34,7 @@
 
     private val hitPathTracker = HitPathTracker(root.coordinates)
     private val pointerInputChangeEventProducer = PointerInputChangeEventProducer()
+    private val hitResult: MutableList<PointerInputFilter> = mutableListOf()
 
     /**
      * Receives [PointerInputEvent]s and process them through the tree rooted on [root].
@@ -54,44 +55,41 @@
         val internalPointerEvent =
             pointerInputChangeEventProducer.produce(pointerEvent, positionCalculator)
 
-        // TODO(shepshapard): Create fast forEach for maps?
-
         // Add new hit paths to the tracker due to down events.
-        internalPointerEvent
-            .changes
-            .filter { (_, pointerInputChange) -> pointerInputChange.changedToDownIgnoreConsumed() }
-            .forEach { (_, pointerInputChange) ->
-                val hitResult: MutableList<PointerInputFilter> = mutableListOf()
+        internalPointerEvent.changes.values.forEach { pointerInputChange ->
+            if (pointerInputChange.changedToDownIgnoreConsumed()) {
                 root.hitTest(
                     pointerInputChange.position,
                     hitResult
                 )
                 if (hitResult.isNotEmpty()) {
                     hitPathTracker.addHitPath(pointerInputChange.id, hitResult)
+                    hitResult.clear()
                 }
             }
+        }
 
         // Remove [PointerInputFilter]s that are no longer valid and refresh the offset information
         // for those that are.
         hitPathTracker.removeDetachedPointerInputFilters()
 
         // Dispatch to PointerInputFilters
-        val (resultingChanges, dispatchedToSomething) =
-            hitPathTracker.dispatchChanges(internalPointerEvent)
+        val dispatchedToSomething = hitPathTracker.dispatchChanges(internalPointerEvent)
 
-        // Remove hit paths from the tracker due to up events.
-        internalPointerEvent
-            .changes
-            .filter { (_, pointerInputChange) -> pointerInputChange.changedToUpIgnoreConsumed() }
-            .forEach { (_, pointerInputChange) ->
+        var anyMovementConsumed = false
+
+        // Remove hit paths from the tracker due to up events, and calculate if we have consumed
+        // any movement
+        internalPointerEvent.changes.values.forEach { pointerInputChange ->
+            if (pointerInputChange.changedToUpIgnoreConsumed()) {
                 hitPathTracker.removeHitPath(pointerInputChange.id)
             }
+            if (pointerInputChange.positionChangeConsumed()) {
+                anyMovementConsumed = true
+            }
+        }
 
-        // TODO(shepshapard): Don't allocate on every call.
-        return ProcessResult(
-            dispatchedToSomething,
-            resultingChanges.changes.any { (_, value) -> value.positionChangeConsumed() }
-        )
+        return ProcessResult(dispatchedToSomething, anyMovementConsumed)
     }
 
     /**
@@ -154,8 +152,7 @@
                     previousPointerInputData[it.id] = PointerInputData(
                         it.uptime,
                         it.positionOnScreen,
-                        it.down,
-                        it.type
+                        it.down
                     )
                 } else {
                     previousPointerInputData.remove(it.id)
@@ -167,15 +164,14 @@
     /**
      * Clears all tracked information.
      */
-    internal fun clear() {
+    fun clear() {
         previousPointerInputData.clear()
     }
 
     private class PointerInputData(
         val uptime: Long,
         val positionOnScreen: Offset,
-        val down: Boolean,
-        val type: PointerType
+        val down: Boolean
     )
 }
 
@@ -183,7 +179,6 @@
  * The result of a call to [PointerInputEventProcessor.process].
  */
 // TODO(shepshpard): Not sure if storing these values in a int is most efficient overall.
-@Suppress("EXPERIMENTAL_FEATURE_WARNING")
 internal inline class ProcessResult(private val value: Int) {
     val dispatchedToAPointerInputModifier
         get() = (value and 1) != 0
diff --git a/core/core/src/androidTest/java/androidx/core/view/AccessibilityDelegateCompatTest.java b/core/core/src/androidTest/java/androidx/core/view/AccessibilityDelegateCompatTest.java
index b6767d5..9ecc7281 100644
--- a/core/core/src/androidTest/java/androidx/core/view/AccessibilityDelegateCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/AccessibilityDelegateCompatTest.java
@@ -169,7 +169,7 @@
 
     @Test
     @SdkSuppress(minSdkVersion = 19, maxSdkVersion = 27)
-    @FlakyTest
+    @FlakyTest(bugId = 187190911)
     public void testAccessibilityPaneTitle_isntTrackedAsPaneWithoutTitle() {
         // This test isn't to test the propagation up, just that the event is sent correctly
         ViewCompat.setAccessibilityLiveRegion(mView,
diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/GridWidgetTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/GridWidgetTest.java
index b6bae78..ce23b2f 100644
--- a/leanback/leanback/src/androidTest/java/androidx/leanback/widget/GridWidgetTest.java
+++ b/leanback/leanback/src/androidTest/java/androidx/leanback/widget/GridWidgetTest.java
@@ -4567,7 +4567,7 @@
         assertEquals(0, mGridView.getSelectedPosition());
     }
 
-    @FlakyTest
+    @FlakyTest(bugId = 187191618)
     @Test
     public void testExtraLayoutSpace() throws Throwable {
         Intent intent = new Intent();
diff --git a/text/text/src/main/java/androidx/compose/ui/text/android/style/TextDecorationSpan.kt b/text/text/src/main/java/androidx/compose/ui/text/android/style/TextDecorationSpan.kt
new file mode 100644
index 0000000..afa6802
--- /dev/null
+++ b/text/text/src/main/java/androidx/compose/ui/text/android/style/TextDecorationSpan.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2021 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 androidx.compose.ui.text.android.style
+
+import android.text.TextPaint
+import android.text.style.CharacterStyle
+import androidx.compose.ui.text.android.InternalPlatformTextApi
+
+/**
+ * A span which applies the underline and strike through to the affected text.
+ *
+ * @property isUnderlineText whether to draw the under for the affected text.
+ * @property isStrikethroughText whether to draw strikethrough line for the affected text.
+ * @suppress
+ */
+@InternalPlatformTextApi
+class TextDecorationSpan(
+    val isUnderlineText: Boolean,
+    val isStrikethroughText: Boolean
+) : CharacterStyle() {
+    override fun updateDrawState(textPaint: TextPaint) {
+        textPaint.isUnderlineText = isUnderlineText
+        textPaint.isStrikeThruText = isStrikethroughText
+    }
+}
\ No newline at end of file