DO NOT MERGE: [connectedappssdk] Update bedstead in AOSP. am: 5d5b0c656a

Original change: https://android-review.googlesource.com/c/platform/external/connectedappssdk/+/2317953

Change-Id: Ie6e37c5b1dc626ea8edf0a2f72d5c707e5fa2a8a
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
diff --git a/Android.bp b/Android.bp
index e608e76..0eab8ec 100644
--- a/Android.bp
+++ b/Android.bp
@@ -37,6 +37,7 @@
         "ConnectedAppsSDK_Annotations",
         "guava-android-annotation-stubs",
         "auto_value_annotations",
+        "error_prone_annotations",
         "guava",
         "ConnectedAppsSDK_Annotations",
         "ConnectedAppsSDK_Test_Annotations"
@@ -72,9 +73,10 @@
     static_libs: [
         "ConnectedAppsSDK_Annotations",
         "guava-android-annotation-stubs",
+        "error_prone_annotations",
     ],
     manifest: "sdk/src/main/AndroidManifest.xml",
-    min_sdk_version: "27",
+    min_sdk_version: "28",
 }
 
 android_library {
@@ -88,45 +90,5 @@
         "androidx.test.ext.junit",
     ],
     manifest: "testing/sdk/src/main/AndroidManifest.xml",
-    min_sdk_version: "27",
-}
-
-android_library {
-    name: "ConnectedAppsSDK_SharedTestApp",
-    sdk_version: "test_current",
-    srcs: [
-        "tests/shared/src/main/java/**/*.java"
-    ],
-    manifest: "tests/shared/src/main/AndroidManifest.xml",
-    min_sdk_version: "27",
-    static_libs: [
-        "ConnectedAppsSDK_Annotations",
-        "ConnectedAppsSDK",
-        "guava",
-        "truth-prebuilt"
-    ],
-    plugins: ["ConnectedAppsSDK_Processor"],
-}
-
-// We only run instrumented tests in AOSP
-android_test {
-    name: "ConnectedAppsSDKTest",
-    srcs: [
-        "tests/instrumented/src/main/java/**/*.java"
-    ],
-    test_suites: [
-        "general-tests",
-    ],
-    static_libs: [
-        "ConnectedAppsSDK",
-        "ConnectedAppsSDK_Annotations",
-        "ConnectedAppsSDK_SharedTestApp",
-        "ConnectedAppsSDK_Testing",
-        "androidx.test.ext.junit",
-        "ctstestrunner-axt",
-        "truth-prebuilt",
-        "testng", // for assertThrows
-    ],
-    manifest: "tests/instrumented/src/AndroidManifest.xml",
-    min_sdk_version: "27"
-}
+    min_sdk_version: "28",
+}
\ No newline at end of file
diff --git a/CHANGES b/CHANGES
new file mode 100644
index 0000000..0d82967
--- /dev/null
+++ b/CHANGES
@@ -0,0 +1,13 @@
+# HEAD
+ * 
+# 1.1.2
+ * Fixed ANR caused by locking in connection holders
+ * Fixed race condition in CrossProfileCallbackMultiMerger when using .both() on multiple threads
+ * Allow providers to be in a different package to cross profile types
+ * Support Drawable in cross-profile calls
+ * Fixed NPE caused when no manifest permissions are requested
+ * Fixed dependency issue with gradle builds
+# 1.1
+ * Added connection holders to allow for ongoing callbacks
+# 1.0
+ * First Release
\ No newline at end of file
diff --git a/README.md b/README.md
index 252b9d6..2171193 100644
--- a/README.md
+++ b/README.md
@@ -7,4 +7,4 @@
 permission.
 
 For more information see
-https://developers.google.com/android/work/connected-apps
\ No newline at end of file
+https://developers.google.com/android/work/connected-apps
diff --git a/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/Cacheable.java b/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/Cacheable.java
new file mode 100644
index 0000000..672015b
--- /dev/null
+++ b/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/Cacheable.java
@@ -0,0 +1,19 @@
+package com.google.android.enterprise.connectedapps.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Annotate a cross profile method to indicate the result should be cached.
+ *
+ * <p>Annotated methods must return non void types.
+ *
+ * <p>Annotated methods must also be annotated with the {@link CrossProfile} annotation.
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.CLASS)
+public @interface Cacheable {
+
+}
diff --git a/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/CrossProfile.java b/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/CrossProfile.java
index dd88592..f6a00ba 100644
--- a/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/CrossProfile.java
+++ b/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/CrossProfile.java
@@ -33,15 +33,6 @@
 public @interface CrossProfile {
 
   /**
-   * The name of the Profile class generated for this cross-profile type.
-   *
-   * <p>This argument can only be passed when annotating types, not methods.
-   *
-   * <p>Defaults to this type name prefixed with "Profile".
-   */
-  String profileClassName() default "";
-
-  /**
    * The {@link CustomProfileConnector} used by this type.
    *
    * <p>Setting this option for a cross-profile type ensures the generated code provides a better
@@ -73,11 +64,4 @@
    * <p>This argument can only be passed when annotating types, not methods.
    */
   boolean isStatic() default false;
-
-  /**
-   * The number of milliseconds to wait before timing out asynchronous calls to this method or type.
-   *
-   * <p>Defaults to {@link #DEFAULT_TIMEOUT_MILLIS}.
-   */
-  long timeoutMillis() default -1;
 }
diff --git a/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/CrossUser.java b/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/CrossUser.java
index da95795..ff2c311 100644
--- a/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/CrossUser.java
+++ b/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/CrossUser.java
@@ -33,15 +33,6 @@
 public @interface CrossUser {
 
   /**
-   * The name of the Profile class generated for this cross-profile type.
-   *
-   * <p>This argument can only be passed when annotating types, not methods.
-   *
-   * <p>Defaults to this type name prefixed with "Profile".
-   */
-  String profileClassName() default "";
-
-  /**
    * The {@link CustomProfileConnector} used by this type.
    *
    * <p>Setting this option for a cross-profile type ensures the generated code provides a better
@@ -73,11 +64,4 @@
    * <p>This argument can only be passed when annotating types, not methods.
    */
   boolean isStatic() default false;
-
-  /**
-   * The number of milliseconds to wait before timing out asynchronous calls to this method or type.
-   *
-   * <p>Defaults to {@link #DEFAULT_TIMEOUT_MILLIS}.
-   */
-  long timeoutMillis() default -1;
 }
diff --git a/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/CustomProfileConnector.java b/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/CustomProfileConnector.java
index 807bb04..7265e72 100644
--- a/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/CustomProfileConnector.java
+++ b/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/CustomProfileConnector.java
@@ -70,4 +70,13 @@
    * <p>By default, this will require that a user be running, unlocked, and not in quiet mode.
    */
   AvailabilityRestrictions availabilityRestrictions() default AvailabilityRestrictions.DEFAULT;
+
+  /**
+   * Determines what to do when a cross-profile method has an uncaught exception.
+   *
+   * <p>By default, the exception will be caught, communicated back to the calling process, then
+   * rethrown in the target process.
+   */
+  UncaughtExceptionsPolicy uncaughtExceptionsPolicy() default
+      UncaughtExceptionsPolicy.NOTIFY_RETHROW;
 }
diff --git a/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/UncaughtExceptionsPolicy.java b/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/UncaughtExceptionsPolicy.java
new file mode 100644
index 0000000..dfdf8a6
--- /dev/null
+++ b/annotations/src/main/java/com/google/android/enterprise/connectedapps/annotations/UncaughtExceptionsPolicy.java
@@ -0,0 +1,15 @@
+package com.google.android.enterprise.connectedapps.annotations;
+
+/** Determines what to do when a cross-profile method has an uncaught exception. */
+public enum UncaughtExceptionsPolicy {
+  /** Notify the caller about the uncaught exception, then rethrow it. */
+  NOTIFY_RETHROW(/* rethrowExceptions= */ true),
+  /** Notify the caller about the uncaught exception, then suppress it. */
+  NOTIFY_SUPPRESS(/* rethrowExceptions= */ false);
+
+  public final boolean rethrowExceptions;
+
+  UncaughtExceptionsPolicy(boolean rethrowExceptions) {
+    this.rethrowExceptions = rethrowExceptions;
+  }
+}
diff --git a/build.gradle b/build.gradle
index 8e106ac..75cb830 100644
--- a/build.gradle
+++ b/build.gradle
@@ -8,7 +8,9 @@
             autoservice: "com.google.auto.service:auto-service:1.0-rc6",
             autoserviceAnnotations: "com.google.auto.service:auto-service-annotations:1.0-rc6",
             javapoet: "com.squareup:javapoet:1.13.0",
-            guava: "com.google.guava:guava:29.0-jre"
+            guava: "com.google.guava:guava:29.0-jre",
+            errorprone: "com.google.errorprone:error_prone_core:2.8.1",
+            robolectric: "org.robolectric:robolectric:4.7.2"
     ]
     repositories {
         jcenter()
diff --git a/connectedappssdk.iml b/connectedappssdk.iml
new file mode 100644
index 0000000..5fa0513
--- /dev/null
+++ b/connectedappssdk.iml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="JAVA_MODULE" version="4">
+    <component name="NewModuleRootManager" inherit-compiler-output="true">
+        <exclude-output />
+        <content url="file:///usr/local/google/home/scottjonathan/android-master/external/connectedappssdk">
+            <sourceFolder url="file:///usr/local/google/home/scottjonathan/android-master/external/connectedappssdk/annotations/src/main/java" isTestSource="false" />
+            <sourceFolder url="file:///usr/local/google/home/scottjonathan/android-master/external/connectedappssdk/processor/src/main/java" isTestSource="false" />
+            <sourceFolder url="file:///usr/local/google/home/scottjonathan/android-master/external/connectedappssdk/sdk/src/main/java" isTestSource="false" />
+            <sourceFolder url="file:///usr/local/google/home/scottjonathan/android-master/external/connectedappssdk/testing/annotations/src/main/java" isTestSource="false" />
+            <sourceFolder url="file:///usr/local/google/home/scottjonathan/android-master/external/connectedappssdk/testing/sdk/src/main/java" isTestSource="false" />
+            <sourceFolder url="file:///usr/local/google/home/scottjonathan/android-master/external/connectedappssdk/tests/instrumented/src/main/java" isTestSource="true" />
+            <sourceFolder url="file:///usr/local/google/home/scottjonathan/android-master/external/connectedappssdk/tests/shared/src/main/java" isTestSource="true" />
+        </content>
+        <orderEntry type="sourceFolder" forTests="false" />
+        <orderEntry type="module" module-name="framework_srcjars" />
+        <orderEntry type="module" module-name="base" />
+        <orderEntry type="module" module-name="cts" />
+        <orderEntry type="module" module-name="dependencies" />
+        <orderEntry type="inheritedJdk" />
+    </component>
+</module>
diff --git a/gradle.properties b/gradle.properties
index 2b34218..8b62e01 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,3 +1,3 @@
 android.useAndroidX=true
-version = 1.0.0-alpha04
+version = 1.1.3-alpha
 org.gradle.jvmargs=-Xmx4g
\ No newline at end of file
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/AlwaysThrowsGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/AlwaysThrowsGenerator.java
index 05a506e..6ad7219 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/AlwaysThrowsGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/AlwaysThrowsGenerator.java
@@ -15,6 +15,8 @@
  */
 package com.google.android.enterprise.connectedapps.processor;
 
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.append;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.EXCEPTION_CALLBACK_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.UNAVAILABLE_PROFILE_EXCEPTION_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.containers.CrossProfileMethodInfo.AutomaticallyResolvedParameterFilterBehaviour.REMOVE_AUTOMATICALLY_RESOLVED_PARAMETERS;
@@ -24,7 +26,6 @@
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.FutureWrapper;
 import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
-import com.squareup.javapoet.AnnotationSpec;
 import com.squareup.javapoet.ClassName;
 import com.squareup.javapoet.MethodSpec;
 import com.squareup.javapoet.ParameterizedTypeName;
@@ -91,19 +92,6 @@
             .addStatement("this.errorMessage = errorMessage")
             .build());
 
-    classBuilder.addMethod(
-        MethodSpec.methodBuilder("timeout")
-            .addAnnotation(Override.class)
-            .addAnnotation(
-                AnnotationSpec.builder(SuppressWarnings.class)
-                    .addMember("value", "$S", "GoodTime")
-                    .build())
-            .addModifiers(Modifier.PUBLIC)
-            .returns(className)
-            .addParameter(long.class, "timeout")
-            .addStatement("return this")
-            .build());
-
     ClassName ifAvailableClass =
         IfAvailableGenerator.getIfAvailableClassName(generatorContext, crossProfileType);
 
@@ -215,7 +203,6 @@
 
   static ClassName getAlwaysThrowsClassName(
       GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
-    return GeneratorUtilities.appendToClassName(
-        crossProfileType.profileClassName(), "_AlwaysThrows");
+    return transformClassName(crossProfileType.generatedClassName(), append("_AlwaysThrows"));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/BundlerGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/BundlerGenerator.java
index 8d421a3..c472003 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/BundlerGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/BundlerGenerator.java
@@ -15,8 +15,11 @@
  */
 package com.google.android.enterprise.connectedapps.processor;
 
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.append;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLER_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLER_TYPE_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLE_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PARCEL_CLASSNAME;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.util.stream.Collectors.toList;
@@ -87,6 +90,8 @@
             .build());
 
     makeParcelable(classBuilder, className);
+    addWriteToBundleMethod(classBuilder);
+    addReadFromBundleMethod(classBuilder);
     addWriteToParcelMethod(classBuilder);
     addReadFromParcelMethod(classBuilder);
     addCreateArrayMethod(classBuilder);
@@ -106,7 +111,6 @@
     generatorUtilities.addDefaultParcelableMethods(classBuilder, bundlerClassName);
   }
 
-
   private void addWriteToParcelMethod(TypeSpec.Builder classBuilder) {
     CodeBlock.Builder methodCode = CodeBlock.builder();
 
@@ -122,9 +126,10 @@
             .addAnnotation(Override.class)
             .addModifiers(Modifier.PUBLIC)
             // This is for passing rawtypes into the Parcelable*.of() methods
+            // ReflectedParcelable isn't a problem because it's the same APK on both sides
             .addAnnotation(
                 AnnotationSpec.builder(SuppressWarnings.class)
-                    .addMember("value", "\"unchecked\"")
+                    .addMember("value", "{\"unchecked\", \"ReflectedParcelable\"}")
                     .build())
             .addParameter(PARCEL_CLASSNAME, "parcel")
             .addParameter(Object.class, "value")
@@ -263,8 +268,124 @@
     codeBuilder.addStatement("return new $T[size]", type.getTypeMirror());
   }
 
+  private void addWriteToBundleMethod(TypeSpec.Builder classBuilder) {
+    CodeBlock.Builder methodCode = CodeBlock.builder();
+
+    List<Type> types =
+        crossProfileType.supportedTypes().usableTypes().stream()
+            .filter(Type::canBeBundled)
+            .filter(t -> !t.isPrimitive())
+            .collect(toList());
+
+    addWriteToBundleTypes(methodCode, types);
+
+    classBuilder.addMethod(
+        MethodSpec.methodBuilder("writeToBundle")
+            // This is for passing rawtypes into the Parcelable*.of() methods
+            .addAnnotation(
+                AnnotationSpec.builder(SuppressWarnings.class)
+                    .addMember("value", "{\"unchecked\", \"ReflectedParcelable\"}")
+                    .build())
+            .addAnnotation(Override.class)
+            .addModifiers(Modifier.PUBLIC)
+            .addParameter(BUNDLE_CLASSNAME, "bundle")
+            .addParameter(String.class, "key")
+            .addParameter(Object.class, "value")
+            .addParameter(BUNDLER_TYPE_CLASSNAME, "valueType")
+            .addCode(methodCode.build())
+            .build());
+  }
+
+  private void addWriteToBundleTypes(CodeBlock.Builder codeBuilder, List<Type> types) {
+    codeBuilder.beginControlFlow(
+        "if ($S.equals(valueType.rawTypeQualifiedName()))", "java.lang.Void");
+    codeBuilder.addStatement("return");
+    for (Type type : types) {
+      codeBuilder.nextControlFlow(
+          "else if ($S.equals(valueType.rawTypeQualifiedName()))",
+          TypeUtils.getRawTypeQualifiedName(type.getTypeMirror()));
+      addWriteToBundleType(codeBuilder, type);
+    }
+    codeBuilder.endControlFlow();
+
+    codeBuilder.addStatement(
+        "throw new $T(\"Type \" + valueType.rawTypeQualifiedName() + \" cannot be written to"
+            + " Bundle\")",
+        IllegalArgumentException.class);
+  }
+
+  private void addWriteToBundleType(CodeBlock.Builder codeBuilder, Type type) {
+    CodeBlock convertedValue =
+        CodeBlock.of("($L) value", TypeUtils.getRawTypeQualifiedName(type.getTypeMirror()));
+    codeBuilder.addStatement(
+        crossProfileType
+            .supportedTypes()
+            .generatePutIntoBundleCode("bundle", type, "key", convertedValue.toString()));
+    codeBuilder.addStatement("return");
+  }
+
+  private void addReadFromBundleMethod(TypeSpec.Builder classBuilder) {
+    CodeBlock.Builder methodCode = CodeBlock.builder();
+
+    List<Type> types =
+        crossProfileType.supportedTypes().usableTypes().stream()
+            .filter(Type::canBeBundled)
+            .collect(toList());
+
+    methodCode.addStatement("bundle.setClassLoader($T.class.getClassLoader())", BUNDLER_CLASSNAME);
+
+    addReadFromBundleTypes(methodCode, types);
+
+    methodCode.addStatement(
+        "throw new $T(\"Type \" + valueType.rawTypeQualifiedName() + \" cannot be read from"
+            + " Bundle\")",
+        IllegalArgumentException.class);
+
+    classBuilder.addMethod(
+        MethodSpec.methodBuilder("readFromBundle")
+            .addAnnotation(
+                AnnotationSpec.builder(SuppressWarnings.class)
+                    .addMember("value", "\"unchecked\"")
+                    .build())
+            .addAnnotation(Override.class)
+            .addModifiers(Modifier.PUBLIC)
+            .returns(Object.class)
+            .addParameter(BUNDLE_CLASSNAME, "bundle")
+            .addParameter(String.class, "key")
+            .addParameter(BUNDLER_TYPE_CLASSNAME, "valueType")
+            .addCode(methodCode.build())
+            .build());
+  }
+
+  private void addReadFromBundleTypes(CodeBlock.Builder codeBuilder, List<Type> types) {
+
+    codeBuilder.beginControlFlow(
+        "if ($S.equals(valueType.rawTypeQualifiedName()))", "java.lang.Void");
+    codeBuilder.addStatement("return null");
+    for (Type type : types) {
+      codeBuilder.nextControlFlow(
+          "else if ($S.equals(valueType.rawTypeQualifiedName()))",
+          TypeUtils.getRawTypeQualifiedName(type.getTypeMirror()));
+      addReadFromBundleType(codeBuilder, type);
+    }
+    codeBuilder.endControlFlow();
+  }
+
+  private void addReadFromBundleType(CodeBlock.Builder codeBuilder, Type type) {
+    TypeMirror objectType = type.getTypeMirror();
+    if (objectType.getKind().isPrimitive()) {
+      PrimitiveType primitiveType = (PrimitiveType) objectType;
+      objectType = generatorContext.types().boxedClass(primitiveType).asType();
+    }
+
+    codeBuilder.addStatement(
+        "return ($L) $L",
+        TypeUtils.getRawTypeQualifiedName(objectType),
+        crossProfileType.supportedTypes().generateGetFromBundleCode("bundle", type, "key"));
+  }
+
   static ClassName getBundlerClassName(
       GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
-    return GeneratorUtilities.appendToClassName(crossProfileType.profileClassName(), "_Bundler");
+    return transformClassName(crossProfileType.generatedClassName(), append("_Bundler"));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ClassNameUtilities.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ClassNameUtilities.java
new file mode 100644
index 0000000..d9c39f7
--- /dev/null
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ClassNameUtilities.java
@@ -0,0 +1,53 @@
+package com.google.android.enterprise.connectedapps.processor;
+
+import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
+import com.squareup.javapoet.ClassName;
+import java.util.function.Function;
+import javax.lang.model.element.TypeElement;
+
+final class ClassNameUtilities {
+
+  /**
+   * Given an {@code originalClassName}, creates a new {@link ClassName} which has an identical
+   * package, but with the type name transformed by a string function.
+   */
+  static ClassName transformClassName(
+      ClassName originalClassName, Function<String, String> transformation) {
+    return ClassName.get(
+        originalClassName.packageName(), transformation.apply(originalClassName.simpleName()));
+  }
+
+  /**
+   * When used with {@link #transformClassName(ClassName, Function)}, inserts the {@code string} in
+   * front of the type name of the type being transformed.
+   */
+  static Function<String, String> prepend(String string) {
+    return name -> string + name;
+  }
+
+  /**
+   * When used with {@link #transformClassName(ClassName, Function)}, inserts the {@code string}
+   * after the type name of the type being transformed.
+   */
+  static Function<String, String> append(String string) {
+    return name -> name + string;
+  }
+
+  static ClassName getBuilderClassName(ClassName originalClassName) {
+    return ClassName.get(
+        originalClassName.packageName() + "." + originalClassName.simpleName(), "Builder");
+  }
+
+  /**
+   * Creates a new {@link ClassName} which has the package of the {@link TypeElement} provided, and
+   * the specified {@code simpleName}.
+   */
+  static ClassName classNameInferringPackageFromElement(
+      GeneratorContext generatorContext, TypeElement packageElement, String simpleName) {
+    return ClassName.get(
+        generatorContext.elements().getPackageOf(packageElement).getQualifiedName().toString(),
+        simpleName);
+  }
+
+  private ClassNameUtilities() {}
+}
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CommonClassNames.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CommonClassNames.java
index cebeebc..23fed95 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CommonClassNames.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CommonClassNames.java
@@ -25,8 +25,11 @@
  */
 public class CommonClassNames {
   static final ClassName CONTEXT_CLASSNAME = ClassName.get("android.content", "Context");
+  static final ClassName BUNDLE_CLASSNAME = ClassName.get("android.os", "Bundle");
   static final ClassName PARCEL_CLASSNAME = ClassName.get("android.os", "Parcel");
   static final ClassName PARCELABLE_CLASSNAME = ClassName.get("android.os", "Parcelable");
+  static final ClassName USER_HANDLE_CLASSNAME = ClassName.get("android.os", "UserHandle");
+  static final ClassName PROCESS_CLASSNAME = ClassName.get("android.os", "Process");
   static final ClassName CROSS_PROFILE_FUTURE_RESULT_WRITER =
       ClassName.get(
           "com.google.android.enterprise.connectedapps.internal", "CrossProfileFutureResultWriter");
@@ -46,19 +49,18 @@
           "com.google.android.enterprise.connectedapps.exceptions", "ProfileRuntimeException");
   static final ClassName PROFILE_AWARE_UTILS_CLASSNAME =
       ClassName.get("com.google.android.enterprise.connectedapps", "ConnectedAppsUtils");
-  static final ClassName BACKGROUND_EXCEPTION_THROWER_CLASSNAME =
-      ClassName.get(
-          "com.google.android.enterprise.connectedapps.internal", "BackgroundExceptionThrower");
-  static final ClassName PARCEL_UTILITIES_CLASSNAME =
-      ClassName.get("com.google.android.enterprise.connectedapps.internal", "ParcelUtilities");
+  static final ClassName EXCEPTION_THROWER_CLASSNAME =
+      ClassName.get("com.google.android.enterprise.connectedapps.internal", "ExceptionThrower");
+  static final ClassName BUNDLE_UTILITIES_CLASSNAME =
+      ClassName.get("com.google.android.enterprise.connectedapps.internal", "BundleUtilities");
   static final ClassName METHOD_RUNNER_CLASSNAME =
       ClassName.get("com.google.android.enterprise.connectedapps.internal", "MethodRunner");
   static final ClassName BUNDLER_CLASSNAME =
       ClassName.get("com.google.android.enterprise.connectedapps.internal", "Bundler");
   static final ClassName BUNDLER_TYPE_CLASSNAME =
       ClassName.get("com.google.android.enterprise.connectedapps.internal", "BundlerType");
-  static final ClassName PARCEL_CALL_RECEIVER_CLASSNAME =
-      ClassName.get("com.google.android.enterprise.connectedapps.internal", "ParcelCallReceiver");
+  static final ClassName BUNDLE_CALL_RECEIVER_CLASSNAME =
+      ClassName.get("com.google.android.enterprise.connectedapps.internal", "BundleCallReceiver");
   public static final ClassName BINDER_CLASSNAME = ClassName.get("android.os", "Binder");
   public static final ClassName INTENT_CLASSNAME = ClassName.get("android.content", "Intent");
   static final ClassName CROSS_PROFILE_SENDER_CLASSNAME =
@@ -77,14 +79,14 @@
       ClassName.get(
           "com.google.android.enterprise.connectedapps.internal",
           "CrossProfileCallbackMultiMerger");
-  static final ClassName CROSS_PROFILE_CALLBACK_PARCEL_CALL_SENDER_CLASSNAME =
+  static final ClassName CROSS_PROFILE_CALLBACK_BUNDLE_CALL_SENDER_CLASSNAME =
       ClassName.get(
           "com.google.android.enterprise.connectedapps.internal",
-          "CrossProfileCallbackParcelCallSender");
-  static final ClassName CROSS_PROFILE_CALLBACK_EXCEPTION_PARCEL_CALL_SENDER_CLASSNAME =
+          "CrossProfileCallbackBundleCallSender");
+  static final ClassName CROSS_PROFILE_CALLBACK_EXCEPTION_BUNDLE_CALL_SENDER_CLASSNAME =
       ClassName.get(
           "com.google.android.enterprise.connectedapps.internal",
-          "CrossProfileCallbackExceptionParcelCallSender");
+          "CrossProfileCallbackExceptionBundleCallSender");
   static final ClassName ASYNC_CALLBACK_PARAM_MULTIMERGER_COMPLETE_LISTENER_CLASSNAME =
       ClassName.get(
           "com.google.android.enterprise.connectedapps.internal.CrossProfileCallbackMultiMerger",
@@ -101,8 +103,12 @@
       ClassName.get("com.google.android.enterprise.connectedapps", "ProfileConnector");
   public static final ClassName ABSTRACT_PROFILE_CONNECTOR_CLASSNAME =
       ClassName.get("com.google.android.enterprise.connectedapps", "AbstractProfileConnector");
+  public static final ClassName USER_CONNECTOR_CLASSNAME =
+      ClassName.get("com.google.android.enterprise.connectedapps", "UserConnector");
   public static final ClassName ABSTRACT_USER_CONNECTOR_CLASSNAME =
       ClassName.get("com.google.android.enterprise.connectedapps", "AbstractUserConnector");
+  public static final ClassName USER_CONNECTOR_WRAPPER_CLASSNAME =
+      ClassName.get("com.google.android.enterprise.connectedapps", "UserConnectorWrapper");
   public static final ClassName ABSTRACT_PROFILE_CONNECTOR_BUILDER_CLASSNAME =
       ClassName.get(
           "com.google.android.enterprise.connectedapps.AbstractProfileConnector", "Builder");
@@ -110,11 +116,21 @@
       ClassName.get("com.google.android.enterprise.connectedapps.AbstractUserConnector", "Builder");
   public static final ClassName CONNECTION_BINDER_CLASSNAME =
       ClassName.get("com.google.android.enterprise.connectedapps", "ConnectionBinder");
+  public static final ClassName USER_BINDER_FACTORY_CLASSNAME =
+      ClassName.get("com.google.android.enterprise.connectedapps", "UserBinderFactory");
   public static final ClassName SCHEDULED_EXECUTOR_SERVICE_CLASSNAME =
       ClassName.get("java.util.concurrent", "ScheduledExecutorService");
+  public static final ClassName FAKE_PROFILE_CONNECTOR_CLASSNAME =
+      ClassName.get("com.google.android.enterprise.connectedapps.testing", "FakeProfileConnector");
   public static final ClassName ABSTRACT_FAKE_PROFILE_CONNECTOR_CLASSNAME =
       ClassName.get(
           "com.google.android.enterprise.connectedapps.testing", "AbstractFakeProfileConnector");
+  public static final ClassName ABSTRACT_FAKE_USER_CONNECTOR_CLASSNAME =
+      ClassName.get(
+          "com.google.android.enterprise.connectedapps.testing", "AbstractFakeUserConnector");
+  public static final ClassName FAKE_USER_CONNECTOR_WRAPPER_CLASSNAME =
+      ClassName.get(
+          "com.google.android.enterprise.connectedapps.testing", "FakeUserConnectorWrapper");
 
   public static final ClassName VERSION_CLASSNAME = ClassName.get("android.os.Build", "VERSION");
   public static final ClassName VERSION_CODES_CLASSNAME =
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ConfigurationCodeGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ConfigurationCodeGenerator.java
index a4647dd..ed72310 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ConfigurationCodeGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ConfigurationCodeGenerator.java
@@ -30,13 +30,12 @@
  */
 class ConfigurationCodeGenerator {
   private boolean generated = false;
-  private final CrossProfileConfigurationInfo configuration;
   private final ServiceGenerator serviceGenerator;
   private final DispatcherGenerator dispatcherGenerator;
 
   ConfigurationCodeGenerator(
       GeneratorContext generatorContext, CrossProfileConfigurationInfo configuration) {
-    this.configuration = checkNotNull(configuration);
+    checkNotNull(configuration);
     this.serviceGenerator = new ServiceGenerator(checkNotNull(generatorContext), configuration);
     this.dispatcherGenerator = new DispatcherGenerator(generatorContext, configuration);
   }
@@ -48,11 +47,6 @@
     }
     generated = true;
 
-    if (configuration.profileConnector() == null) {
-      // Without a connector we can't line things up so don't generate
-      return;
-    }
-
     serviceGenerator.generate();
     dispatcherGenerator.generate();
   }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileCallbackCodeGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileCallbackCodeGenerator.java
index 2822bf2..255d596 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileCallbackCodeGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileCallbackCodeGenerator.java
@@ -15,16 +15,17 @@
  */
 package com.google.android.enterprise.connectedapps.processor;
 
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.classNameInferringPackageFromElement;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.ASYNC_CALLBACK_PARAM_MULTIMERGER_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.ASYNC_CALLBACK_PARAM_MULTIMERGER_COMPLETE_LISTENER_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLER_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLE_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLE_UTILITIES_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CROSS_PROFILE_CALLBACK_BUNDLE_CALL_SENDER_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CROSS_PROFILE_CALLBACK_CLASSNAME;
-import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CROSS_PROFILE_CALLBACK_EXCEPTION_PARCEL_CALL_SENDER_CLASSNAME;
-import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CROSS_PROFILE_CALLBACK_PARCEL_CALL_SENDER_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CROSS_PROFILE_CALLBACK_EXCEPTION_BUNDLE_CALL_SENDER_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.EXCEPTION_CALLBACK_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.LOCAL_CALLBACK_CLASSNAME;
-import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PARCEL_CLASSNAME;
-import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PARCEL_UTILITIES_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PROFILE_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.UNAVAILABLE_PROFILE_EXCEPTION_CLASSNAME;
 import static com.google.common.base.Preconditions.checkNotNull;
@@ -43,7 +44,6 @@
 import java.util.Map;
 import javax.lang.model.element.ExecutableElement;
 import javax.lang.model.element.Modifier;
-import javax.lang.model.element.PackageElement;
 import javax.lang.model.element.VariableElement;
 import javax.lang.model.type.TypeMirror;
 
@@ -255,7 +255,7 @@
                     + " $2T}.\n",
                 callbackInterface.interfaceElement(),
                 CROSS_PROFILE_CALLBACK_CLASSNAME,
-                PARCEL_CLASSNAME);
+                BUNDLE_CLASSNAME);
 
     classBuilder.addField(
         FieldSpec.builder(CROSS_PROFILE_CALLBACK_CLASSNAME, "callback")
@@ -305,22 +305,20 @@
 
     methodBuilder.addStatement(
         "$1T callSender = new $1T(callback, /* methodIdentifier= */ $2L)",
-        CROSS_PROFILE_CALLBACK_PARCEL_CALL_SENDER_CLASSNAME,
+        CROSS_PROFILE_CALLBACK_BUNDLE_CALL_SENDER_CLASSNAME,
         callbackInterface.getIdentifier(method));
 
-    // parcel is recycled in this method
-    methodBuilder.addStatement("$1T parcel = $1T.obtain()", PARCEL_CLASSNAME);
+    methodBuilder.addStatement(
+        "$1T bundle = new $1T($2T.class.getClassLoader())", BUNDLE_CLASSNAME, BUNDLER_CLASSNAME);
 
     for (VariableElement param : method.getParameters()) {
       methodBuilder.addStatement(
-          "bundler.writeToParcel(parcel, $1L, $2L, /* flags= */ 0)",
+          "bundler.writeToBundle(bundle, $1S, $1L, $2L)",
           param.getSimpleName(),
           TypeUtils.generateBundlerType(param.asType()));
     }
 
-    methodBuilder.addStatement("callSender.makeParcelCall(parcel)", PARCEL_CLASSNAME);
-
-    methodBuilder.addStatement("parcel.recycle()");
+    methodBuilder.addStatement("callSender.makeBundleCall(bundle)");
 
     methodBuilder
         .nextControlFlow("catch ($T e)", Exception.class)
@@ -328,16 +326,16 @@
         .addStatement(
             "$1T unavailableProfileException = new $1T(\"Error when writing callback result\", e)",
             UNAVAILABLE_PROFILE_EXCEPTION_CLASSNAME)
-        // parcel is recycled in this method
-        .addStatement("$1T parcel = $1T.obtain()", PARCEL_CLASSNAME)
         .addStatement(
-            "$T.writeThrowableToParcel(parcel, unavailableProfileException)",
-            PARCEL_UTILITIES_CLASSNAME)
+            "$1T bundle = new $1T($2T.class.getClassLoader())", BUNDLE_CLASSNAME, BUNDLER_CLASSNAME)
+        .addStatement(
+            "$T.writeThrowableToBundle(bundle, $S, unavailableProfileException)",
+            BUNDLE_UTILITIES_CLASSNAME,
+            "throwable")
         .addStatement(
             "$1T callSender = new $1T(callback)",
-            CROSS_PROFILE_CALLBACK_EXCEPTION_PARCEL_CALL_SENDER_CLASSNAME)
-        .addStatement("callSender.makeParcelCall(parcel)", PARCEL_CLASSNAME)
-        .addStatement("parcel.recycle()")
+            CROSS_PROFILE_CALLBACK_EXCEPTION_BUNDLE_CALL_SENDER_CLASSNAME)
+        .addStatement("callSender.makeBundleCall(bundle)")
         .nextControlFlow("catch ($T r)", UNAVAILABLE_PROFILE_EXCEPTION_CLASSNAME)
         .addComment(
             "TODO: Decide what should happen if the connection is dropped between the call and"
@@ -404,7 +402,7 @@
             .addAnnotation(Override.class)
             .addModifiers(Modifier.PUBLIC)
             .addParameter(int.class, "methodIdentifier")
-            .addParameter(PARCEL_CLASSNAME, "params");
+            .addParameter(BUNDLE_CLASSNAME, "params");
     methodBuilder.beginControlFlow("switch (methodIdentifier)$>");
 
     for (ExecutableElement method : callbackInterface.methods()) {
@@ -424,11 +422,12 @@
         MethodSpec.methodBuilder("onException")
             .addAnnotation(Override.class)
             .addModifiers(Modifier.PUBLIC)
-            .addParameter(PARCEL_CLASSNAME, "exception");
+            .addParameter(BUNDLE_CLASSNAME, "exception");
     methodBuilder.addStatement(
-        "$1T throwable = $2T.readThrowableFromParcel(exception)",
+        "$1T throwable = $2T.readThrowableFromBundle(exception, $3S)",
         Throwable.class,
-        PARCEL_UTILITIES_CLASSNAME);
+        BUNDLE_UTILITIES_CLASSNAME,
+        "throwable");
 
     methodBuilder.addStatement("exceptionCallback.onException(throwable)");
 
@@ -438,7 +437,8 @@
   private void addDispatchCode(MethodSpec.Builder methodBuilder, ExecutableElement method) {
     for (VariableElement parameter : method.getParameters()) {
       methodBuilder.addStatement(
-          "@SuppressWarnings(\"unchecked\") $1T $2L = ($1T) bundler.readFromParcel(params, $3L)",
+          "@SuppressWarnings(\"unchecked\") $1T $2L = ($1T) bundler.readFromBundle(params, $2S,"
+              + " $3L)",
           parameter.asType(),
           parameter.getSimpleName().toString(),
           TypeUtils.generateBundlerType(parameter.asType()));
@@ -454,48 +454,42 @@
 
   static ClassName getCrossProfileCallbackMultiInterfaceClassName(
       GeneratorContext generatorContext, CrossProfileCallbackInterfaceInfo callbackInterface) {
-    PackageElement originalPackage =
-        generatorContext.elements().getPackageOf(callbackInterface.interfaceElement());
-    String interfaceName = String.format("%s_Multi", callbackInterface.simpleName());
-
-    return ClassName.get(originalPackage.getQualifiedName().toString(), interfaceName);
+    return classNameInferringPackageFromElement(
+        generatorContext,
+        callbackInterface.interfaceElement(),
+        String.format("%s_Multi", callbackInterface.simpleName()));
   }
 
   static ClassName getCrossProfileCallbackMultiMergerResultClassName(
       GeneratorContext generatorContext, CrossProfileCallbackInterfaceInfo callbackInterface) {
-    PackageElement originalPackage =
-        generatorContext.elements().getPackageOf(callbackInterface.interfaceElement());
-    String interfaceName =
-        String.format("Profile_%s_MultiMergerResult", callbackInterface.simpleName());
-
-    return ClassName.get(originalPackage.getQualifiedName().toString(), interfaceName);
+    return classNameInferringPackageFromElement(
+        generatorContext,
+        callbackInterface.interfaceElement(),
+        String.format("Profile_%s_MultiMergerResult", callbackInterface.simpleName()));
   }
 
   static ClassName getCrossProfileCallbackMultiMergerInputClassName(
       GeneratorContext generatorContext, CrossProfileCallbackInterfaceInfo callbackInterface) {
-    PackageElement originalPackage =
-        generatorContext.elements().getPackageOf(callbackInterface.interfaceElement());
-    String interfaceName =
-        String.format("Profile_%s_MultiMergerInput", callbackInterface.simpleName());
-
-    return ClassName.get(originalPackage.getQualifiedName().toString(), interfaceName);
+    return classNameInferringPackageFromElement(
+        generatorContext,
+        callbackInterface.interfaceElement(),
+        String.format("Profile_%s_MultiMergerInput", callbackInterface.simpleName()));
   }
 
   static ClassName getCrossProfileCallbackReceiverClassName(
       GeneratorContext generatorContext, CrossProfileCallbackInterfaceInfo callbackInterface) {
-    PackageElement originalPackage =
-        generatorContext.elements().getPackageOf(callbackInterface.interfaceElement());
-    String interfaceName = String.format("Profile_%s_Receiver", callbackInterface.simpleName());
-
-    return ClassName.get(originalPackage.getQualifiedName().toString(), interfaceName);
+    return classNameInferringPackageFromElement(
+        generatorContext,
+        callbackInterface.interfaceElement(),
+        String.format("Profile_%s_Receiver", callbackInterface.simpleName()));
   }
 
   static ClassName getCrossProfileCallbackSenderClassName(
       GeneratorContext generatorContext, CrossProfileCallbackInterfaceInfo callbackInterface) {
-    PackageElement originalPackage =
-        generatorContext.elements().getPackageOf(callbackInterface.interfaceElement());
-    String interfaceName = String.format("Profile_%s_Sender", callbackInterface.simpleName());
-
-    return ClassName.get(originalPackage.getQualifiedName().toString(), interfaceName);
+    return classNameInferringPackageFromElement(
+        generatorContext,
+        callbackInterface.interfaceElement(),
+        String.format("Profile_%s_Sender", callbackInterface.simpleName()));
   }
 }
+
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileTypeCodeGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileTypeCodeGenerator.java
index 075ac6c..c1c0336 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileTypeCodeGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileTypeCodeGenerator.java
@@ -23,15 +23,9 @@
 
 class CrossProfileTypeCodeGenerator {
   private boolean generated = false;
-  private final InterfaceGenerator interfaceGenerator;
-  private final CurrentProfileGenerator currentProfileGenerator;
-  private final OtherProfileGenerator otherProfileGenerator;
-  private final IfAvailableGenerator ifAvailableGenerator;
-  private final AlwaysThrowsGenerator alwaysThrowsGenerator;
+  private final CrossProfileTypeInterfaceGenerator crossProfileTypeInterfaceGenerator;
   private final MultipleProfilesGenerator multipleProfilesGenerator;
   private final DefaultProfileClassGenerator defaultProfileClassGenerator;
-  private final InternalCrossProfileClassGenerator internalCrossProfileClassGenerator;
-  private final BundlerGenerator bundlerGenerator;
 
   public CrossProfileTypeCodeGenerator(
       GeneratorContext generatorContext,
@@ -39,18 +33,12 @@
       CrossProfileTypeInfo crossProfileType) {
     checkNotNull(generatorContext);
     checkNotNull(crossProfileType);
-    this.interfaceGenerator = new InterfaceGenerator(generatorContext, crossProfileType);
-    this.currentProfileGenerator = new CurrentProfileGenerator(generatorContext, crossProfileType);
-    this.otherProfileGenerator = new OtherProfileGenerator(generatorContext, crossProfileType);
-    this.ifAvailableGenerator = new IfAvailableGenerator(generatorContext, crossProfileType);
-    this.alwaysThrowsGenerator = new AlwaysThrowsGenerator(generatorContext, crossProfileType);
+    this.crossProfileTypeInterfaceGenerator =
+        new CrossProfileTypeInterfaceGenerator(generatorContext, crossProfileType);
     this.multipleProfilesGenerator =
         new MultipleProfilesGenerator(generatorContext, crossProfileType);
     this.defaultProfileClassGenerator =
         new DefaultProfileClassGenerator(generatorContext, crossProfileType);
-    this.internalCrossProfileClassGenerator =
-        new InternalCrossProfileClassGenerator(generatorContext, providerClass, crossProfileType);
-    this.bundlerGenerator = new BundlerGenerator(generatorContext, crossProfileType);
   }
 
   void generate() {
@@ -60,14 +48,8 @@
     }
     generated = true;
 
-    interfaceGenerator.generate();
-    currentProfileGenerator.generate();
-    otherProfileGenerator.generate();
-    ifAvailableGenerator.generate();
-    alwaysThrowsGenerator.generate();
+    crossProfileTypeInterfaceGenerator.generate();
     multipleProfilesGenerator.generate();
     defaultProfileClassGenerator.generate();
-    internalCrossProfileClassGenerator.generate();
-    bundlerGenerator.generate();
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileTypeInterfaceGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileTypeInterfaceGenerator.java
new file mode 100644
index 0000000..b0db0db
--- /dev/null
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileTypeInterfaceGenerator.java
@@ -0,0 +1,261 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.processor;
+
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PROFILE_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PROFILE_CONNECTOR_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.InterfaceGenerator.getCrossProfileTypeInterfaceClassName;
+import static com.google.android.enterprise.connectedapps.processor.InterfaceGenerator.getMultipleSenderInterfaceClassName;
+import static com.google.android.enterprise.connectedapps.processor.InterfaceGenerator.getSingleSenderCanThrowInterfaceClassName;
+import static com.google.android.enterprise.connectedapps.processor.InterfaceGenerator.getSingleSenderInterfaceClassName;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.android.enterprise.connectedapps.annotations.CrossProfile;
+import com.google.android.enterprise.connectedapps.annotations.CustomProfileConnector;
+import com.google.android.enterprise.connectedapps.annotations.CustomProfileConnector.ProfileType;
+import com.google.android.enterprise.connectedapps.processor.containers.ConnectorInfo;
+import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
+import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
+import com.google.android.enterprise.connectedapps.processor.containers.ProfileConnectorInfo;
+import com.google.common.base.Ascii;
+import com.squareup.javapoet.ArrayTypeName;
+import com.squareup.javapoet.ClassName;
+import com.squareup.javapoet.CodeBlock;
+import com.squareup.javapoet.MethodSpec;
+import com.squareup.javapoet.TypeSpec;
+import java.util.Optional;
+import javax.lang.model.element.Modifier;
+
+/** Generator of cross-profile code for a single {@link CrossProfile} type. */
+final class CrossProfileTypeInterfaceGenerator {
+
+  private boolean generated = false;
+  private final GeneratorContext generatorContext;
+  private final GeneratorUtilities generatorUtilities;
+  private final CrossProfileTypeInfo crossProfileType;
+  private final Optional<ProfileConnectorInfo> profileConnector;
+
+  CrossProfileTypeInterfaceGenerator(
+      GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
+    this.generatorContext = checkNotNull(generatorContext);
+    this.generatorUtilities = new GeneratorUtilities(generatorContext);
+    this.crossProfileType = checkNotNull(crossProfileType);
+    this.profileConnector =
+        crossProfileType.connectorInfo().map(ConnectorInfo::profileConnector).map(Optional::get);
+  }
+
+  void generate() {
+    if (generated) {
+      throw new IllegalStateException(
+          "CrossProfileTypeInterfaceGenerator#generate can only be called once");
+    }
+    generated = true;
+
+    generateCrossProfileTypeInterface();
+  }
+
+  private void generateCrossProfileTypeInterface() {
+    ClassName interfaceName =
+        getCrossProfileTypeInterfaceClassName(generatorContext, crossProfileType);
+
+    TypeSpec.Builder interfaceBuilder =
+        TypeSpec.interfaceBuilder(interfaceName)
+            .addJavadoc(
+                "Entry point for cross-profile calls to {@link $T}.\n",
+                crossProfileType.className())
+            .addModifiers(Modifier.PUBLIC);
+
+    ClassName connectorClassName =
+        profileConnector.isPresent()
+            ? profileConnector.get().connectorClassName()
+            : PROFILE_CONNECTOR_CLASSNAME;
+
+    interfaceBuilder.addMethod(
+        MethodSpec.methodBuilder("create")
+            .returns(interfaceName)
+            .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
+            .addParameter(connectorClassName, "connector")
+            .addStatement(
+                "return new $T(connector)",
+                DefaultProfileClassGenerator.getDefaultProfileClassName(
+                    generatorContext, crossProfileType))
+            .build());
+
+    interfaceBuilder.addMethod(
+        MethodSpec.methodBuilder("current")
+            .addJavadoc("Run a method on the current profile.\n")
+            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
+            .returns(getSingleSenderInterfaceClassName(generatorContext, crossProfileType))
+            .build());
+
+    interfaceBuilder.addMethod(
+        MethodSpec.methodBuilder("other")
+            .addJavadoc("Run a method on the other profile, if accessible.\n")
+            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
+            .returns(getSingleSenderCanThrowInterfaceClassName(generatorContext, crossProfileType))
+            .build());
+
+    interfaceBuilder.addMethod(
+        MethodSpec.methodBuilder("personal")
+            .addJavadoc("Run a method on the personal profile, if accessible.\n")
+            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
+            .returns(getSingleSenderCanThrowInterfaceClassName(generatorContext, crossProfileType))
+            .build());
+
+    interfaceBuilder.addMethod(
+        MethodSpec.methodBuilder("work")
+            .addJavadoc("Run a method on the work profile, if accessible.\n")
+            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
+            .returns(getSingleSenderCanThrowInterfaceClassName(generatorContext, crossProfileType))
+            .build());
+
+    interfaceBuilder.addMethod(
+        MethodSpec.methodBuilder("profile")
+            .addJavadoc("Run a method on the given profile, if accessible.\n")
+            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
+            .addParameter(PROFILE_CLASSNAME, "profile")
+            .returns(getSingleSenderCanThrowInterfaceClassName(generatorContext, crossProfileType))
+            .build());
+
+    interfaceBuilder.addMethod(
+        MethodSpec.methodBuilder("profiles")
+            .addJavadoc(
+                CodeBlock.builder()
+                    .add("Run a method on the given profiles, if accessible.\n\n")
+                    .add(
+                        "<p>This will deduplicate profiles to ensure that the method is only run"
+                            + " at most once on each profile.\n")
+                    .build())
+            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
+            .addParameter(ArrayTypeName.of(PROFILE_CLASSNAME), "profiles")
+            .varargs(true)
+            .returns(getMultipleSenderInterfaceClassName(generatorContext, crossProfileType))
+            .build());
+
+    interfaceBuilder.addMethod(
+        MethodSpec.methodBuilder("both")
+            .addJavadoc("Run a method on both the personal and work profile, if accessible.\n")
+            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
+            .returns(getMultipleSenderInterfaceClassName(generatorContext, crossProfileType))
+            .build());
+
+    if (!profileConnector.isPresent()
+        || profileConnector.get().primaryProfile() != ProfileType.NONE) {
+      generatePrimarySecondaryMethods(interfaceBuilder);
+    }
+
+    generatorUtilities.writeClassToFile(interfaceName.packageName(), interfaceBuilder);
+  }
+
+  private void generatePrimarySecondaryMethods(TypeSpec.Builder interfaceBuilder) {
+    generatePrimaryMethod(interfaceBuilder);
+    generateSecondaryMethod(interfaceBuilder);
+    generateSuppliersMethod(interfaceBuilder);
+  }
+
+  private void generatePrimaryMethod(TypeSpec.Builder interfaceBuilder) {
+    MethodSpec.Builder methodBuilder =
+        MethodSpec.methodBuilder("primary")
+            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
+            .returns(getSingleSenderCanThrowInterfaceClassName(generatorContext, crossProfileType));
+
+    if (profileConnector.isPresent()) {
+      methodBuilder.addJavadoc(
+          "Run a method on the primary ("
+              + Ascii.toLowerCase(profileConnector.get().primaryProfile().name())
+              + ") profile, if accessible.\n\n@see $T#primaryProfile()\n",
+          CustomProfileConnector.class);
+    } else {
+      methodBuilder.addJavadoc(
+          "Run a method on the primary profile, if accessible.\n\n"
+              + "@throws $1T if the {@link $2T} does not have a primary profile set\n"
+              + "@see $2T#primaryProfile()\n",
+          IllegalStateException.class,
+          CustomProfileConnector.class);
+    }
+
+    interfaceBuilder.addMethod(methodBuilder.build());
+  }
+
+  private void generateSecondaryMethod(TypeSpec.Builder interfaceBuilder) {
+    MethodSpec.Builder methodBuilder =
+        MethodSpec.methodBuilder("secondary")
+            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
+            .returns(getSingleSenderCanThrowInterfaceClassName(generatorContext, crossProfileType));
+
+    if (profileConnector.isPresent()) {
+      String secondaryProfileName =
+          profileConnector.get().primaryProfile().equals(ProfileType.WORK)
+              ? Ascii.toLowerCase(ProfileType.PERSONAL.name())
+              : Ascii.toLowerCase(ProfileType.WORK.name());
+      methodBuilder.addJavadoc(
+          "Run a method on the secondary ("
+              + secondaryProfileName
+              + ") profile, if accessible.\n\n@see $T#primaryProfile()\n",
+          CustomProfileConnector.class);
+    } else {
+      methodBuilder.addJavadoc(
+          "Run a method on the secondary profile, if accessible.\n\n"
+              + "@throws $1T if the {@link $2T} does not have a primary profile set\n"
+              + "@see $2T#primaryProfile()\n",
+          IllegalStateException.class,
+          CustomProfileConnector.class);
+    }
+
+    interfaceBuilder.addMethod(methodBuilder.build());
+  }
+
+  private void generateSuppliersMethod(TypeSpec.Builder interfaceBuilder) {
+    MethodSpec.Builder methodBuilder =
+        MethodSpec.methodBuilder("suppliers")
+            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
+            .returns(getMultipleSenderInterfaceClassName(generatorContext, crossProfileType));
+
+    if (profileConnector.isPresent()) {
+      String primaryProfileName =
+          profileConnector.get().primaryProfile().equals(ProfileType.WORK)
+              ? Ascii.toLowerCase(ProfileType.WORK.name())
+              : Ascii.toLowerCase(ProfileType.PERSONAL.name());
+      String secondaryProfileName =
+          profileConnector.get().primaryProfile().equals(ProfileType.WORK)
+              ? Ascii.toLowerCase(ProfileType.PERSONAL.name())
+              : Ascii.toLowerCase(ProfileType.WORK.name());
+      methodBuilder
+          .addJavadoc("Run a method on supplier profiles, if accessible.\n\n")
+          .addJavadoc(
+              "<p>When run from the primary ($1L) profile, supplier profiles are the primary ($1L)"
+                  + " and secondary ($2L) profiles. When run from the secondary ($2L) profile,"
+                  + " supplier profiles includes only the secondary ($2L) profile.\n\n",
+              primaryProfileName,
+              secondaryProfileName)
+          .addJavadoc("@see $T#primaryProfile()\n", CustomProfileConnector.class);
+    } else {
+      methodBuilder
+          .addJavadoc("Run a method on supplier profiles, if accessible.\n\n")
+          .addJavadoc(
+              "<p>When run from the primary profile, supplier profiles are the primary and"
+                  + " secondary profiles. When run from the secondary profile, supplier profiles"
+                  + " includes only the secondary profile.\n\n")
+          .addJavadoc(
+              "@throws $1T if the {@link $2T} does not have a primary profile set\n",
+              IllegalStateException.class,
+              CustomProfileConnector.class)
+          .addJavadoc("@see $T#primaryProfile()\n", CustomProfileConnector.class);
+    }
+
+    interfaceBuilder.addMethod(methodBuilder.build());
+  }
+}
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossUserTypeCodeGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossUserTypeCodeGenerator.java
new file mode 100644
index 0000000..d65524c
--- /dev/null
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossUserTypeCodeGenerator.java
@@ -0,0 +1,219 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.processor;
+
+import static com.google.android.enterprise.connectedapps.processor.AlwaysThrowsGenerator.getAlwaysThrowsClassName;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.prepend;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CONTEXT_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PROCESS_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.USER_CONNECTOR_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.USER_CONNECTOR_WRAPPER_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.USER_HANDLE_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.VERSION_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.VERSION_CODES_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CurrentProfileGenerator.getCurrentProfileClassName;
+import static com.google.android.enterprise.connectedapps.processor.InterfaceGenerator.getSingleSenderCanThrowInterfaceClassName;
+import static com.google.android.enterprise.connectedapps.processor.InterfaceGenerator.getSingleSenderInterfaceClassName;
+import static com.google.android.enterprise.connectedapps.processor.OtherProfileGenerator.getOtherProfileClassName;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
+import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
+import com.google.android.enterprise.connectedapps.processor.containers.ProviderClassInfo;
+import com.google.android.enterprise.connectedapps.processor.containers.UserConnectorInfo;
+import com.squareup.javapoet.ClassName;
+import com.squareup.javapoet.FieldSpec;
+import com.squareup.javapoet.MethodSpec;
+import com.squareup.javapoet.TypeSpec;
+import java.util.Optional;
+import javax.lang.model.element.Modifier;
+
+class CrossUserTypeCodeGenerator {
+
+  private final GeneratorContext generatorContext;
+  private final GeneratorUtilities generatorUtilities;
+  private final CrossProfileTypeInfo crossUserType;
+  private final Optional<UserConnectorInfo> userConnector;
+
+  private boolean generated = false;
+
+  public CrossUserTypeCodeGenerator(
+      GeneratorContext generatorContext,
+      ProviderClassInfo providerClass,
+      CrossProfileTypeInfo crossUserType) {
+    checkNotNull(generatorContext);
+    checkNotNull(crossUserType);
+    this.generatorContext = generatorContext;
+    generatorUtilities = new GeneratorUtilities(generatorContext);
+    this.crossUserType = crossUserType;
+    this.userConnector =
+        crossUserType.connectorInfo().map(connectorInfo -> connectorInfo.userConnector().get());
+  }
+
+  void generate() {
+    if (generated) {
+      throw new IllegalStateException(
+          "CrossProfileTypeCodeGenerator#generate can only be called once");
+    }
+    generated = true;
+
+    generateCrossUserInterface();
+    generateCrossUserDefaultImplementation();
+  }
+
+  private void generateCrossUserInterface() {
+    ClassName interfaceName = getCrossUserTypeInterfaceClassName(generatorContext, crossUserType);
+    ClassName connectorClassName =
+        userConnector.map(UserConnectorInfo::connectorClassName).orElse(USER_CONNECTOR_CLASSNAME);
+
+    TypeSpec.Builder interfaceBuilder =
+        TypeSpec.interfaceBuilder(interfaceName)
+            .addJavadoc(
+                "Entry point for cross-user calls to {@link $T}.\n", crossUserType.className())
+            .addModifiers(Modifier.PUBLIC);
+
+    interfaceBuilder.addMethod(
+        MethodSpec.methodBuilder("create")
+            .returns(interfaceName)
+            .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
+            .addParameter(connectorClassName, "connector")
+            .addStatement(
+                "return new $T(connector)",
+                getDefaultUserClassName(generatorContext, crossUserType))
+            .build());
+
+    interfaceBuilder.addMethod(
+        MethodSpec.methodBuilder("current")
+            .addJavadoc("Run a method on the current user.\n")
+            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
+            .returns(getSingleSenderInterfaceClassName(generatorContext, crossUserType))
+            .build());
+
+    interfaceBuilder.addMethod(
+        MethodSpec.methodBuilder("user")
+            .addJavadoc("Run a method on a specific user.\n")
+            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
+            .addParameter(USER_HANDLE_CLASSNAME, "userHandle")
+            .returns(getSingleSenderCanThrowInterfaceClassName(generatorContext, crossUserType))
+            .build());
+
+    generatorUtilities.writeClassToFile(interfaceName.packageName(), interfaceBuilder);
+  }
+
+  private void generateCrossUserDefaultImplementation() {
+    ClassName userClassName = getDefaultUserClassName(generatorContext, crossUserType);
+    ClassName crossUserTypeInterfaceClassName =
+        getCrossUserTypeInterfaceClassName(generatorContext, crossUserType);
+
+    TypeSpec.Builder classBuilder =
+        TypeSpec.classBuilder(userClassName)
+            .addJavadoc(
+                "Default implementation of {@link $T} to be used in production.\n",
+                crossUserTypeInterfaceClassName)
+            .addModifiers(Modifier.FINAL);
+
+    classBuilder.addSuperinterface(crossUserTypeInterfaceClassName);
+
+    classBuilder.addField(
+        FieldSpec.builder(USER_CONNECTOR_CLASSNAME, "connector")
+            .addModifiers(Modifier.PRIVATE, Modifier.FINAL)
+            .build());
+
+    classBuilder.addMethod(
+        MethodSpec.constructorBuilder()
+            .addParameter(USER_CONNECTOR_CLASSNAME, "connector")
+            .addModifiers(Modifier.PUBLIC)
+            .addStatement("this.connector = connector")
+            .build());
+
+    addCurrentMethod(classBuilder);
+    addUserMethod(classBuilder);
+    addCurrentProfileHelper(classBuilder);
+
+    generatorUtilities.writeClassToFile(userClassName.packageName(), classBuilder);
+  }
+
+  private void addCurrentProfileHelper(TypeSpec.Builder classBuilder) {
+    ClassName currentProfileConcreteType =
+        getCurrentProfileClassName(generatorContext, crossUserType);
+    MethodSpec.Builder currentProfileHelperMethodBuilder =
+        MethodSpec.methodBuilder("instanceOfCurrentProfile")
+            .addModifiers(Modifier.PRIVATE)
+            .returns(currentProfileConcreteType)
+            .addStatement(
+                "$T context = connector.applicationContext($T.myUserHandle())",
+                CONTEXT_CLASSNAME,
+                PROCESS_CLASSNAME);
+
+    if (crossUserType.isStatic()) {
+      currentProfileHelperMethodBuilder.addStatement(
+          "return new $1T(context)", currentProfileConcreteType);
+    } else {
+      currentProfileHelperMethodBuilder.addStatement(
+          "return new $1T(context, $2T.instance().crossProfileType(context))",
+          currentProfileConcreteType,
+          InternalCrossProfileClassGenerator.getInternalCrossProfileClassName(
+              generatorContext, crossUserType));
+    }
+
+    classBuilder.addMethod(currentProfileHelperMethodBuilder.build());
+  }
+
+  private void addCurrentMethod(TypeSpec.Builder classBuilder) {
+    classBuilder.addMethod(
+        MethodSpec.methodBuilder("current")
+            .addAnnotation(Override.class)
+            .addModifiers(Modifier.PUBLIC)
+            .returns(getSingleSenderInterfaceClassName(generatorContext, crossUserType))
+            .addStatement("return instanceOfCurrentProfile()")
+            .build());
+  }
+
+  private void addUserMethod(TypeSpec.Builder classBuilder) {
+    classBuilder.addMethod(
+        MethodSpec.methodBuilder("user")
+            .addAnnotation(Override.class)
+            .addModifiers(Modifier.PUBLIC)
+            .addParameter(USER_HANDLE_CLASSNAME, "userHandle")
+            .returns(getSingleSenderCanThrowInterfaceClassName(generatorContext, crossUserType))
+            .beginControlFlow("if ($T.SDK_INT < $T.O)", VERSION_CLASSNAME, VERSION_CODES_CLASSNAME)
+            .addStatement(
+                "return new $T($S)",
+                getAlwaysThrowsClassName(generatorContext, crossUserType),
+                "Cross-user calls are not supported on this version of Android")
+            .nextControlFlow("else if (userHandle == $T.myUserHandle())", PROCESS_CLASSNAME)
+            .addStatement("return instanceOfCurrentProfile()")
+            .nextControlFlow("else")
+            .addStatement(
+                "return new $T(new $T(connector, userHandle))",
+                getOtherProfileClassName(generatorContext, crossUserType),
+                USER_CONNECTOR_WRAPPER_CLASSNAME)
+            .endControlFlow()
+            .build());
+  }
+
+  static ClassName getCrossUserTypeInterfaceClassName(
+      GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
+    return transformClassName(crossProfileType.generatedClassName(), prepend("User"));
+  }
+
+  static ClassName getDefaultUserClassName(
+      GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
+    return transformClassName(
+        getCrossUserTypeInterfaceClassName(generatorContext, crossProfileType), prepend("Default"));
+  }
+}
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CurrentProfileGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CurrentProfileGenerator.java
index 103b965..d16bdf8 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CurrentProfileGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CurrentProfileGenerator.java
@@ -15,6 +15,8 @@
  */
 package com.google.android.enterprise.connectedapps.processor;
 
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.append;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CONTEXT_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.EXCEPTION_CALLBACK_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.UNAVAILABLE_PROFILE_EXCEPTION_CLASSNAME;
@@ -25,7 +27,6 @@
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileMethodInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
-import com.squareup.javapoet.AnnotationSpec;
 import com.squareup.javapoet.ClassName;
 import com.squareup.javapoet.CodeBlock;
 import com.squareup.javapoet.FieldSpec;
@@ -98,19 +99,6 @@
       }
     }
 
-    classBuilder.addMethod(
-        MethodSpec.methodBuilder("timeout")
-            .addAnnotation(Override.class)
-            .addAnnotation(
-                AnnotationSpec.builder(SuppressWarnings.class)
-                    .addMember("value", "$S", "GoodTime")
-                    .build())
-            .addModifiers(Modifier.PUBLIC)
-            .returns(className)
-            .addParameter(long.class, "timeout")
-            .addStatement("return this")
-            .build());
-
     ClassName ifAvailableClass =
         IfAvailableGenerator.getIfAvailableClassName(generatorContext, crossProfileType);
 
@@ -213,7 +201,6 @@
 
   static ClassName getCurrentProfileClassName(
       GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
-    return GeneratorUtilities.appendToClassName(
-        crossProfileType.profileClassName(), "_CurrentProfile");
+    return transformClassName(crossProfileType.generatedClassName(), append("_CurrentProfile"));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/DefaultProfileClassGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/DefaultProfileClassGenerator.java
index 114a19c..af10095 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/DefaultProfileClassGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/DefaultProfileClassGenerator.java
@@ -16,6 +16,8 @@
 package com.google.android.enterprise.connectedapps.processor;
 
 import static com.google.android.enterprise.connectedapps.processor.AlwaysThrowsGenerator.getAlwaysThrowsClassName;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.prepend;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PROFILE_AWARE_UTILS_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PROFILE_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PROFILE_CONNECTOR_CLASSNAME;
@@ -30,8 +32,10 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.android.enterprise.connectedapps.annotations.CustomProfileConnector.ProfileType;
+import com.google.android.enterprise.connectedapps.processor.containers.ConnectorInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
+import com.google.android.enterprise.connectedapps.processor.containers.ProfileConnectorInfo;
 import com.squareup.javapoet.ArrayTypeName;
 import com.squareup.javapoet.ClassName;
 import com.squareup.javapoet.FieldSpec;
@@ -40,6 +44,7 @@
 import com.squareup.javapoet.TypeSpec;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Optional;
 import javax.lang.model.element.Modifier;
 
 /**
@@ -56,12 +61,15 @@
   private final GeneratorContext generatorContext;
   private final GeneratorUtilities generatorUtilities;
   private final CrossProfileTypeInfo crossProfileType;
+  private final Optional<ProfileConnectorInfo> profileConnector;
 
   DefaultProfileClassGenerator(
       GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
     this.generatorContext = checkNotNull(generatorContext);
     this.generatorUtilities = new GeneratorUtilities(generatorContext);
     this.crossProfileType = checkNotNull(crossProfileType);
+    this.profileConnector =
+        crossProfileType.connectorInfo().map(ConnectorInfo::profileConnector).map(Optional::get);
   }
 
   void generate() {
@@ -77,11 +85,6 @@
   private void generateDefaultProfileClass() {
     ClassName className = getDefaultProfileClassName(generatorContext, crossProfileType);
 
-    ClassName connectorClassName =
-        crossProfileType.profileConnector().isPresent()
-            ? crossProfileType.profileConnector().get().connectorClassName()
-            : PROFILE_CONNECTOR_CLASSNAME;
-
     ClassName crossProfileTypeInterfaceClassName =
         InterfaceGenerator.getCrossProfileTypeInterfaceClassName(
             generatorContext, crossProfileType);
@@ -96,13 +99,13 @@
     classBuilder.addSuperinterface(crossProfileTypeInterfaceClassName);
 
     classBuilder.addField(
-        FieldSpec.builder(connectorClassName, "connector")
+        FieldSpec.builder(PROFILE_CONNECTOR_CLASSNAME, "connector")
             .addModifiers(Modifier.PRIVATE, Modifier.FINAL)
             .build());
 
     classBuilder.addMethod(
         MethodSpec.constructorBuilder()
-            .addParameter(connectorClassName, "connector")
+            .addParameter(PROFILE_CONNECTOR_CLASSNAME, "connector")
             .addModifiers(Modifier.PUBLIC)
             .addStatement("this.connector = connector")
             .build());
@@ -220,8 +223,8 @@
             .addStatement("return profiles(currentProfileIdentifier, otherProfileIdentifier)")
             .build());
 
-    if (!crossProfileType.profileConnector().isPresent()
-        || crossProfileType.profileConnector().get().primaryProfile() != ProfileType.NONE) {
+    if (!profileConnector.isPresent()
+        || profileConnector.get().primaryProfile() != ProfileType.NONE) {
       generatePrimarySecondaryMethods(classBuilder);
     }
 
@@ -323,8 +326,6 @@
 
   static ClassName getDefaultProfileClassName(
       GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
-    return ClassName.get(
-        crossProfileType.profileClassName().packageName(),
-        "Default" + crossProfileType.profileClassName().simpleName());
+    return transformClassName(crossProfileType.generatedClassName(), prepend("DefaultProfile"));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/DispatcherGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/DispatcherGenerator.java
index f8733d1..8ae6304 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/DispatcherGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/DispatcherGenerator.java
@@ -15,19 +15,23 @@
  */
 package com.google.android.enterprise.connectedapps.processor;
 
-import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BACKGROUND_EXCEPTION_THROWER_CLASSNAME;
-import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BINDER_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.append;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLER_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLE_CALL_RECEIVER_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLE_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLE_UTILITIES_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CONTEXT_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CROSS_PROFILE_CALLBACK_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CROSS_PROFILE_SENDER_CLASSNAME;
-import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PARCEL_CALL_RECEIVER_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.EXCEPTION_THROWER_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PARCEL_CLASSNAME;
-import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PARCEL_UTILITIES_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.ServiceGenerator.getConnectedAppsServiceClassName;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.util.stream.Collectors.joining;
 
 import com.google.android.enterprise.connectedapps.annotations.CrossProfileConfiguration;
+import com.google.android.enterprise.connectedapps.annotations.UncaughtExceptionsPolicy;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileConfigurationInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
 import com.google.android.enterprise.connectedapps.processor.containers.ProviderClassInfo;
@@ -84,17 +88,19 @@
                     + "<p>This uses a {@link $T} to construct calls before passing the completed"
                     + " call\n"
                     + "to a provider.\n",
-                PARCEL_CALL_RECEIVER_CLASSNAME);
+                BUNDLE_CALL_RECEIVER_CLASSNAME);
 
     classBuilder.addField(
-        FieldSpec.builder(PARCEL_CALL_RECEIVER_CLASSNAME, "parcelCallReceiver")
+        FieldSpec.builder(BUNDLE_CALL_RECEIVER_CLASSNAME, "bundleCallReceiver")
             .addModifiers(Modifier.PRIVATE, Modifier.FINAL)
-            .initializer("new $T()", PARCEL_CALL_RECEIVER_CLASSNAME)
+            .initializer("new $T()", BUNDLE_CALL_RECEIVER_CLASSNAME)
             .build());
 
     addCallMethod(classBuilder);
     addPrepareCallMethod(classBuilder);
+    addPrepareBundleMethod(classBuilder);
     addFetchResponseMethod(classBuilder);
+    addFetchResponseBundleMethod(classBuilder);
 
     generatorUtilities.writeClassToFile(className.packageName(), classBuilder);
   }
@@ -108,7 +114,7 @@
             .addParameter(int.class, "blockId")
             .addParameter(int.class, "numBytes")
             .addParameter(ArrayTypeName.of(byte.class), "paramBytes")
-            .addStatement("parcelCallReceiver.prepareCall(callId, blockId, numBytes, paramBytes)")
+            .addStatement("bundleCallReceiver.prepareCall(callId, blockId, numBytes, paramBytes)")
             .addJavadoc(
                 "Store a block of bytes to be part of a future call to\n"
                     + "{@link #call(Context, long, int, long, int, byte[], ICrossProfileCallback)}."
@@ -127,7 +133,31 @@
                     + " $1T#MAX_BYTES_PER_BLOCK} bytes.\n\n"
                     + "@see $2T#prepareCall(long, int, int, byte[])",
                 CROSS_PROFILE_SENDER_CLASSNAME,
-                PARCEL_CALL_RECEIVER_CLASSNAME)
+                BUNDLE_CALL_RECEIVER_CLASSNAME)
+            .build();
+    classBuilder.addMethod(prepareCallMethod);
+  }
+
+  private static void addPrepareBundleMethod(TypeSpec.Builder classBuilder) {
+    MethodSpec prepareCallMethod =
+        MethodSpec.methodBuilder("prepareBundle")
+            .addModifiers(Modifier.PUBLIC)
+            .addParameter(CONTEXT_CLASSNAME, "context")
+            .addParameter(long.class, "callId")
+            .addParameter(int.class, "bundleId")
+            .addParameter(BUNDLE_CLASSNAME, "bundle")
+            .addStatement("bundleCallReceiver.prepareBundle(callId, bundleId, bundle)")
+            .addJavadoc(
+                "Store a bundle to be part of a future call to\n"
+                    + "{@link #call(Context, long, int, long, int, byte[], ICrossProfileCallback)}."
+                    + "\n\n"
+                    + "@param callId Arbitrary identifier used to link together\n"
+                    + "    {@link #prepareCall(Context, long, int, int, byte[])} and\n    "
+                    + "{@link #call(Context, long, int, long, int, byte[], ICrossProfileCallback)}"
+                    + " calls.\n"
+                    + "@param bundleId The (zero indexed) number of this bundle.\n"
+                    + "@see $1T#prepareBundle(long, int, bundle)",
+                BUNDLE_CALL_RECEIVER_CLASSNAME)
             .build();
     classBuilder.addMethod(prepareCallMethod);
   }
@@ -140,7 +170,7 @@
             .addParameter(long.class, "callId")
             .addParameter(int.class, "blockId")
             .returns(ArrayTypeName.of(byte.class))
-            .addStatement("return parcelCallReceiver.getPreparedResponse(callId, blockId)")
+            .addStatement("return bundleCallReceiver.getPreparedResponse(callId, blockId)")
             .addJavadoc(
                 "Fetch a response block if a previous call to\n {@link #call(Context, long, int,"
                     + " long, int, byte[], ICrossProfileCallback)} returned a\n byte array with"
@@ -149,7 +179,29 @@
                     + " long, int, long, int, byte[], ICrossProfileCallback)}\n"
                     + "@param blockId The (zero indexed) number of the block to fetch.\n\n"
                     + "@see $1T#getPreparedResponse(long, int)\n",
-                PARCEL_CALL_RECEIVER_CLASSNAME)
+                BUNDLE_CALL_RECEIVER_CLASSNAME)
+            .build();
+    classBuilder.addMethod(prepareCallMethod);
+  }
+
+  private static void addFetchResponseBundleMethod(TypeSpec.Builder classBuilder) {
+    MethodSpec prepareCallMethod =
+        MethodSpec.methodBuilder("fetchResponseBundle")
+            .addModifiers(Modifier.PUBLIC)
+            .addParameter(CONTEXT_CLASSNAME, "context")
+            .addParameter(long.class, "callId")
+            .addParameter(int.class, "bundleId")
+            .returns(BUNDLE_CLASSNAME)
+            .addStatement("return bundleCallReceiver.getPreparedResponseBundle(callId, bundleId)")
+            .addJavadoc(
+                "Fetch a response bundle if a previous call to\n {@link #call(Context, long, int,"
+                    + " long, int, byte[], ICrossProfileCallback)} returned a\n byte array with"
+                    + " 2 as the first byte.\n\n"
+                    + "@param callId should be the same callId used with\n    {@link #call(Context,"
+                    + " long, int, long, int, byte[], ICrossProfileCallback)}\n"
+                    + "@param blockId The (zero indexed) number of the bundle to fetch.\n\n"
+                    + "@see $1T#getPreparedResponseBundle(long, int)\n",
+                BUNDLE_CALL_RECEIVER_CLASSNAME)
             .build();
     classBuilder.addMethod(prepareCallMethod);
   }
@@ -160,8 +212,8 @@
     methodCode.beginControlFlow("try");
 
     methodCode.addStatement(
-        "$1T parcel = parcelCallReceiver.getPreparedCall(callId, blockId, paramBytes)",
-        PARCEL_CLASSNAME);
+        "$1T bundle = bundleCallReceiver.getPreparedCall(callId, blockId, paramBytes)",
+        BUNDLE_CLASSNAME);
 
     List<ProviderClassInfo> providers = configuration.providers().asList();
 
@@ -174,32 +226,42 @@
         IllegalArgumentException.class);
 
     methodCode.nextControlFlow("catch ($T e)", RuntimeException.class);
-    // parcel is recycled in this method
-    methodCode.addStatement("$1T throwableParcel = $1T.obtain()", PARCEL_CLASSNAME);
-    methodCode.add("throwableParcel.writeInt(1); //errors\n");
     methodCode.addStatement(
-        "$T.writeThrowableToParcel(throwableParcel, e)", PARCEL_UTILITIES_CLASSNAME);
+        "$1T throwableBundle = new $1T($2T.class.getClassLoader())",
+        BUNDLE_CLASSNAME,
+        BUNDLER_CLASSNAME);
     methodCode.addStatement(
-        "$1T throwableBytes = parcelCallReceiver.prepareResponse(callId, throwableParcel)",
+        "$T.writeThrowableToBundle(throwableBundle, $S, e)",
+        BUNDLE_UTILITIES_CLASSNAME,
+        "throwable");
+    methodCode.addStatement(
+        "$1T throwableBytes = bundleCallReceiver.prepareResponse(callId, throwableBundle)",
         ArrayTypeName.of(byte.class));
-    methodCode.addStatement("throwableParcel.recycle()");
 
-    // methodCode.addStatement("$T.throwInBackground(e)", BACKGROUND_EXCEPTION_THROWER_CLASSNAME);
+    final UncaughtExceptionsPolicy exceptionsPolicy =
+        configuration.connectorInfo().uncaughtExceptionsPolicy();
+
+    if (exceptionsPolicy.rethrowExceptions) {
+      methodCode.addStatement("$T.delayThrow(e)", EXCEPTION_THROWER_CLASSNAME);
+    }
 
     methodCode.addStatement("return throwableBytes");
     methodCode.nextControlFlow("catch ($T e)", Error.class);
-
-    // parcel is recycled in this method
-    methodCode.addStatement("$1T throwableParcel = $1T.obtain()", PARCEL_CLASSNAME);
-    methodCode.add("throwableParcel.writeInt(1); //errors\n");
     methodCode.addStatement(
-            "$T.writeThrowableToParcel(throwableParcel, e)", PARCEL_UTILITIES_CLASSNAME);
+        "$1T throwableBundle = new $1T($2T.class.getClassLoader())",
+        BUNDLE_CLASSNAME,
+        BUNDLER_CLASSNAME);
     methodCode.addStatement(
-            "$1T throwableBytes = parcelCallReceiver.prepareResponse(callId, throwableParcel)",
-            ArrayTypeName.of(byte.class));
-    methodCode.addStatement("throwableParcel.recycle()");
+        "$T.writeThrowableToBundle(throwableBundle, $S, e)",
+        BUNDLE_UTILITIES_CLASSNAME,
+        "throwable");
+    methodCode.addStatement(
+        "$1T throwableBytes = bundleCallReceiver.prepareResponse(callId, throwableBundle)",
+        ArrayTypeName.of(byte.class));
 
-    // methodCode.addStatement("$T.throwInBackground(e)", BACKGROUND_EXCEPTION_THROWER_CLASSNAME);
+    if (exceptionsPolicy.rethrowExceptions) {
+      methodCode.addStatement("$T.delayThrow(e)", EXCEPTION_THROWER_CLASSNAME);
+    }
 
     methodCode.addStatement("return throwableBytes");
     methodCode.endControlFlow();
@@ -208,10 +270,11 @@
         MethodSpec.methodBuilder("call")
             .addModifiers(Modifier.PUBLIC)
             .returns(ArrayTypeName.of(byte.class))
-            .addAnnotation(AnnotationSpec.builder(SuppressWarnings.class)
-                // Allow catching of RuntimeException
-                .addMember("value", "\"CatchSpecificExceptionsChecker\"")
-                .build())
+            .addAnnotation(
+                AnnotationSpec.builder(SuppressWarnings.class)
+                    // Allow catching of RuntimeException
+                    .addMember("value", "\"CatchSpecificExceptionsChecker\"")
+                    .build())
             .addParameter(CONTEXT_CLASSNAME, "context")
             .addParameter(long.class, "callId")
             .addParameter(int.class, "blockId")
@@ -222,8 +285,8 @@
             .addCode(methodCode.build())
             .addJavadoc(
                 "Make a call, which will execute some annotated method and return a response.\n\n"
-                    + "<p>The parameters to the call should be contained in a {@link $1T}"
-                    + " marshalled into\n"
+                    + "<p>The parameters to the call should be contained in a {@link $4T} written"
+                    + " to a {@link $1T} and marshalled into\n"
                     + "a byte array. If the byte array is larger than {@link"
                     + " $2T#MAX_BYTES_PER_BLOCK},\n"
                     + "then it should be separated into blocks of {@link"
@@ -245,22 +308,27 @@
                     + "    {@link #call(Context, long, int, long, int, byte[],"
                     + " ICrossProfileCallback)} calls.\n"
                     + "@param blockId The (zero indexed) number of this block. Each block should"
-                    + " be\n    {@link CrossProfileSender#MAX_BYTES_PER_BLOCK} bytes so the total"
-                    + " number of blocks is\n    {@code numBytes /"
-                    + " CrossProfileSender#MAX_BYTES_PER_BLOCK}.\n"
+                    + " be\n"
+                    + "    {@link CrossProfileSender#MAX_BYTES_PER_BLOCK} bytes so the total"
+                    + " number of blocks is\n"
+                    + "    {@code numBytes / CrossProfileSender#MAX_BYTES_PER_BLOCK}.\n"
                     + "@param crossProfileTypeIdentifier The generated identifier for the type"
-                    + " which contains the\n    method being called.\n"
+                    + " which contains the\n"
+                    + "    method being called.\n"
                     + "@param methodIdentifier The index of the method being called on the cross"
                     + " profile type.\n"
                     + "@param paramBytes The bytes for the final block, this will be merged with"
-                    + " any blocks\n    previously set by a call to"
-                    + " {@link #prepareCall(Context, long, int, int, byte[])}.\n"
+                    + " any blocks\n"
+                    + "    previously set by a call to {@link #prepareCall(Context, long, int,"
+                    + " int, byte[])}.\n"
                     + "@param callback A callback to be used if this is an asynchronous call."
-                    + " Otherwise this should be\n    {@code null}.\n\n"
+                    + " Otherwise this should be\n"
+                    + "    {@code null}.\n\n"
                     + "@see $3T#getPreparedCall(long, int, byte[])\n",
                 PARCEL_CLASSNAME,
                 CROSS_PROFILE_SENDER_CLASSNAME,
-                PARCEL_CALL_RECEIVER_CLASSNAME)
+                BUNDLE_CALL_RECEIVER_CLASSNAME,
+                BUNDLE_CLASSNAME)
             .build();
 
     classBuilder.addMethod(callMethod);
@@ -287,22 +355,20 @@
 
     methodCode.beginControlFlow("if ($L)", condition);
     methodCode.addStatement(
-        "$1T returnParcel = $2T.instance().call(context.getApplicationContext(),"
-            + " crossProfileTypeIdentifier, methodIdentifier, parcel, callback)",
-        PARCEL_CLASSNAME,
+        "$1T returnBundle = $2T.instance().call(context.getApplicationContext(),"
+            + " crossProfileTypeIdentifier, methodIdentifier, bundle, callback)",
+        BUNDLE_CLASSNAME,
         InternalProviderClassGenerator.getInternalProviderClassName(generatorContext, provider));
     methodCode.addStatement(
-        "$1T returnBytes = parcelCallReceiver.prepareResponse(callId, returnParcel)",
+        "$1T returnBytes = bundleCallReceiver.prepareResponse(callId, returnBundle)",
         ArrayTypeName.of(byte.class));
-    methodCode.addStatement("parcel.recycle()");
-    methodCode.addStatement("returnParcel.recycle()");
     methodCode.addStatement("return returnBytes");
     methodCode.endControlFlow();
   }
 
   static ClassName getDispatcherClassName(
       GeneratorContext generatorContext, CrossProfileConfigurationInfo configuration) {
-    ClassName serviceName = getConnectedAppsServiceClassName(generatorContext, configuration);
-    return ClassName.get(serviceName.packageName(), serviceName.simpleName() + "_Dispatcher");
+    return transformClassName(
+        getConnectedAppsServiceClassName(generatorContext, configuration), append("_Dispatcher"));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/EarlyValidator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/EarlyValidator.java
index f1f566d..8c84a70 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/EarlyValidator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/EarlyValidator.java
@@ -17,6 +17,10 @@
 
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PARCELABLE_CREATOR_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.GeneratorUtilities.findCrossProfileMethodsInClass;
+import static com.google.android.enterprise.connectedapps.processor.TypeUtils.extractTypeArguments;
+import static com.google.android.enterprise.connectedapps.processor.TypeUtils.getRawTypeClassName;
+import static com.google.android.enterprise.connectedapps.processor.TypeUtils.getRawTypeQualifiedName;
+import static com.google.android.enterprise.connectedapps.processor.TypeUtils.removeTypeArguments;
 import static com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationFinder.hasCrossProfileAnnotation;
 import static com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationFinder.hasCrossProfileConfigurationAnnotation;
 import static com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationFinder.hasCrossProfileConfigurationsAnnotation;
@@ -26,6 +30,7 @@
 import static java.util.stream.Collectors.toMap;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.android.enterprise.connectedapps.annotations.Cacheable;
 import com.google.android.enterprise.connectedapps.annotations.CustomFutureWrapper;
 import com.google.android.enterprise.connectedapps.annotations.CustomParcelableWrapper;
 import com.google.android.enterprise.connectedapps.annotations.CustomProfileConnector;
@@ -48,6 +53,7 @@
 import com.squareup.javapoet.ClassName;
 import com.squareup.javapoet.ParameterizedTypeName;
 import com.squareup.javapoet.TypeName;
+import java.io.Serializable;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashSet;
@@ -100,7 +106,7 @@
       "All classes specified in 'providers' must be provider classes";
   private static final String CONNECTOR_MUST_BE_INTERFACE = "Connectors must be interfaces";
   private static final String CONNECTOR_MUST_EXTEND_CONNECTOR =
-      "Interfaces specified as a connector must extend ProfileConnector";
+      "Interfaces specified as a connector must extend ProfileConnector or UserConnector";
   private static final String CUSTOM_PROFILE_CONNECTOR_MUST_BE_INTERFACE =
       "@CustomProfileConnector must only be applied to interfaces";
   private static final String CUSTOM_USER_CONNECTOR_MUST_BE_INTERFACE =
@@ -149,9 +155,6 @@
       "@CROSS_PROFILE_ANNOTATION annotations on methods can not specify a connector";
   private static final String METHOD_PARCELABLE_WRAPPERS_ERROR =
       "@CROSS_PROFILE_ANNOTATION annotations on methods can not specify parcelable wrappers";
-  private static final String METHOD_CLASSNAME_ERROR =
-      "@CROSS_PROFILE_ANNOTATION annotations on methods can not specify a profile class name";
-  private static final String INVALID_TIMEOUT_MILLIS = "timeoutMillis must be positive";
   private static final String ADDITIONAL_PROFILE_CONNECTOR_METHODS_ERROR =
       "Interfaces annotated with @GeneratedProfileConnector can not declare non-static methods";
   private static final String ADDITIONAL_USER_CONNECTOR_METHODS_ERROR =
@@ -204,6 +207,16 @@
           + " methods annotated @CROSS_PROFILE_ANNOTATION";
   private static final String METHOD_STATICTYPES_ERROR =
       "@CROSS_PROFILE_PROVIDER_ANNOTATION annotations on methods can not specify staticTypes";
+  private static final String CACHEABLE_METHOD_RETURNS_VOID_ERROR =
+      "Methods annotated with @Cacheable must return a non-void type";
+  private static final String CACHEABLE_METHOD_RETURNS_NON_SERIALIZABLE_ERROR =
+      "Methods annotated with @Cacheable must return a type which implements Serializable, return"
+          + " a future with a Serializable result or return void with a simple callback parameter.";
+  private static final String CACHEABLE_METHOD_NON_SIMPLE_CALLBACK_ERROR =
+      "Methods annotated with @Cacheable may only have a callback parameter which is simple.";
+  private static final String CACHEABLE_METHOD_USES_INVALID_PARAMETERS_ERROR =
+      "Methods annotated with @Cacheable may only use callbacks that take a single Serializable"
+          + " parameter.";
 
   private final ValidatorContext validatorContext;
   private final TypeMirror contextType;
@@ -484,7 +497,8 @@
     }
 
     if (configuration.connector().isPresent()
-        && !implementsInterface(configuration.connector().get(), profileConnectorType)) {
+        && !implementsInterface(configuration.connector().get(), profileConnectorType)
+        && !implementsInterface(configuration.connector().get(), userConnectorType)) {
       showError(CONNECTOR_MUST_EXTEND_CONNECTOR, configuration.configurationElement());
       isValid = false;
     }
@@ -539,9 +553,9 @@
       }
     }
 
-    if (crossProfileType.profileConnector().isPresent()
+    if (crossProfileType.connectorInfo().isPresent()
         && !crossProfileType
-            .profileConnector()
+            .connectorInfo()
             .get()
             .connectorElement()
             .getKind()
@@ -550,18 +564,15 @@
       isValid = false;
     }
 
-    if (crossProfileType.profileConnector().isPresent()
+    if (crossProfileType.connectorInfo().isPresent()
         && !implementsInterface(
-            crossProfileType.profileConnector().get().connectorElement(), profileConnectorType)) {
+            crossProfileType.connectorInfo().get().connectorElement(), profileConnectorType)
+        && !implementsInterface(
+            crossProfileType.connectorInfo().get().connectorElement(), userConnectorType)) {
       showError(CONNECTOR_MUST_EXTEND_CONNECTOR, crossProfileType.crossProfileTypeElement());
       isValid = false;
     }
 
-    if (crossProfileType.timeoutMillis() <= 0) {
-      showError(INVALID_TIMEOUT_MILLIS, crossProfileType.crossProfileTypeElement());
-      isValid = false;
-    }
-
     for (TypeElement parcelableWrapper : crossProfileType.parcelableWrapperClasses()) {
       if (parcelableWrapper.getAnnotation(CustomParcelableWrapper.class) == null) {
         showError(PARCELABLE_WRAPPER_ANNOTATION_ERROR, crossProfileType.crossProfileTypeElement());
@@ -652,7 +663,7 @@
 
       CrossProfileProviderAnnotationInfo annotationInfo =
           AnnotationFinder.extractCrossProfileProviderAnnotationInfo(
-              providerMethod, validatorContext.types(), validatorContext.elements());
+              validatorContext, providerMethod);
 
       if (!annotationInfo.staticTypes().isEmpty()) {
         showError(METHOD_STATICTYPES_ERROR, providerMethod);
@@ -718,8 +729,7 @@
     boolean isValid = true;
 
     CrossProfileAnnotationInfo crossProfileAnnotation =
-        AnnotationFinder.extractCrossProfileAnnotationInfo(
-            crossProfileMethod, validatorContext.types(), validatorContext.elements());
+        AnnotationFinder.extractCrossProfileAnnotationInfo(validatorContext, crossProfileMethod);
 
     if (!crossProfileAnnotation.connectorIsDefault()) {
       showError(METHOD_CONNECTOR_ERROR, crossProfileMethod);
@@ -731,21 +741,10 @@
       isValid = false;
     }
 
-    if (!crossProfileAnnotation.isProfileClassNameDefault()) {
-      showError(METHOD_CLASSNAME_ERROR, crossProfileMethod);
-      isValid = false;
-    }
-
-    if (crossProfileAnnotation.timeoutMillis().isPresent()
-        && crossProfileAnnotation.timeoutMillis().get() <= 0) {
-      showError(INVALID_TIMEOUT_MILLIS, crossProfileMethod);
-      isValid = false;
-    }
-
     if (!crossProfileMethod.getThrownTypes().isEmpty()) {
       if (CrossProfileMethodInfo.isFuture(crossProfileType.supportedTypes(), crossProfileMethod)
           || CrossProfileMethodInfo.getCrossProfileCallbackParam(
-                  validatorContext.elements(), crossProfileMethod)
+                  validatorContext, crossProfileMethod)
               .isPresent()) {
         showError(ASYNC_DECLARED_EXCEPTION_ERROR, crossProfileMethod);
         isValid = false;
@@ -761,6 +760,11 @@
         isValid
             && validateReturnType(crossProfileType, crossProfileMethod)
             && validateParameterTypesForCrossProfileMethod(crossProfileType, crossProfileMethod);
+
+    if (crossProfileMethod.getAnnotation(Cacheable.class) != null) {
+      isValid = isValid && validateCacheableMethod(crossProfileType, crossProfileMethod);
+    }
+
     return isValid;
   }
 
@@ -874,7 +878,7 @@
 
     CrossProfileCallbackAnnotationInfo annotationInfo =
         AnnotationFinder.extractCrossProfileCallbackAnnotationInfo(
-            crossProfileCallbackInterface, validatorContext.types(), validatorContext.elements());
+            validatorContext, crossProfileCallbackInterface);
 
     PackageElement packageElement =
         (PackageElement) crossProfileCallbackInterface.getEnclosingElement();
@@ -955,6 +959,89 @@
     return isValid;
   }
 
+  private boolean validateCacheableMethod(
+      ValidatorCrossProfileTypeInfo crossProfileTypeInfo, ExecutableElement cacheableMethod) {
+    boolean isValid = true;
+
+    TypeMirror returnType = cacheableMethod.getReturnType();
+
+    if (returnType.getKind().equals(TypeKind.VOID)) {
+      isValid = isValid && validateCallbackOnCacheableMethod(cacheableMethod);
+    } else if (!isSerializable(crossProfileTypeInfo, returnType)) {
+      showError(CACHEABLE_METHOD_RETURNS_NON_SERIALIZABLE_ERROR, cacheableMethod);
+      isValid = false;
+    }
+
+    return isValid;
+  }
+
+  private boolean isSerializable(
+      ValidatorCrossProfileTypeInfo crossProfileTypeInfo, TypeMirror type) {
+    return isSerializable(type) || isFutureWithSerializableResult(crossProfileTypeInfo, type);
+  }
+
+  private boolean isSerializable(TypeMirror type) {
+    TypeMirror serializable =
+        validatorContext.elements().getTypeElement(Serializable.class.getCanonicalName()).asType();
+
+    return validatorContext.types().isAssignable(type, serializable);
+  }
+
+  private boolean isFutureWithSerializableResult(
+      ValidatorCrossProfileTypeInfo crossProfileType, TypeMirror type) {
+
+    if (!crossProfileType.supportedTypes().isFuture(removeTypeArguments(type))) {
+      return false;
+    }
+
+    TypeMirror futureResult = extractTypeArguments(type).get(0);
+
+    return isSerializable(futureResult);
+  }
+
+  private boolean validateCallbackOnCacheableMethod(ExecutableElement cacheableMethod) {
+    boolean isValid = true;
+
+    if (!hasCallback(cacheableMethod)) {
+      showError(CACHEABLE_METHOD_RETURNS_VOID_ERROR, cacheableMethod);
+      return false;
+    }
+
+    TypeElement callback =
+        cacheableMethod.getParameters().stream()
+            .map(v -> validatorContext.elements().getTypeElement(v.asType().toString()))
+            .filter(Objects::nonNull)
+            .filter(AnnotationFinder::hasCrossProfileCallbackAnnotation)
+            .findFirst()
+            .get();
+
+    CrossProfileCallbackAnnotationInfo annotationInfo =
+        AnnotationFinder.extractCrossProfileCallbackAnnotationInfo(validatorContext, callback);
+    if (!annotationInfo.simple()) {
+      showError(CACHEABLE_METHOD_NON_SIMPLE_CALLBACK_ERROR, cacheableMethod);
+      isValid = false;
+    }
+
+    ExecutableElement method = getMethods(callback).stream().findFirst().get();
+    if (!hasSingleSerializableParameterOnly(method)) {
+      showError(CACHEABLE_METHOD_USES_INVALID_PARAMETERS_ERROR, cacheableMethod);
+      isValid = false;
+    }
+
+    return isValid;
+  }
+
+  private boolean hasCallback(ExecutableElement method) {
+    return method.getParameters().stream()
+        .map(v -> validatorContext.elements().getTypeElement(v.asType().toString()))
+        .filter(Objects::nonNull)
+        .anyMatch(AnnotationFinder::hasCrossProfileCallbackAnnotation);
+  }
+
+  private boolean hasSingleSerializableParameterOnly(ExecutableElement method) {
+    return method.getParameters().stream().filter(p -> isSerializable(p.asType())).count() == 1;
+  }
+
   private boolean validateCrossProfileTests(
       Collection<ValidatorCrossProfileTestInfo> crossProfileTests) {
     return crossProfileTests.stream().allMatch(this::validateCrossProfileTest);
@@ -983,12 +1070,11 @@
       isValid = false;
     }
 
-    ClassName parcelableWrapperRawType =
-        TypeUtils.getRawTypeClassName(customParcelableWrapper.asType());
+    ClassName parcelableWrapperRawType = getRawTypeClassName(customParcelableWrapper.asType());
     ClassName wrappedParamRawType =
-        TypeUtils.getRawTypeClassName(
+        getRawTypeClassName(
             ParcelableWrapperAnnotationInfo.extractFromParcelableWrapperAnnotation(
-                    validatorContext.types(),
+                    validatorContext,
                     customParcelableWrapper.getAnnotation(CustomParcelableWrapper.class))
                 .originalType()
                 .asType());
@@ -1002,8 +1088,8 @@
             // the method is returning the correct generic type
             .filter(
                 p ->
-                    TypeUtils.getRawTypeClassName(p.getReturnType())
-                        .equals(TypeUtils.getRawTypeClassName(customParcelableWrapper.asType())))
+                    getRawTypeClassName(p.getReturnType())
+                        .equals(getRawTypeClassName(customParcelableWrapper.asType())))
             .filter(p -> ofMethodHasExpectedArguments(wrappedParamRawType, p))
             .findFirst();
 
@@ -1019,8 +1105,7 @@
             .filter(p -> p.getSimpleName().contentEquals("get"))
             // We drop generics as without being overly prescriptive it's impossible to know that
             // the method is returning the correct generic type
-            .filter(
-                p -> TypeUtils.getRawTypeClassName(p.getReturnType()).equals(wrappedParamRawType))
+            .filter(p -> getRawTypeClassName(p.getReturnType()).equals(wrappedParamRawType))
             .findFirst();
 
     if (!getMethod.isPresent()) {
@@ -1067,7 +1152,7 @@
       return false;
     }
 
-    if (!TypeUtils.getRawTypeClassName(parameters.get(2).asType()).equals(wrappedParamRawType)) {
+    if (!getRawTypeClassName(parameters.get(2).asType()).equals(wrappedParamRawType)) {
       return false;
     }
 
@@ -1082,14 +1167,13 @@
     boolean isValid = true;
 
     ClassName wrappedFutureRawType =
-        TypeUtils.getRawTypeClassName(
+        getRawTypeClassName(
             FutureWrapperAnnotationInfo.extractFromFutureWrapperAnnotation(
-                    validatorContext.types(),
-                    futureWrapper.getAnnotation(CustomFutureWrapper.class))
+                    validatorContext, futureWrapper.getAnnotation(CustomFutureWrapper.class))
                 .originalType()
                 .asType());
 
-    if (!TypeUtils.getRawTypeQualifiedName(futureWrapper.getSuperclass())
+    if (!getRawTypeQualifiedName(futureWrapper.getSuperclass())
         .equals("com.google.android.enterprise.connectedapps.FutureWrapper")) {
       showError(DOES_NOT_EXTEND_FUTURE_WRAPPER_ERROR, futureWrapper);
       isValid = false;
@@ -1111,8 +1195,8 @@
             // the method is returning the correct generic type
             .filter(
                 e ->
-                    TypeUtils.getRawTypeClassName(e.getReturnType())
-                        .equals(TypeUtils.getRawTypeClassName(futureWrapper.asType())))
+                    getRawTypeClassName(e.getReturnType())
+                        .equals(getRawTypeClassName(futureWrapper.asType())))
             .filter(this::createMethodHasExpectedArguments)
             .findFirst();
 
@@ -1130,8 +1214,7 @@
             .filter(e -> !e.getModifiers().contains(Modifier.STATIC))
             // We drop generics as without being overly prescriptive it's impossible to know that
             // the method is returning the correct generic type
-            .filter(
-                e -> TypeUtils.getRawTypeClassName(e.getReturnType()).equals(wrappedFutureRawType))
+            .filter(e -> getRawTypeClassName(e.getReturnType()).equals(wrappedFutureRawType))
             .filter(e -> e.getParameters().isEmpty())
             .findFirst();
 
@@ -1178,19 +1261,17 @@
   private boolean groupResultsMethodHasExpectedReturnType(
       ExecutableElement groupResultsMethod, ClassName wrappedFutureRawType) {
 
-    if (!TypeUtils.getRawTypeClassName(groupResultsMethod.getReturnType())
-        .equals(wrappedFutureRawType)) {
+    if (!getRawTypeClassName(groupResultsMethod.getReturnType()).equals(wrappedFutureRawType)) {
       return false;
     }
 
-    TypeMirror wrappedReturnType =
-        TypeUtils.extractTypeArguments(groupResultsMethod.getReturnType()).get(0);
+    TypeMirror wrappedReturnType = extractTypeArguments(groupResultsMethod.getReturnType()).get(0);
 
-    if (!TypeUtils.getRawTypeClassName(wrappedReturnType).equals(ClassName.get(Map.class))) {
+    if (!getRawTypeClassName(wrappedReturnType).equals(ClassName.get(Map.class))) {
       return false;
     }
 
-    TypeMirror wrappedReturnTypeKey = TypeUtils.extractTypeArguments(wrappedReturnType).get(0);
+    TypeMirror wrappedReturnTypeKey = extractTypeArguments(wrappedReturnType).get(0);
 
     if (!validatorContext.types().isSameType(wrappedReturnTypeKey, profileType)) {
       return false;
@@ -1207,11 +1288,11 @@
 
     TypeMirror param = groupResultsMethod.getParameters().get(0).asType();
 
-    if (!TypeUtils.getRawTypeClassName(param).equals(ClassName.get(Map.class))) {
+    if (!getRawTypeClassName(param).equals(ClassName.get(Map.class))) {
       return false;
     }
 
-    List<TypeMirror> params = TypeUtils.extractTypeArguments(param);
+    List<TypeMirror> params = extractTypeArguments(param);
 
     TypeMirror keyParam = params.get(0);
     TypeMirror valueParam = params.get(1);
@@ -1220,7 +1301,7 @@
       return false;
     }
 
-    if (!TypeUtils.getRawTypeClassName(valueParam).equals(wrappedFutureRawType)) {
+    if (!getRawTypeClassName(valueParam).equals(wrappedFutureRawType)) {
       return false;
     }
 
@@ -1253,16 +1334,14 @@
       return false;
     }
 
-    if (!TypeUtils.getRawTypeClassName(method.getParameters().get(0).asType())
-        .equals(wrappedFutureRawType)) {
+    if (!getRawTypeClassName(method.getParameters().get(0).asType()).equals(wrappedFutureRawType)) {
       return false;
     }
 
     if (!validatorContext
         .types()
         .isAssignable(
-            TypeUtils.removeTypeArguments(method.getParameters().get(1).asType()),
-            futureResultWriterType)) {
+            removeTypeArguments(method.getParameters().get(1).asType()), futureResultWriterType)) {
       return false;
     }
 
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeCrossProfileTypeGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeCrossProfileTypeGenerator.java
index 0abba95..9a3d441 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeCrossProfileTypeGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeCrossProfileTypeGenerator.java
@@ -15,10 +15,14 @@
  */
 package com.google.android.enterprise.connectedapps.processor;
 
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.getBuilderClassName;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.prepend;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.ABSTRACT_FAKE_PROFILE_CONNECTOR_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PROFILE_AWARE_UTILS_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PROFILE_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CurrentProfileGenerator.getCurrentProfileClassName;
+import static com.google.android.enterprise.connectedapps.processor.InterfaceGenerator.getCrossProfileTypeInterfaceClassName;
 import static com.google.android.enterprise.connectedapps.processor.InterfaceGenerator.getMultipleSenderInterfaceClassName;
 import static com.google.android.enterprise.connectedapps.processor.InterfaceGenerator.getSingleSenderCanThrowInterfaceClassName;
 import static com.google.android.enterprise.connectedapps.processor.InterfaceGenerator.getSingleSenderInterfaceClassName;
@@ -29,6 +33,7 @@
 import com.google.android.enterprise.connectedapps.annotations.CustomProfileConnector.ProfileType;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.squareup.javapoet.ArrayTypeName;
 import com.squareup.javapoet.ClassName;
 import com.squareup.javapoet.CodeBlock;
@@ -45,12 +50,19 @@
   private final CrossProfileTypeInfo crossProfileType;
   private final GeneratorContext generatorContext;
   private final GeneratorUtilities generatorUtilities;
+  private final ClassName fakeProfileConnectorClassName;
 
   public FakeCrossProfileTypeGenerator(
       GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
     this.generatorContext = checkNotNull(generatorContext);
     this.generatorUtilities = new GeneratorUtilities(generatorContext);
     this.crossProfileType = checkNotNull(crossProfileType);
+    this.fakeProfileConnectorClassName =
+        crossProfileType.connectorInfo().isPresent()
+                && crossProfileType.connectorInfo().get().profileConnector().isPresent()
+            ? FakeProfileConnectorGenerator.getFakeProfileConnectorClassName(
+                crossProfileType.connectorInfo().get().profileConnector().get())
+            : ABSTRACT_FAKE_PROFILE_CONNECTOR_CLASSNAME;
   }
 
   void generate() {
@@ -68,13 +80,7 @@
     ClassName builderClassName =
         getFakeCrossProfileTypeBuilderClassName(generatorContext, crossProfileType);
     ClassName crossProfileTypeInterfaceClassName =
-        InterfaceGenerator.getCrossProfileTypeInterfaceClassName(
-            generatorContext, crossProfileType);
-    ClassName fakeProfileConnectorClassName =
-        crossProfileType.profileConnector().isPresent()
-            ? FakeProfileConnectorGenerator.getFakeProfileConnectorClassName(
-                crossProfileType.profileConnector().get())
-            : ABSTRACT_FAKE_PROFILE_CONNECTOR_CLASSNAME;
+        getCrossProfileTypeInterfaceClassName(generatorContext, crossProfileType);
 
     TypeSpec.Builder classBuilder =
         TypeSpec.classBuilder(className)
@@ -188,8 +194,10 @@
             .addStatement("return profiles(currentProfileIdentifier, otherProfileIdentifier)")
             .build());
 
-    if (!crossProfileType.profileConnector().isPresent()
-        || crossProfileType.profileConnector().get().primaryProfile() != ProfileType.NONE) {
+    if (!crossProfileType.connectorInfo().isPresent()
+        || !crossProfileType.connectorInfo().get().profileConnector().isPresent()
+        || crossProfileType.connectorInfo().get().profileConnector().get().primaryProfile()
+            != ProfileType.NONE) {
       generatePrimarySecondaryMethods(classBuilder);
     }
 
@@ -199,12 +207,6 @@
   }
 
   private void addConstructor(TypeSpec.Builder classBuilder) {
-    ClassName fakeProfileConnectorClassName =
-        crossProfileType.profileConnector().isPresent()
-            ? FakeProfileConnectorGenerator.getFakeProfileConnectorClassName(
-                crossProfileType.profileConnector().get())
-            : ABSTRACT_FAKE_PROFILE_CONNECTOR_CLASSNAME;
-
     if (crossProfileType.isStatic()) {
       classBuilder.addMethod(
           MethodSpec.constructorBuilder()
@@ -300,11 +302,6 @@
         getFakeCrossProfileTypeClassName(generatorContext, crossProfileType);
     ClassName builderClassName =
         getFakeCrossProfileTypeBuilderClassName(generatorContext, crossProfileType);
-    ClassName fakeProfileConnectorClassName =
-        crossProfileType.profileConnector().isPresent()
-            ? FakeProfileConnectorGenerator.getFakeProfileConnectorClassName(
-                crossProfileType.profileConnector().get())
-            : ABSTRACT_FAKE_PROFILE_CONNECTOR_CLASSNAME;
 
     TypeSpec.Builder classBuilder =
         TypeSpec.classBuilder(builderClassName)
@@ -327,6 +324,7 @@
             .addJavadoc(
                 "Set the {@link $T} to be used to manage the state of this fake.\n",
                 fakeProfileConnectorClassName)
+            .addAnnotation(CanIgnoreReturnValue.class)
             .addModifiers(Modifier.PUBLIC)
             .returns(builderClassName)
             .addParameter(fakeProfileConnectorClassName, "connector")
@@ -369,6 +367,7 @@
                 "Set the {@link $T} to be used when a call needs to be made to the personal"
                     + " profile.\n",
                 crossProfileType.className())
+            .addAnnotation(CanIgnoreReturnValue.class)
             .addModifiers(Modifier.PUBLIC)
             .returns(builderClassName)
             .addParameter(crossProfileType.className(), "personal")
@@ -386,6 +385,7 @@
             .addJavadoc(
                 "Set the {@link $T} to be used when a call needs to be made to the work profile.\n",
                 crossProfileType.className())
+            .addAnnotation(CanIgnoreReturnValue.class)
             .addModifiers(Modifier.PUBLIC)
             .returns(builderClassName)
             .addParameter(crossProfileType.className(), "work")
@@ -455,23 +455,13 @@
 
   static ClassName getFakeCrossProfileTypeClassName(
       GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
-    ClassName crossProfileTypeClassName =
-        InterfaceGenerator.getCrossProfileTypeInterfaceClassName(
-            generatorContext, crossProfileType);
-    return ClassName.get(
-        crossProfileTypeClassName.packageName(), "Fake" + crossProfileTypeClassName.simpleName());
+    return transformClassName(
+        getCrossProfileTypeInterfaceClassName(generatorContext, crossProfileType), prepend("Fake"));
   }
 
   static ClassName getFakeCrossProfileTypeBuilderClassName(
       GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
-    ClassName crossProfileTypeClassName =
-        InterfaceGenerator.getCrossProfileTypeInterfaceClassName(
-            generatorContext, crossProfileType);
-    return ClassName.get(
-        crossProfileTypeClassName.packageName()
-            + "."
-            + "Fake"
-            + crossProfileTypeClassName.simpleName(),
-        "Builder");
+    return getBuilderClassName(
+        getFakeCrossProfileTypeClassName(generatorContext, crossProfileType));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeCrossUserTypeGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeCrossUserTypeGenerator.java
new file mode 100644
index 0000000..d04c38a
--- /dev/null
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeCrossUserTypeGenerator.java
@@ -0,0 +1,262 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.processor;
+
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.getBuilderClassName;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.prepend;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.ABSTRACT_FAKE_USER_CONNECTOR_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.FAKE_USER_CONNECTOR_WRAPPER_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.USER_HANDLE_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CrossUserTypeCodeGenerator.getCrossUserTypeInterfaceClassName;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
+import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.squareup.javapoet.ClassName;
+import com.squareup.javapoet.FieldSpec;
+import com.squareup.javapoet.MethodSpec;
+import com.squareup.javapoet.ParameterizedTypeName;
+import com.squareup.javapoet.TypeName;
+import com.squareup.javapoet.TypeSpec;
+import java.util.HashMap;
+import java.util.Map;
+import javax.lang.model.element.Modifier;
+
+final class FakeCrossUserTypeGenerator {
+
+  private final GeneratorContext generatorContext;
+  private final GeneratorUtilities generatorUtilities;
+  private final CrossProfileTypeInfo crossUserType;
+
+  private final ClassName fakeUserConnectorClassName;
+  private final TypeName mapType;
+
+  private boolean generated = false;
+
+  public FakeCrossUserTypeGenerator(
+      GeneratorContext generatorContext, CrossProfileTypeInfo crossUserType) {
+    this.generatorContext = checkNotNull(generatorContext);
+    this.generatorUtilities = new GeneratorUtilities(generatorContext);
+    this.crossUserType = checkNotNull(crossUserType);
+    this.fakeUserConnectorClassName =
+        crossUserType.connectorInfo().isPresent()
+                && crossUserType.connectorInfo().get().userConnector().isPresent()
+            ? FakeUserConnectorGenerator.getFakeUserConnectorClassName(
+                crossUserType.connectorInfo().get().userConnector().get())
+            : ABSTRACT_FAKE_USER_CONNECTOR_CLASSNAME;
+    this.mapType =
+        ParameterizedTypeName.get(
+            ClassName.get(Map.class), USER_HANDLE_CLASSNAME, crossUserType.className());
+  }
+
+  void generate() {
+    if (generated) {
+      throw new IllegalStateException(
+          "FakeCrossUserTypeGenerator#generate can only be called once");
+    }
+    generated = true;
+
+    generateFakeCrossUserType();
+  }
+
+  private void generateFakeCrossUserType() {
+    ClassName className = getFakeCrossUserTypeClassName(generatorContext, crossUserType);
+    ClassName builderClassName =
+        getFakeCrossUserTypeBuilderClassName(generatorContext, crossUserType);
+    ClassName crossUserTypeInterfaceClassName =
+        getCrossUserTypeInterfaceClassName(generatorContext, crossUserType);
+
+    TypeSpec.Builder classBuilder =
+        TypeSpec.classBuilder(className)
+            .addJavadoc(
+                "Fake implementation of {@link $T} for use during tests.\n\n"
+                    + "<p>This should be injected into your code under test and the {@link $T}\n"
+                    + "used to control the fake state. Calls will be routed to the correct {@link"
+                    + " $T}.\n",
+                crossUserTypeInterfaceClassName,
+                fakeUserConnectorClassName,
+                crossUserType.className())
+            .addModifiers(Modifier.PUBLIC, Modifier.FINAL)
+            .addSuperinterface(crossUserTypeInterfaceClassName);
+
+    generateConstructor(classBuilder);
+    generateInterfaceMethods(classBuilder);
+    generateBuilder(classBuilder, className, builderClassName);
+
+    generatorUtilities.writeClassToFile(className.packageName(), classBuilder);
+  }
+
+  private void generateConstructor(TypeSpec.Builder classBuilder) {
+    classBuilder.addField(
+        FieldSpec.builder(fakeUserConnectorClassName, "connector")
+            .addModifiers(Modifier.PRIVATE, Modifier.FINAL)
+            .build());
+    classBuilder.addField(
+        FieldSpec.builder(mapType, "targetTypeForUserHandle")
+            .addModifiers(Modifier.PRIVATE, Modifier.FINAL)
+            .build());
+    classBuilder.addMethod(
+        MethodSpec.constructorBuilder()
+            .addModifiers(Modifier.PRIVATE)
+            .addParameter(fakeUserConnectorClassName, "connector")
+            .addParameter(mapType, "targetTypeForUserHandle")
+            .addStatement("this.connector = connector")
+            .addStatement("this.targetTypeForUserHandle = targetTypeForUserHandle")
+            .build());
+  }
+
+  private void generateInterfaceMethods(TypeSpec.Builder classBuilder) {
+    generateCurrentMethod(classBuilder);
+    generateSpecificUserMethod(classBuilder);
+    generateTargetTypeGetter(classBuilder);
+  }
+
+  private void generateTargetTypeGetter(TypeSpec.Builder classBuilder) {
+    classBuilder.addMethod(
+        MethodSpec.methodBuilder("getTargetType")
+            .addModifiers(Modifier.PRIVATE)
+            .addParameter(USER_HANDLE_CLASSNAME, "userHandle")
+            .returns(crossUserType.className())
+            .beginControlFlow("if (userHandle == null)")
+            .addStatement("throw new $T(\"Null user handle.\")", IllegalArgumentException.class)
+            .nextControlFlow("else if (!targetTypeForUserHandle.containsKey(userHandle))")
+            .addStatement(
+                "throw new $T(\"No $L type specified for target user handle.\")",
+                UnsupportedOperationException.class,
+                crossUserType.generatedClassName().simpleName())
+            .endControlFlow()
+            .addStatement("return targetTypeForUserHandle.get(userHandle)")
+            .build());
+  }
+
+  private void generateCurrentMethod(TypeSpec.Builder classBuilder) {
+    classBuilder.addMethod(
+        MethodSpec.methodBuilder("current")
+            .addAnnotation(Override.class)
+            .addModifiers(Modifier.PUBLIC)
+            .returns(
+                InterfaceGenerator.getSingleSenderInterfaceClassName(
+                    generatorContext, crossUserType))
+            .beginControlFlow("if (connector.runningOnUser() == null)")
+            .addStatement(
+                "throw new $T(\"Current user not specified - you must call setRunningOnUser on your"
+                    + " connector.\")",
+                UnsupportedOperationException.class)
+            .endControlFlow()
+            .addStatement("$T userHandle = connector.runningOnUser()", USER_HANDLE_CLASSNAME)
+            .addStatement(
+                "return new $T(connector.applicationContext(userHandle),"
+                    + " getTargetType(userHandle))",
+                CurrentProfileGenerator.getCurrentProfileClassName(generatorContext, crossUserType))
+            .build());
+  }
+
+  private void generateSpecificUserMethod(TypeSpec.Builder classBuilder) {
+    classBuilder.addMethod(
+        MethodSpec.methodBuilder("user")
+            .addAnnotation(Override.class)
+            .addModifiers(Modifier.PUBLIC)
+            .addParameter(USER_HANDLE_CLASSNAME, "userHandle")
+            .returns(
+                InterfaceGenerator.getSingleSenderCanThrowInterfaceClassName(
+                    generatorContext, crossUserType))
+            .beginControlFlow("if (connector.runningOnUser() == userHandle)")
+            .addStatement(
+                "return new $T(connector.applicationContext(userHandle),"
+                    + " getTargetType(userHandle))",
+                CurrentProfileGenerator.getCurrentProfileClassName(generatorContext, crossUserType))
+            .endControlFlow()
+            .addStatement(
+                "return new $T(new $T(connector, userHandle), getTargetType(userHandle))",
+                FakeOtherGenerator.getFakeOtherClassName(generatorContext, crossUserType),
+                FAKE_USER_CONNECTOR_WRAPPER_CLASSNAME)
+            .build());
+  }
+
+  private void generateBuilder(
+      TypeSpec.Builder classBuilder, ClassName className, ClassName builderClassName) {
+    classBuilder.addMethod(
+        MethodSpec.methodBuilder("builder")
+            .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
+            .returns(builderClassName)
+            .addStatement("return new $T()", builderClassName)
+            .build());
+
+    String targetTypeName = "target" + crossUserType.className().simpleName();
+    classBuilder.addType(
+        TypeSpec.classBuilder("Builder")
+            .addModifiers(Modifier.PUBLIC, Modifier.STATIC, Modifier.FINAL)
+            .addField(
+                FieldSpec.builder(mapType, "targetTypeForUserHandle")
+                    .addModifiers(Modifier.PRIVATE, Modifier.FINAL)
+                    .initializer(
+                        "new $T()",
+                        ParameterizedTypeName.get(
+                            ClassName.get(HashMap.class),
+                            USER_HANDLE_CLASSNAME,
+                            crossUserType.generatedClassName()))
+                    .build())
+            .addField(
+                FieldSpec.builder(fakeUserConnectorClassName, "connector")
+                    .addModifiers(Modifier.PRIVATE)
+                    .build())
+            .addMethod(
+                MethodSpec.methodBuilder("user")
+                    .addAnnotation(CanIgnoreReturnValue.class)
+                    .addModifiers(Modifier.PUBLIC)
+                    .addParameter(USER_HANDLE_CLASSNAME, "userHandle")
+                    .addParameter(crossUserType.className(), targetTypeName)
+                    .returns(builderClassName)
+                    .addStatement("targetTypeForUserHandle.put(userHandle, $L)", targetTypeName)
+                    .addStatement("return this")
+                    .build())
+            .addMethod(
+                MethodSpec.methodBuilder("connector")
+                    .addAnnotation(CanIgnoreReturnValue.class)
+                    .addModifiers(Modifier.PUBLIC)
+                    .addParameter(fakeUserConnectorClassName, "connector")
+                    .returns(builderClassName)
+                    .addStatement("this.connector = connector")
+                    .addStatement("return this")
+                    .build())
+            .addMethod(
+                MethodSpec.methodBuilder("build")
+                    .addModifiers(Modifier.PUBLIC)
+                    .returns(className)
+                    .beginControlFlow("if (connector == null)")
+                    .addStatement(
+                        "throw new $T(\"Cannot build $L with no connector specified.\")",
+                        IllegalStateException.class,
+                        className.simpleName())
+                    .endControlFlow()
+                    .addStatement("return new $T(connector, targetTypeForUserHandle)", className)
+                    .build())
+            .build());
+  }
+
+  static ClassName getFakeCrossUserTypeClassName(
+      GeneratorContext generatorContext, CrossProfileTypeInfo crossUserType) {
+    return transformClassName(
+        getCrossUserTypeInterfaceClassName(generatorContext, crossUserType), prepend("Fake"));
+  }
+
+  static ClassName getFakeCrossUserTypeBuilderClassName(
+      GeneratorContext generatorContext, CrossProfileTypeInfo crossUserType) {
+    return getBuilderClassName(getFakeCrossUserTypeClassName(generatorContext, crossUserType));
+  }
+}
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeOtherGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeOtherGenerator.java
index 53e5c72..9726689 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeOtherGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeOtherGenerator.java
@@ -15,9 +15,11 @@
  */
 package com.google.android.enterprise.connectedapps.processor;
 
-import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.ABSTRACT_FAKE_PROFILE_CONNECTOR_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.append;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CONTEXT_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.EXCEPTION_CALLBACK_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.FAKE_PROFILE_CONNECTOR_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PROFILE_RUNTIME_EXCEPTION_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.UNAVAILABLE_PROFILE_EXCEPTION_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.containers.CrossProfileMethodInfo.AutomaticallyResolvedParameterFilterBehaviour.REMOVE_AUTOMATICALLY_RESOLVED_PARAMETERS;
@@ -28,7 +30,6 @@
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.FutureWrapper;
 import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
-import com.squareup.javapoet.AnnotationSpec;
 import com.squareup.javapoet.ClassName;
 import com.squareup.javapoet.CodeBlock;
 import com.squareup.javapoet.MethodSpec;
@@ -73,12 +74,6 @@
         InterfaceGenerator.getSingleSenderCanThrowInterfaceClassName(
             generatorContext, crossProfileType);
 
-    ClassName fakeProfileConnectorClassName =
-        crossProfileType.profileConnector().isPresent()
-            ? FakeProfileConnectorGenerator.getFakeProfileConnectorClassName(
-                crossProfileType.profileConnector().get())
-            : ABSTRACT_FAKE_PROFILE_CONNECTOR_CLASSNAME;
-
     TypeSpec.Builder classBuilder =
         TypeSpec.classBuilder(className)
             .addJavadoc(
@@ -86,28 +81,15 @@
                     + "<p>This acts based on the state of the passed in {@link $T} and acts as if"
                     + " making a call on the other profile.\n",
                 singleSenderCanThrowInterface,
-                fakeProfileConnectorClassName)
+                FAKE_PROFILE_CONNECTOR_CLASSNAME)
             .addModifiers(Modifier.PUBLIC, Modifier.FINAL)
             .addSuperinterface(singleSenderCanThrowInterface);
 
     classBuilder.addField(
-        fakeProfileConnectorClassName, "connector", Modifier.PRIVATE, Modifier.FINAL);
+        FAKE_PROFILE_CONNECTOR_CLASSNAME, "connector", Modifier.PRIVATE, Modifier.FINAL);
 
     addConstructor(classBuilder);
 
-    classBuilder.addMethod(
-        MethodSpec.methodBuilder("timeout")
-            .addAnnotation(Override.class)
-            .addAnnotation(
-                AnnotationSpec.builder(SuppressWarnings.class)
-                    .addMember("value", "$S", "GoodTime")
-                    .build())
-            .addModifiers(Modifier.PUBLIC)
-            .returns(className)
-            .addParameter(long.class, "timeout")
-            .addStatement("return this")
-            .build());
-
     ClassName ifAvailableClass =
         IfAvailableGenerator.getIfAvailableClassName(generatorContext, crossProfileType);
 
@@ -135,19 +117,13 @@
   }
 
   private void addConstructor(TypeSpec.Builder classBuilder) {
-    ClassName fakeProfileConnectorClassName =
-        crossProfileType.profileConnector().isPresent()
-            ? FakeProfileConnectorGenerator.getFakeProfileConnectorClassName(
-                crossProfileType.profileConnector().get())
-            : ABSTRACT_FAKE_PROFILE_CONNECTOR_CLASSNAME;
-
     classBuilder.addField(CONTEXT_CLASSNAME, "context", Modifier.PRIVATE, Modifier.FINAL);
 
     if (crossProfileType.isStatic()) {
       classBuilder.addMethod(
           MethodSpec.constructorBuilder()
               .addModifiers(Modifier.PUBLIC)
-              .addParameter(fakeProfileConnectorClassName, "connector")
+              .addParameter(FAKE_PROFILE_CONNECTOR_CLASSNAME, "connector")
               .addStatement("this.context = connector.applicationContext()")
               .addStatement("this.connector = connector")
               .build());
@@ -158,7 +134,7 @@
       classBuilder.addMethod(
           MethodSpec.constructorBuilder()
               .addModifiers(Modifier.PUBLIC)
-              .addParameter(fakeProfileConnectorClassName, "connector")
+              .addParameter(FAKE_PROFILE_CONNECTOR_CLASSNAME, "connector")
               .addParameter(crossProfileType.className(), "crossProfileType")
               .addStatement("this.context = connector.applicationContext()")
               .addStatement("this.connector = connector")
@@ -204,7 +180,7 @@
         "Could not access other profile");
     methodBuilder.endControlFlow();
 
-    methodBuilder.beginControlFlow("if (!connector.isManuallyManagingConnection())");
+    methodBuilder.beginControlFlow("if (!connector.hasExplicitConnectionHolders())");
     methodBuilder.addStatement(
         "throw new $T($S)",
         UNAVAILABLE_PROFILE_EXCEPTION_CLASSNAME,
@@ -345,6 +321,6 @@
 
   static ClassName getFakeOtherClassName(
       GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
-    return GeneratorUtilities.appendToClassName(crossProfileType.profileClassName(), "_FakeOther");
+    return transformClassName(crossProfileType.generatedClassName(), append("_FakeOther"));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeProfileConnectorGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeProfileConnectorGenerator.java
index 5ff51ea..0a41745 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeProfileConnectorGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeProfileConnectorGenerator.java
@@ -15,6 +15,8 @@
  */
 package com.google.android.enterprise.connectedapps.processor;
 
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.prepend;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.ABSTRACT_FAKE_PROFILE_CONNECTOR_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CONTEXT_CLASSNAME;
 import static com.google.common.base.Preconditions.checkNotNull;
@@ -29,19 +31,19 @@
 
 class FakeProfileConnectorGenerator {
   private boolean generated = false;
-  private final ProfileConnectorInfo connector;
   private final GeneratorUtilities generatorUtilities;
+  private final ProfileConnectorInfo profileConnector;
 
   public FakeProfileConnectorGenerator(
       GeneratorContext generatorContext, ProfileConnectorInfo connector) {
     this.generatorUtilities = new GeneratorUtilities(checkNotNull(generatorContext));
-    this.connector = checkNotNull(connector);
+    this.profileConnector = checkNotNull(connector);
   }
 
   void generate() {
     if (generated) {
       throw new IllegalStateException(
-          "FakeProfileConectorGenerator#generate can only be called once");
+          "FakeProfileConnectorGenerator#generate can only be called once");
     }
     generated = true;
 
@@ -49,7 +51,7 @@
   }
 
   private void generateFakeProfileConnector() {
-    ClassName className = getFakeProfileConnectorClassName(connector);
+    ClassName className = getFakeProfileConnectorClassName(profileConnector);
 
     TypeSpec.Builder classBuilder =
         TypeSpec.classBuilder(className)
@@ -57,13 +59,13 @@
                 "Fake Profile Connector for {@link $1T}.\n\n"
                     + "<p>All functionality is implemented by {@link $2T}, this class is just used"
                     + " for compatibility with the {@link $1T} interface.\n",
-                connector.connectorClassName(),
+                profileConnector.connectorClassName(),
                 ABSTRACT_FAKE_PROFILE_CONNECTOR_CLASSNAME)
             .addModifiers(Modifier.PUBLIC, Modifier.FINAL)
-            .addSuperinterface(connector.connectorClassName())
+            .addSuperinterface(profileConnector.connectorClassName())
             .superclass(ABSTRACT_FAKE_PROFILE_CONNECTOR_CLASSNAME);
 
-    if (connector.primaryProfile().equals(ProfileType.UNKNOWN)) {
+    if (profileConnector.primaryProfile().equals(ProfileType.UNKNOWN)) {
       // Special case - we need to provide the profile type to the fake.
       classBuilder.addMethod(
           MethodSpec.constructorBuilder()
@@ -85,16 +87,16 @@
               .addModifiers(Modifier.PUBLIC)
               .addParameter(CONTEXT_CLASSNAME, "context")
               .addStatement(
-                  "super(context, $T.$L)", ProfileType.class, connector.primaryProfile().name())
+                  "super(context, $T.$L)",
+                  ProfileType.class,
+                  profileConnector.primaryProfile().name())
               .build());
     }
 
     generatorUtilities.writeClassToFile(className.packageName(), classBuilder);
   }
 
-  static ClassName getFakeProfileConnectorClassName(ProfileConnectorInfo connector) {
-    return ClassName.get(
-        connector.connectorClassName().packageName(),
-        "Fake" + connector.connectorClassName().simpleName());
+  static ClassName getFakeProfileConnectorClassName(ProfileConnectorInfo profileConnector) {
+    return transformClassName(profileConnector.connectorClassName(), prepend("Fake"));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeUserConnectorGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeUserConnectorGenerator.java
new file mode 100644
index 0000000..3d99434
--- /dev/null
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/FakeUserConnectorGenerator.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.processor;
+
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.prepend;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.ABSTRACT_FAKE_USER_CONNECTOR_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CONTEXT_CLASSNAME;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
+import com.google.android.enterprise.connectedapps.processor.containers.UserConnectorInfo;
+import com.squareup.javapoet.ClassName;
+import com.squareup.javapoet.MethodSpec;
+import com.squareup.javapoet.TypeSpec;
+import javax.lang.model.element.Modifier;
+
+class FakeUserConnectorGenerator {
+
+  private final GeneratorUtilities generatorUtilities;
+  private final UserConnectorInfo userConnector;
+
+  private boolean generated = false;
+
+  public FakeUserConnectorGenerator(
+      GeneratorContext generatorContext, UserConnectorInfo connector) {
+    this.generatorUtilities = new GeneratorUtilities(checkNotNull(generatorContext));
+    this.userConnector = checkNotNull(connector);
+  }
+
+  void generate() {
+    if (generated) {
+      throw new IllegalStateException(
+          "FakeUserConnectorGenerator#generate can only be called once");
+    }
+    generated = true;
+
+    generateFakeUserConnector();
+  }
+
+  private void generateFakeUserConnector() {
+    ClassName className = getFakeUserConnectorClassName(userConnector);
+
+    TypeSpec.Builder classBuilder =
+        TypeSpec.classBuilder(className)
+            .addJavadoc(
+                "Fake User Connector for {@link $1T}.\n\n"
+                    + "<p>All functionality is implemented by {@link $2T}, this class is just used"
+                    + " for compatibility with the {@link $1T} interface.\n",
+                userConnector.connectorClassName(),
+                ABSTRACT_FAKE_USER_CONNECTOR_CLASSNAME)
+            .addModifiers(Modifier.PUBLIC, Modifier.FINAL)
+            .addSuperinterface(userConnector.connectorClassName())
+            .superclass(ABSTRACT_FAKE_USER_CONNECTOR_CLASSNAME);
+
+    classBuilder.addMethod(
+        MethodSpec.constructorBuilder()
+            .addModifiers(Modifier.PUBLIC)
+            .addParameter(CONTEXT_CLASSNAME, "context")
+            .addStatement("super(context)")
+            .build());
+
+    generatorUtilities.writeClassToFile(className.packageName(), classBuilder);
+  }
+
+  static ClassName getFakeUserConnectorClassName(UserConnectorInfo userConnector) {
+    return transformClassName(userConnector.connectorClassName(), prepend("Fake"));
+  }
+}
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/GeneratorUtilities.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/GeneratorUtilities.java
index 7d4dbd7..9c600c8 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/GeneratorUtilities.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/GeneratorUtilities.java
@@ -22,13 +22,13 @@
 import static com.google.android.enterprise.connectedapps.processor.containers.CrossProfileMethodInfo.AutomaticallyResolvedParameterFilterBehaviour.REPLACE_AUTOMATICALLY_RESOLVED_PARAMETERS;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
 
 import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationFinder;
 import com.google.android.enterprise.connectedapps.processor.containers.Context;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileMethodInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileMethodInfo.AutomaticallyResolvedParameterFilterBehaviour;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.squareup.javapoet.AnnotationSpec;
 import com.squareup.javapoet.ArrayTypeName;
@@ -44,7 +44,6 @@
 import java.io.IOException;
 import java.io.PrintWriter;
 import java.util.List;
-import java.util.Set;
 import javax.lang.model.element.Element;
 import javax.lang.model.element.ElementKind;
 import javax.lang.model.element.ExecutableElement;
@@ -116,22 +115,27 @@
     throw new AssertionError("Could not extract classes from annotation");
   }
 
-  public static Set<ExecutableElement> findCrossProfileMethodsInClass(TypeElement clazz) {
-    return clazz.getEnclosedElements().stream()
+  public static ImmutableSet<ExecutableElement> findCrossProfileMethodsInClass(TypeElement clazz) {
+    ImmutableSet.Builder<ExecutableElement> result = ImmutableSet.builder();
+    clazz.getEnclosedElements().stream()
         .filter(e -> e instanceof ExecutableElement)
         .map(e -> (ExecutableElement) e)
         .filter(e -> e.getKind() == ElementKind.METHOD)
         .filter(AnnotationFinder::hasCrossProfileAnnotation)
-        .collect(toSet());
+        .forEach(result::add);
+    return result.build();
   }
 
-  public static Set<ExecutableElement> findCrossProfileProviderMethodsInClass(TypeElement clazz) {
-    return clazz.getEnclosedElements().stream()
+  public static ImmutableSet<ExecutableElement> findCrossProfileProviderMethodsInClass(
+      TypeElement clazz) {
+    ImmutableSet.Builder<ExecutableElement> result = ImmutableSet.builder();
+    clazz.getEnclosedElements().stream()
         .filter(e -> e instanceof ExecutableElement)
         .map(e -> (ExecutableElement) e)
         .filter(e -> e.getKind() == ElementKind.METHOD)
         .filter(AnnotationFinder::hasCrossProfileProviderAnnotation)
-        .collect(toSet());
+        .forEach(result::add);
+    return result.build();
   }
 
   /** Generate a {@code @link} reference to a given method. */
@@ -292,8 +296,4 @@
   private static List<TypeMirror> convertParametersToTypes(CrossProfileMethodInfo method) {
     return method.methodElement().getParameters().stream().map(Element::asType).collect(toList());
   }
-
-  static ClassName appendToClassName(ClassName originalClassName, String suffix) {
-    return ClassName.get(originalClassName.packageName(), originalClassName.simpleName() + suffix);
-  }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/IfAvailableGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/IfAvailableGenerator.java
index 68d84f4..d3b4270 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/IfAvailableGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/IfAvailableGenerator.java
@@ -15,6 +15,8 @@
  */
 package com.google.android.enterprise.connectedapps.processor;
 
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.append;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.IF_AVAILABLE_FUTURE_RESULT_WRITER;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.UNAVAILABLE_PROFILE_EXCEPTION_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.GeneratorUtilities.generateMethodReference;
@@ -22,6 +24,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileCallbackInterfaceInfo;
+import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileCallbackParameterInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileMethodInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.FutureWrapper;
@@ -31,7 +34,6 @@
 import com.squareup.javapoet.MethodSpec;
 import com.squareup.javapoet.TypeSpec;
 import javax.lang.model.element.Modifier;
-import javax.lang.model.element.TypeElement;
 import javax.lang.model.type.TypeKind;
 import javax.lang.model.type.TypeMirror;
 
@@ -149,13 +151,10 @@
         return;
       }
 
+      CrossProfileCallbackParameterInfo callbackParam =
+          method.getCrossProfileCallbackParam(generatorContext).get();
       CrossProfileCallbackInterfaceInfo callbackInterface =
-          CrossProfileCallbackInterfaceInfo.create(
-              (TypeElement)
-                  generatorContext
-                      .types()
-                      .asElement(
-                          method.getCrossProfileCallbackParam(generatorContext).get().asType()));
+          callbackParam.crossProfileCallbackInterface();
       if (callbackInterface.argumentTypes().isEmpty()) {
         // Void
         // This assumes a single callback method
@@ -167,7 +166,7 @@
                 method.simpleName(),
                 method.commaSeparatedParameters(
                     crossProfileType.supportedTypes(), REMOVE_AUTOMATICALLY_RESOLVED_PARAMETERS),
-                method.getCrossProfileCallbackParam(generatorContext).get().getSimpleName(),
+                callbackParam.getSimpleName(),
                 callbackInterface.methods().get(0).getSimpleName());
       } else {
         // This assumes a single callback method
@@ -226,7 +225,6 @@
 
   static ClassName getIfAvailableClassName(
       GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
-    return GeneratorUtilities.appendToClassName(
-        crossProfileType.profileClassName(), "_IfAvailable");
+    return transformClassName(crossProfileType.generatedClassName(), append("_IfAvailable"));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InterfaceGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InterfaceGenerator.java
index a8c76c6..a8d94c1 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InterfaceGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InterfaceGenerator.java
@@ -15,9 +15,11 @@
  */
 package com.google.android.enterprise.connectedapps.processor;
 
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.append;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.prepend;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.EXCEPTION_CALLBACK_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PROFILE_CLASSNAME;
-import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PROFILE_CONNECTOR_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PROFILE_RUNTIME_EXCEPTION_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.UNAVAILABLE_PROFILE_EXCEPTION_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.GeneratorUtilities.generateMethodReference;
@@ -26,15 +28,11 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.android.enterprise.connectedapps.annotations.CrossProfile;
-import com.google.android.enterprise.connectedapps.annotations.CustomProfileConnector;
-import com.google.android.enterprise.connectedapps.annotations.CustomProfileConnector.ProfileType;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileCallbackInterfaceInfo;
+import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileCallbackParameterInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileMethodInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
-import com.google.common.base.Ascii;
-import com.squareup.javapoet.AnnotationSpec;
-import com.squareup.javapoet.ArrayTypeName;
 import com.squareup.javapoet.ClassName;
 import com.squareup.javapoet.CodeBlock;
 import com.squareup.javapoet.MethodSpec;
@@ -45,8 +43,6 @@
 import java.util.List;
 import java.util.Map;
 import javax.lang.model.element.Modifier;
-import javax.lang.model.element.TypeElement;
-import javax.lang.model.element.VariableElement;
 import javax.lang.model.type.TypeKind;
 import javax.lang.model.type.TypeMirror;
 
@@ -70,204 +66,11 @@
     }
     generated = true;
 
-    generateCrossProfileTypeInterface();
     generateSingleSenderInterface();
     generateSingleSenderCanThrowInterface();
     generateMultipleSenderInterface();
   }
 
-  private void generateCrossProfileTypeInterface() {
-    ClassName interfaceName =
-        getCrossProfileTypeInterfaceClassName(generatorContext, crossProfileType);
-
-    TypeSpec.Builder interfaceBuilder =
-        TypeSpec.interfaceBuilder(interfaceName)
-            .addJavadoc(
-                "Entry point for cross-profile calls to {@link $T}.\n",
-                crossProfileType.className())
-            .addModifiers(Modifier.PUBLIC);
-
-    ClassName connectorClassName =
-        crossProfileType.profileConnector().isPresent()
-            ? crossProfileType.profileConnector().get().connectorClassName()
-            : PROFILE_CONNECTOR_CLASSNAME;
-
-    interfaceBuilder.addMethod(
-        MethodSpec.methodBuilder("create")
-            .returns(interfaceName)
-            .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
-            .addParameter(connectorClassName, "connector")
-            .addStatement(
-                "return new $T(connector)",
-                DefaultProfileClassGenerator.getDefaultProfileClassName(
-                    generatorContext, crossProfileType))
-            .build());
-
-    interfaceBuilder.addMethod(
-        MethodSpec.methodBuilder("current")
-            .addJavadoc("Run a method on the current profile.\n")
-            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
-            .returns(getSingleSenderInterfaceClassName(generatorContext, crossProfileType))
-            .build());
-
-    interfaceBuilder.addMethod(
-        MethodSpec.methodBuilder("other")
-            .addJavadoc("Run a method on the other profile, if accessible.\n")
-            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
-            .returns(getSingleSenderCanThrowInterfaceClassName(generatorContext, crossProfileType))
-            .build());
-
-    interfaceBuilder.addMethod(
-        MethodSpec.methodBuilder("personal")
-            .addJavadoc("Run a method on the personal profile, if accessible.\n")
-            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
-            .returns(getSingleSenderCanThrowInterfaceClassName(generatorContext, crossProfileType))
-            .build());
-
-    interfaceBuilder.addMethod(
-        MethodSpec.methodBuilder("work")
-            .addJavadoc("Run a method on the work profile, if accessible.\n")
-            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
-            .returns(getSingleSenderCanThrowInterfaceClassName(generatorContext, crossProfileType))
-            .build());
-
-    interfaceBuilder.addMethod(
-        MethodSpec.methodBuilder("profile")
-            .addJavadoc("Run a method on the given profile, if accessible.\n")
-            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
-            .addParameter(PROFILE_CLASSNAME, "profile")
-            .returns(getSingleSenderCanThrowInterfaceClassName(generatorContext, crossProfileType))
-            .build());
-
-    interfaceBuilder.addMethod(
-        MethodSpec.methodBuilder("profiles")
-            .addJavadoc(
-                CodeBlock.builder()
-                    .add("Run a method on the given profiles, if accessible.\n\n")
-                    .add(
-                        "<p>This will deduplicate profiles to ensure that the method is only run"
-                            + " at most once on each profile.\n")
-                    .build())
-            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
-            .addParameter(ArrayTypeName.of(PROFILE_CLASSNAME), "profiles")
-            .varargs(true)
-            .returns(getMultipleSenderInterfaceClassName(generatorContext, crossProfileType))
-            .build());
-
-    interfaceBuilder.addMethod(
-        MethodSpec.methodBuilder("both")
-            .addJavadoc("Run a method on both the personal and work profile, if accessible.\n")
-            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
-            .returns(getMultipleSenderInterfaceClassName(generatorContext, crossProfileType))
-            .build());
-
-    if (!crossProfileType.profileConnector().isPresent()
-        || crossProfileType.profileConnector().get().primaryProfile() != ProfileType.NONE) {
-      generatePrimarySecondaryMethods(interfaceBuilder);
-    }
-
-    generatorUtilities.writeClassToFile(interfaceName.packageName(), interfaceBuilder);
-  }
-
-  private void generatePrimarySecondaryMethods(TypeSpec.Builder interfaceBuilder) {
-    generatePrimaryMethod(interfaceBuilder);
-    generateSecondaryMethod(interfaceBuilder);
-    generateSuppliersMethod(interfaceBuilder);
-  }
-
-  private void generatePrimaryMethod(TypeSpec.Builder interfaceBuilder) {
-    MethodSpec.Builder methodBuilder =
-        MethodSpec.methodBuilder("primary")
-            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
-            .returns(getSingleSenderCanThrowInterfaceClassName(generatorContext, crossProfileType));
-
-    if (crossProfileType.profileConnector().isPresent()) {
-      methodBuilder.addJavadoc(
-          "Run a method on the primary ("
-              + Ascii.toLowerCase(crossProfileType.profileConnector().get().primaryProfile().name())
-              + ") profile, if accessible.\n\n@see $T#primaryProfile()\n",
-          CustomProfileConnector.class);
-    } else {
-      methodBuilder.addJavadoc(
-          "Run a method on the primary profile, if accessible.\n\n"
-              + "@throws $1T if the {@link $2T} does not have a primary profile set\n"
-              + "@see $2T#primaryProfile()\n",
-          IllegalStateException.class,
-          CustomProfileConnector.class);
-    }
-
-    interfaceBuilder.addMethod(methodBuilder.build());
-  }
-
-  private void generateSecondaryMethod(TypeSpec.Builder interfaceBuilder) {
-    MethodSpec.Builder methodBuilder =
-        MethodSpec.methodBuilder("secondary")
-            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
-            .returns(getSingleSenderCanThrowInterfaceClassName(generatorContext, crossProfileType));
-
-    if (crossProfileType.profileConnector().isPresent()) {
-      String secondaryProfileName =
-          crossProfileType.profileConnector().get().primaryProfile().equals(ProfileType.WORK)
-              ? Ascii.toLowerCase(ProfileType.PERSONAL.name())
-              : Ascii.toLowerCase(ProfileType.WORK.name());
-      methodBuilder.addJavadoc(
-          "Run a method on the secondary ("
-              + secondaryProfileName
-              + ") profile, if accessible.\n\n@see $T#primaryProfile()\n",
-          CustomProfileConnector.class);
-    } else {
-      methodBuilder.addJavadoc(
-          "Run a method on the secondary profile, if accessible.\n\n"
-              + "@throws $1T if the {@link $2T} does not have a primary profile set\n"
-              + "@see $2T#primaryProfile()\n",
-          IllegalStateException.class,
-          CustomProfileConnector.class);
-    }
-
-    interfaceBuilder.addMethod(methodBuilder.build());
-  }
-
-  private void generateSuppliersMethod(TypeSpec.Builder interfaceBuilder) {
-    MethodSpec.Builder methodBuilder =
-        MethodSpec.methodBuilder("suppliers")
-            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
-            .returns(getMultipleSenderInterfaceClassName(generatorContext, crossProfileType));
-
-    if (crossProfileType.profileConnector().isPresent()) {
-      String primaryProfileName =
-          crossProfileType.profileConnector().get().primaryProfile().equals(ProfileType.WORK)
-              ? Ascii.toLowerCase(ProfileType.WORK.name())
-              : Ascii.toLowerCase(ProfileType.PERSONAL.name());
-      String secondaryProfileName =
-          crossProfileType.profileConnector().get().primaryProfile().equals(ProfileType.WORK)
-              ? Ascii.toLowerCase(ProfileType.PERSONAL.name())
-              : Ascii.toLowerCase(ProfileType.WORK.name());
-      methodBuilder
-          .addJavadoc("Run a method on supplier profiles, if accessible.\n\n")
-          .addJavadoc(
-              "<p>When run from the primary ($1L) profile, supplier profiles are the primary ($1L)"
-                  + " and secondary ($2L) profiles. When run from the secondary ($2L) profile,"
-                  + " supplier profiles includes only the secondary ($2L) profile.\n\n",
-              primaryProfileName,
-              secondaryProfileName)
-          .addJavadoc("@see $T#primaryProfile()\n", CustomProfileConnector.class);
-    } else {
-      methodBuilder
-          .addJavadoc("Run a method on supplier profiles, if accessible.\n\n")
-          .addJavadoc(
-              "<p>When run from the primary profile, supplier profiles are the primary and"
-                  + " secondary profiles. When run from the secondary profile, supplier profiles"
-                  + " includes only the secondary profile.\n\n")
-          .addJavadoc(
-              "@throws $1T if the {@link $2T} does not have a primary profile set\n",
-              IllegalStateException.class,
-              CustomProfileConnector.class)
-          .addJavadoc("@see $T#primaryProfile()\n", CustomProfileConnector.class);
-    }
-
-    interfaceBuilder.addMethod(methodBuilder.build());
-  }
-
   private void generateSingleSenderInterface() {
     ClassName interfaceName = getSingleSenderInterfaceClassName(generatorContext, crossProfileType);
 
@@ -349,20 +152,6 @@
                 IfAvailableGenerator.getIfAvailableClassName(generatorContext, crossProfileType))
             .build());
 
-    interfaceBuilder.addMethod(
-        MethodSpec.methodBuilder("timeout")
-            .addJavadoc(
-                "Set a timeout to be used when making asynchronous calls to other profiles.\n\n"
-                    + "<p>This overrides any timeout set on the type or method being called.\n")
-            .addAnnotation(
-                AnnotationSpec.builder(SuppressWarnings.class)
-                    .addMember("value", "$S", "GoodTime")
-                    .build())
-            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
-            .returns(interfaceName)
-            .addParameter(long.class, "timeout")
-            .build());
-
     generatorUtilities.writeClassToFile(interfaceName.packageName(), interfaceBuilder);
   }
 
@@ -459,20 +248,6 @@
       }
     }
 
-    interfaceBuilder.addMethod(
-        MethodSpec.methodBuilder("timeout")
-            .addJavadoc(
-                "Set a timeout to be used when making asynchronous calls to other profiles.\n\n"
-                    + "<p>This overrides any timeout set on the type or method being called.")
-            .addAnnotation(
-                AnnotationSpec.builder(SuppressWarnings.class)
-                    .addMember("value", "$S", "GoodTime")
-                    .build())
-            .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT)
-            .returns(interfaceName)
-            .addParameter(long.class, "timeout")
-            .build());
-
     generatorUtilities.writeClassToFile(interfaceName.packageName(), interfaceBuilder);
   }
 
@@ -543,20 +318,13 @@
       return;
     }
 
-    VariableElement callbackParameter = method.getCrossProfileCallbackParam(generatorContext).get();
-    TypeElement callbackType =
-        generatorContext.elements().getTypeElement(callbackParameter.asType().toString());
-    CrossProfileCallbackInterfaceInfo callbackInterface =
-        CrossProfileCallbackInterfaceInfo.create(callbackType);
-
     List<ParameterSpec> parameters =
         convertCallbackParametersIntoMulti(
             GeneratorUtilities.extractParametersFromMethod(
                 crossProfileType.supportedTypes(),
                 method.methodElement(),
                 REMOVE_AUTOMATICALLY_RESOLVED_PARAMETERS),
-            callbackParameter,
-            callbackInterface);
+            method.getCrossProfileCallbackParam(generatorContext).get());
 
     CodeBlock methodReference = generateMethodReference(crossProfileType, method);
 
@@ -636,20 +404,22 @@
                 + " a {@link $T} will be thrown on another thread with the original exception"
                 + " as the cause.\n\n",
             PROFILE_RUNTIME_EXCEPTION_CLASSNAME)
+        .addJavadoc(
+            "<p>Only the first result passed in for each profile will be passed into the "
+                + "callback.\n\n"
+        )
         .addJavadoc("@see $L\n", methodReference);
 
     interfaceBuilder.addMethod(methodBuilder.build());
   }
 
   private List<ParameterSpec> convertCallbackParametersIntoMulti(
-      List<ParameterSpec> parameters,
-      VariableElement callbackParameter,
-      CrossProfileCallbackInterfaceInfo callbackInterface) {
+      List<ParameterSpec> parameters, CrossProfileCallbackParameterInfo callbackParameter) {
     return parameters.stream()
         .map(
             e ->
                 e.name.equals(callbackParameter.getSimpleName().toString())
-                    ? convertCallbackToMulti(e, callbackInterface)
+                    ? convertCallbackToMulti(e, callbackParameter.crossProfileCallbackInterface())
                     : e)
         .collect(toList());
   }
@@ -667,24 +437,22 @@
 
   static ClassName getCrossProfileTypeInterfaceClassName(
       GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
-    return crossProfileType.profileClassName();
+    return transformClassName(crossProfileType.generatedClassName(), prepend("Profile"));
   }
 
   static ClassName getSingleSenderInterfaceClassName(
       GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
-    return GeneratorUtilities.appendToClassName(
-        crossProfileType.profileClassName(), "_SingleSender");
+    return transformClassName(crossProfileType.generatedClassName(), append("_SingleSender"));
   }
 
   static ClassName getSingleSenderCanThrowInterfaceClassName(
       GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
-    return GeneratorUtilities.appendToClassName(
-        crossProfileType.profileClassName(), "_SingleSenderCanThrow");
+    return transformClassName(
+        crossProfileType.generatedClassName(), append("_SingleSenderCanThrow"));
   }
 
   static ClassName getMultipleSenderInterfaceClassName(
       GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
-    return GeneratorUtilities.appendToClassName(
-        crossProfileType.profileClassName(), "_MultipleSender");
+    return transformClassName(crossProfileType.generatedClassName(), append("_MultipleSender"));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InternalCrossProfileClassGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InternalCrossProfileClassGenerator.java
index bbbf0ab..c9e1e72 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InternalCrossProfileClassGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InternalCrossProfileClassGenerator.java
@@ -15,19 +15,21 @@
  */
 package com.google.android.enterprise.connectedapps.processor;
 
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.append;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLER_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLE_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLE_UTILITIES_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CONTEXT_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CROSS_PROFILE_CALLBACK_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CROSS_PROFILE_FUTURE_RESULT_WRITER;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.METHOD_RUNNER_CLASSNAME;
-import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PARCEL_CLASSNAME;
-import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PARCEL_UTILITIES_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.containers.CrossProfileMethodInfo.AutomaticallyResolvedParameterFilterBehaviour.REPLACE_AUTOMATICALLY_RESOLVED_PARAMETERS;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.util.stream.Collectors.joining;
 
 import com.google.android.enterprise.connectedapps.annotations.CrossProfile;
-import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileCallbackInterfaceInfo;
+import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileCallbackParameterInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileMethodInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.FutureWrapper;
@@ -44,7 +46,6 @@
 import java.util.stream.IntStream;
 import javax.lang.model.element.ExecutableElement;
 import javax.lang.model.element.Modifier;
-import javax.lang.model.element.TypeElement;
 import javax.lang.model.element.VariableElement;
 import javax.lang.model.type.TypeKind;
 import javax.lang.model.type.TypeMirror;
@@ -64,9 +65,9 @@
   private final CrossProfileTypeInfo crossProfileType;
 
   InternalCrossProfileClassGenerator(
-          GeneratorContext generatorContext,
-          ProviderClassInfo providerClass,
-          CrossProfileTypeInfo crossProfileType) {
+      GeneratorContext generatorContext,
+      ProviderClassInfo providerClass,
+      CrossProfileTypeInfo crossProfileType) {
     this.generatorContext = checkNotNull(generatorContext);
     this.generatorUtilities = new GeneratorUtilities(generatorContext);
     this.providerClass = checkNotNull(providerClass);
@@ -76,7 +77,7 @@
   void generate() {
     if (generated) {
       throw new IllegalStateException(
-              "InternalCrossProfileClassGenerator#generate can only be called once");
+          "InternalCrossProfileClassGenerator#generate can only be called once");
     }
     generated = true;
 
@@ -87,60 +88,62 @@
     ClassName className = getInternalCrossProfileClassName(generatorContext, crossProfileType);
 
     TypeSpec.Builder classBuilder =
-            TypeSpec.classBuilder(className).addModifiers(Modifier.PUBLIC, Modifier.FINAL);
+        TypeSpec.classBuilder(className).addModifiers(Modifier.PUBLIC, Modifier.FINAL);
 
     classBuilder.addJavadoc(
-            "Internal class for {@link $T}.\n\n"
-                    + "<p>This is used by the Connected Apps SDK to dispatch cross-profile calls.\n\n"
-                    + "<p>Cross-profile type identifier: $L.\n",
-            crossProfileType.crossProfileTypeElement().asType(),
-            crossProfileType.identifier());
+        "Internal class for {@link $T}.\n\n"
+            + "<p>This is used by the Connected Apps SDK to dispatch cross-profile calls.\n\n"
+            + "<p>Cross-profile type identifier: $L.\n",
+        crossProfileType.crossProfileTypeElement().asType(),
+        crossProfileType.identifier());
 
     classBuilder.addField(
-            FieldSpec.builder(className, "instance")
-                    .addModifiers(Modifier.PRIVATE, Modifier.STATIC, Modifier.FINAL)
-                    .initializer("new $T()", className)
-                    .build());
+        FieldSpec.builder(className, "instance")
+            .addModifiers(Modifier.PRIVATE, Modifier.STATIC, Modifier.FINAL)
+            .initializer("new $T()", className)
+            .build());
 
     classBuilder.addField(
-            FieldSpec.builder(BUNDLER_CLASSNAME, "bundler")
-                    .addModifiers(Modifier.PRIVATE, Modifier.STATIC, Modifier.FINAL)
-                    .initializer(
-                            "new $T()",
-                            BundlerGenerator.getBundlerClassName(generatorContext, crossProfileType))
-                    .build());
+        FieldSpec.builder(BUNDLER_CLASSNAME, "bundler")
+            .addModifiers(Modifier.PRIVATE, Modifier.STATIC, Modifier.FINAL)
+            .initializer(
+                "new $T()",
+                BundlerGenerator.getBundlerClassName(generatorContext, crossProfileType))
+            .build());
 
     if (!crossProfileType.isStatic()) {
       ExecutableElement providerMethod =
-              providerClass.findProviderMethodFor(generatorContext, crossProfileType);
+          providerClass.findProviderMethodFor(generatorContext, crossProfileType);
       String paramsString = providerMethod.getParameters().isEmpty() ? "()" : "(context)";
       CodeBlock providerMethodCall =
-              CodeBlock.of("$L$L", providerMethod.getSimpleName(), paramsString);
+          CodeBlock.of("$L$L", providerMethod.getSimpleName(), paramsString);
 
       classBuilder.addMethod(
-              MethodSpec.methodBuilder("crossProfileType")
-                      .addParameter(CONTEXT_CLASSNAME, "context")
-                      .returns(crossProfileType.className())
-                      .addStatement(
-                              "return $T.instance().providerClass(context).$L",
-                              InternalProviderClassGenerator.getInternalProviderClassName(
-                                      generatorContext, providerClass),
-                              providerMethodCall)
-                      .build());
+          MethodSpec.methodBuilder("crossProfileType")
+              .addParameter(CONTEXT_CLASSNAME, "context")
+              .returns(crossProfileType.className())
+              .addStatement(
+                  "return $T.instance().providerClass(context).$L",
+                  InternalProviderClassGenerator.getInternalProviderClassName(
+                      generatorContext, providerClass),
+                  providerMethodCall)
+              .build());
     }
 
     classBuilder.addMethod(
-            MethodSpec.methodBuilder("bundler")
-                    .returns(BUNDLER_CLASSNAME)
-                    .addStatement("return bundler")
-                    .build());
+        MethodSpec.methodBuilder("bundler")
+            .returns(BUNDLER_CLASSNAME)
+            .addStatement("return bundler")
+            .build());
 
     classBuilder.addMethod(
-            MethodSpec.methodBuilder("instance")
-                    .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
-                    .returns(className)
-                    .addStatement("return instance")
-                    .build());
+        MethodSpec.methodBuilder("instance")
+            .addModifiers(Modifier.PUBLIC, Modifier.STATIC)
+            .addJavadoc(
+                "Connected-Apps SDK internal use only. This API may be removed at any time.")
+            .returns(className)
+            .addStatement("return instance")
+            .build());
 
     classBuilder.addMethod(MethodSpec.constructorBuilder().addModifiers(Modifier.PRIVATE).build());
 
@@ -152,23 +155,23 @@
   }
 
   private void addMethodsField(
-          TypeSpec.Builder classBuilder, CrossProfileTypeInfo crossProfileType) {
+      TypeSpec.Builder classBuilder, CrossProfileTypeInfo crossProfileType) {
     int totalMethods = crossProfileType.crossProfileMethods().size();
 
     classBuilder.addField(
-            FieldSpec.builder(ArrayTypeName.of(METHOD_RUNNER_CLASSNAME), "methods")
-                    .addModifiers(Modifier.PRIVATE)
-                    .initializer(
-                            "new $T[]{$L}",
-                            METHOD_RUNNER_CLASSNAME,
-                            IntStream.range(0, totalMethods)
-                                    .mapToObj(n -> "this::method" + n)
-                                    .collect(joining(",")))
-                    .build());
+        FieldSpec.builder(ArrayTypeName.of(METHOD_RUNNER_CLASSNAME), "methods")
+            .addModifiers(Modifier.PRIVATE)
+            .initializer(
+                "new $T[]{$L}",
+                METHOD_RUNNER_CLASSNAME,
+                IntStream.range(0, totalMethods)
+                    .mapToObj(n -> "this::method" + n)
+                    .collect(joining(",")))
+            .build());
   }
 
   private void addCrossProfileTypeMethods(
-          TypeSpec.Builder classBuilder, CrossProfileTypeInfo crossProfileType) {
+      TypeSpec.Builder classBuilder, CrossProfileTypeInfo crossProfileType) {
     for (CrossProfileMethodInfo method : crossProfileType.crossProfileMethods()) {
       if (method.isBlocking(generatorContext, crossProfileType)) {
         addBlockingCrossProfileTypeMethod(classBuilder, method);
@@ -183,21 +186,23 @@
   }
 
   private void addBlockingCrossProfileTypeMethod(
-          TypeSpec.Builder classBuilder, CrossProfileMethodInfo method) {
+      TypeSpec.Builder classBuilder, CrossProfileMethodInfo method) {
     CodeBlock.Builder methodCode = CodeBlock.builder();
 
-    // parcle is recycled by caller
-    methodCode.addStatement("$1T returnParcel = $1T.obtain()", PARCEL_CLASSNAME);
+    methodCode.addStatement(
+        "$1T returnBundle = new $1T($2T.class.getClassLoader())",
+        BUNDLE_CLASSNAME,
+        BUNDLER_CLASSNAME);
 
     addExtractParametersCode(methodCode, method);
 
     CodeBlock methodCall =
-            CodeBlock.of(
-                    "$L.$L($L)",
-                    getCrossProfileTypeReference(method),
-                    method.simpleName(),
-                    method.commaSeparatedParameters(
-                            crossProfileType.supportedTypes(), REPLACE_AUTOMATICALLY_RESOLVED_PARAMETERS));
+        CodeBlock.of(
+            "$L.$L($L)",
+            getCrossProfileTypeReference(method),
+            method.simpleName(),
+            method.commaSeparatedParameters(
+                crossProfileType.supportedTypes(), REPLACE_AUTOMATICALLY_RESOLVED_PARAMETERS));
 
     if (!method.thrownExceptions().isEmpty()) {
       methodCode.beginControlFlow("try");
@@ -208,203 +213,203 @@
     } else {
       methodCall = CodeBlock.of("$T returnValue = $L", method.returnType(), methodCall);
       methodCode.addStatement(methodCall);
-      methodCode.add("returnParcel.writeInt(0); // No errors\n");
       methodCode.addStatement(
-              "bundler.writeToParcel(returnParcel, returnValue, $L, /* flags= */ 0)",
-              TypeUtils.generateBundlerType(method.returnType()));
+          "bundler.writeToBundle(returnBundle, $S, returnValue, $L)",
+          "return",
+          TypeUtils.generateBundlerType(method.returnType()));
     }
 
     if (!method.thrownExceptions().isEmpty()) {
       for (TypeName exceptionType : method.thrownExceptions()) {
         methodCode.nextControlFlow("catch ($L e)", exceptionType);
-        methodCode.add("returnParcel.writeInt(1); // Errors\n");
         methodCode.addStatement(
-                "$T.writeThrowableToParcel(returnParcel, e)", PARCEL_UTILITIES_CLASSNAME);
+            "$T.writeThrowableToBundle(returnBundle, $S, e)",
+            BUNDLE_UTILITIES_CLASSNAME,
+            "throwable");
       }
       methodCode.endControlFlow();
     }
 
-    methodCode.addStatement("return returnParcel");
+    methodCode.addStatement("return returnBundle");
 
     classBuilder.addMethod(
-            MethodSpec.methodBuilder("method" + method.identifier())
-                    .addModifiers(Modifier.PRIVATE)
-                    .returns(PARCEL_CLASSNAME)
-                    .addParameter(CONTEXT_CLASSNAME, "context")
-                    .addParameter(PARCEL_CLASSNAME, "params")
-                    .addParameter(CROSS_PROFILE_CALLBACK_CLASSNAME, "callback")
-                    .addCode(methodCode.build())
-                    .addJavadoc(
-                            "Call $1L and return a {@link $2T} containing the return value.\n\n"
-                                    + "<p>The {@link $2T} must be recycled after use.\n",
-                            GeneratorUtilities.methodJavadocReference(method.methodElement()),
-                            PARCEL_CLASSNAME)
-                    .build());
+        MethodSpec.methodBuilder("method" + method.identifier())
+            .addModifiers(Modifier.PRIVATE)
+            .returns(BUNDLE_CLASSNAME)
+            .addParameter(CONTEXT_CLASSNAME, "context")
+            .addParameter(BUNDLE_CLASSNAME, "params")
+            .addParameter(CROSS_PROFILE_CALLBACK_CLASSNAME, "callback")
+            .addCode(methodCode.build())
+            .addJavadoc(
+                "Call $1L and return a {@link $2T} containing the return value under the \"return\""
+                    + "key.\n",
+                GeneratorUtilities.methodJavadocReference(method.methodElement()),
+                BUNDLE_CLASSNAME)
+            .build());
   }
 
   private void addCrossProfileCallbackCrossProfileTypeMethod(
-          TypeSpec.Builder classBuilder, CrossProfileMethodInfo method) {
+      TypeSpec.Builder classBuilder, CrossProfileMethodInfo method) {
     CodeBlock.Builder methodCode = CodeBlock.builder();
 
-    // parcel is recycled by caller
-    methodCode.addStatement("$1T returnParcel = $1T.obtain()", PARCEL_CLASSNAME);
+    methodCode.addStatement(
+        "$1T returnBundle = new $1T($2T.class.getClassLoader())",
+        BUNDLE_CLASSNAME,
+        BUNDLER_CLASSNAME);
 
     addExtractParametersCode(methodCode, method);
 
     createCrossProfileCallbackParameter(methodCode, method);
 
     CodeBlock methodCall =
-            CodeBlock.of(
-                    "$L.$L($L)",
-                    getCrossProfileTypeReference(method),
-                    method.simpleName(),
-                    method.commaSeparatedParameters(
-                            crossProfileType.supportedTypes(), REPLACE_AUTOMATICALLY_RESOLVED_PARAMETERS));
+        CodeBlock.of(
+            "$L.$L($L)",
+            getCrossProfileTypeReference(method),
+            method.simpleName(),
+            method.commaSeparatedParameters(
+                crossProfileType.supportedTypes(), REPLACE_AUTOMATICALLY_RESOLVED_PARAMETERS));
 
     if (isPrimitiveOrObjectVoid(method.returnType())) {
       methodCode.addStatement(methodCall);
     } else {
       methodCall = CodeBlock.of("$T returnValue = $L", method.returnType(), methodCall);
       methodCode.addStatement(methodCall);
-      methodCode.add("returnParcel.writeInt(0); // No errors\n");
       methodCode.addStatement(
-              "bundler.writeToParcel(returnParcel, returnValue, $L, /* flags= */ 0)",
-              TypeUtils.generateBundlerType(method.returnType()));
+          "bundler.writeToBundle(returnBundle, $1S, returnValue, $2L)",
+          "return",
+          TypeUtils.generateBundlerType(method.returnType()));
     }
 
-    methodCode.addStatement("return returnParcel");
+    methodCode.addStatement("return returnBundle");
 
     classBuilder.addMethod(
-            MethodSpec.methodBuilder("method" + method.identifier())
-                    .addModifiers(Modifier.PRIVATE)
-                    .returns(PARCEL_CLASSNAME)
-                    .addParameter(CONTEXT_CLASSNAME, "context")
-                    .addParameter(PARCEL_CLASSNAME, "params")
-                    // TODO: This should be renamed to "callback" once we prefix unpacked parameter names
-                    //  (without doing this, a param named "callback" will cause a compile error)
-                    .addParameter(CROSS_PROFILE_CALLBACK_CLASSNAME, "crossProfileCallback")
-                    .addCode(methodCode.build())
-                    .addJavadoc(
-                            "Call $1L, and link the callback to {@code crossProfileCallback}.\n\n"
-                                    + "@return An empty parcel. This must be recycled after use.\n",
-                            GeneratorUtilities.methodJavadocReference(method.methodElement()))
-                    .build());
+        MethodSpec.methodBuilder("method" + method.identifier())
+            .addModifiers(Modifier.PRIVATE)
+            .returns(BUNDLE_CLASSNAME)
+            .addParameter(CONTEXT_CLASSNAME, "context")
+            .addParameter(BUNDLE_CLASSNAME, "params")
+            // TODO: This should be renamed to "callback" once we prefix unpacked parameter names
+            //  (without doing this, a param named "callback" will cause a compile error)
+            .addParameter(CROSS_PROFILE_CALLBACK_CLASSNAME, "crossProfileCallback")
+            .addCode(methodCode.build())
+            .addJavadoc(
+                "Call $1L, and link the callback to {@code crossProfileCallback}.\n\n"
+                    + "@return An empty bundle.\n",
+                GeneratorUtilities.methodJavadocReference(method.methodElement()))
+            .build());
   }
 
   private void addFutureCrossProfileTypeMethod(
-          TypeSpec.Builder classBuilder, CrossProfileMethodInfo method) {
+      TypeSpec.Builder classBuilder, CrossProfileMethodInfo method) {
     CodeBlock.Builder methodCode = CodeBlock.builder();
 
-    // parcel is recycled by caller
-    methodCode.addStatement("$1T returnParcel = $1T.obtain()", PARCEL_CLASSNAME);
+    methodCode.addStatement(
+        "$1T returnBundle = new $1T($2T.class.getClassLoader())",
+        BUNDLE_CLASSNAME,
+        BUNDLER_CLASSNAME);
 
     addExtractParametersCode(methodCode, method);
 
     CodeBlock methodCall =
-            CodeBlock.of(
-                    "$L.$L($L)",
-                    getCrossProfileTypeReference(method),
-                    method.simpleName(),
-                    method.commaSeparatedParameters(
-                            crossProfileType.supportedTypes(), REPLACE_AUTOMATICALLY_RESOLVED_PARAMETERS));
+        CodeBlock.of(
+            "$L.$L($L)",
+            getCrossProfileTypeReference(method),
+            method.simpleName(),
+            method.commaSeparatedParameters(
+                crossProfileType.supportedTypes(), REPLACE_AUTOMATICALLY_RESOLVED_PARAMETERS));
 
     methodCode.addStatement("$T future = $L", method.returnType(), methodCall);
 
     TypeMirror rawFutureType = TypeUtils.removeTypeArguments(method.returnType());
 
     FutureWrapper futureWrapper =
-            crossProfileType.supportedTypes().getType(rawFutureType).getFutureWrapper().get();
+        crossProfileType.supportedTypes().getType(rawFutureType).getFutureWrapper().get();
     // This assumes every Future is generic with one type argument
     TypeMirror wrappedReturnType =
-            TypeUtils.extractTypeArguments(method.returnType()).iterator().next();
+        TypeUtils.extractTypeArguments(method.returnType()).iterator().next();
     methodCode.addStatement(
-            "$T.writeFutureResult(future, new $T<>(callback, bundler, $L))",
-            futureWrapper.wrapperClassName(),
-            CROSS_PROFILE_FUTURE_RESULT_WRITER,
-            TypeUtils.generateBundlerType(wrappedReturnType));
+        "$T.writeFutureResult(future, new $T<>(callback, bundler, $L))",
+        futureWrapper.wrapperClassName(),
+        CROSS_PROFILE_FUTURE_RESULT_WRITER,
+        TypeUtils.generateBundlerType(wrappedReturnType));
 
-    // TODO: Can this just return null? where does it go? that'd avoid having to obtain/recycle
-    methodCode.addStatement("return returnParcel");
+    methodCode.addStatement("return returnBundle");
 
     classBuilder.addMethod(
-            MethodSpec.methodBuilder("method" + method.identifier())
-                    .addModifiers(Modifier.PRIVATE)
-                    .returns(PARCEL_CLASSNAME)
-                    .addParameter(CONTEXT_CLASSNAME, "context")
-                    .addParameter(PARCEL_CLASSNAME, "params")
-                    .addParameter(CROSS_PROFILE_CALLBACK_CLASSNAME, "callback")
-                    .addCode(methodCode.build())
-                    .addJavadoc(
-                            "Call $1L, and link the returned future to {@code crossProfileCallback}.\n\n"
-                                    + "@return An empty parcel. This must be recycled after use.\n",
-                            GeneratorUtilities.methodJavadocReference(method.methodElement()))
-                    .build());
+        MethodSpec.methodBuilder("method" + method.identifier())
+            .addModifiers(Modifier.PRIVATE)
+            .returns(BUNDLE_CLASSNAME)
+            .addParameter(CONTEXT_CLASSNAME, "context")
+            .addParameter(BUNDLE_CLASSNAME, "params")
+            .addParameter(CROSS_PROFILE_CALLBACK_CLASSNAME, "callback")
+            .addCode(methodCode.build())
+            .addJavadoc(
+                "Call $1L, and link the returned future to {@code crossProfileCallback}.\n\n"
+                    + "@return An empty bundle.\n",
+                GeneratorUtilities.methodJavadocReference(method.methodElement()))
+            .build());
   }
 
   private void createCrossProfileCallbackParameter(
-          CodeBlock.Builder methodCode, CrossProfileMethodInfo method) {
-    VariableElement asyncCallbackParam =
-            method.getCrossProfileCallbackParam(generatorContext).get();
-
-    TypeElement callbackType =
-            generatorContext.elements().getTypeElement(asyncCallbackParam.asType().toString());
-    CrossProfileCallbackInterfaceInfo callbackInterface =
-            CrossProfileCallbackInterfaceInfo.create(callbackType);
+      CodeBlock.Builder methodCode, CrossProfileMethodInfo method) {
+    CrossProfileCallbackParameterInfo callbackParameter =
+        method.getCrossProfileCallbackParam(generatorContext).get();
 
     methodCode.addStatement(
-            "$T $L = new $L(crossProfileCallback, bundler)",
-            asyncCallbackParam.asType(),
-            asyncCallbackParam.getSimpleName(),
-            CrossProfileCallbackCodeGenerator.getCrossProfileCallbackReceiverClassName(
-                    generatorContext, callbackInterface));
+        "$T $L = new $L(crossProfileCallback, bundler)",
+        callbackParameter.variable().asType(),
+        callbackParameter.getSimpleName(),
+        CrossProfileCallbackCodeGenerator.getCrossProfileCallbackReceiverClassName(
+            generatorContext, callbackParameter.crossProfileCallbackInterface()));
   }
 
   private static boolean isPrimitiveOrObjectVoid(TypeMirror typeMirror) {
     return typeMirror.getKind().equals(TypeKind.VOID)
-            || typeMirror.toString().equals("java.lang.Void");
+        || typeMirror.toString().equals("java.lang.Void");
   }
 
   private void addExtractParametersCode(CodeBlock.Builder code, CrossProfileMethodInfo method) {
-    Optional<VariableElement> callbackParameter =
-            method.getCrossProfileCallbackParam(generatorContext);
+    Optional<CrossProfileCallbackParameterInfo> callbackParameter =
+        method.getCrossProfileCallbackParam(generatorContext);
     for (VariableElement parameter : method.methodElement().getParameters()) {
       if (callbackParameter.isPresent()
-              && callbackParameter.get().getSimpleName().equals(parameter.getSimpleName())) {
+          && callbackParameter.get().variable().getSimpleName().equals(parameter.getSimpleName())) {
         continue; // Don't extract a callback parameter
       }
       if (crossProfileType.supportedTypes().isAutomaticallyResolved(parameter.asType())) {
         continue;
       }
       code.addStatement(
-              "@SuppressWarnings(\"unchecked\") $1T $2L = ($1T) bundler.readFromParcel(params, $3L)",
-              parameter.asType(),
-              parameter.getSimpleName().toString(),
-              TypeUtils.generateBundlerType(parameter.asType()));
+          "@SuppressWarnings(\"unchecked\") $1T $2L = ($1T) bundler.readFromBundle(params, $2S,"
+              + " $3L)",
+          parameter.asType(),
+          parameter.getSimpleName().toString(),
+          TypeUtils.generateBundlerType(parameter.asType()));
     }
   }
 
   private static void addCallMethod(TypeSpec.Builder classBuilder) {
     classBuilder.addMethod(
-            MethodSpec.methodBuilder("call")
-                    .addModifiers(Modifier.PUBLIC)
-                    .returns(PARCEL_CLASSNAME)
-                    .addParameter(CONTEXT_CLASSNAME, "context")
-                    .addParameter(int.class, "methodIdentifier")
-                    .addParameter(PARCEL_CLASSNAME, "params")
-                    .addParameter(CROSS_PROFILE_CALLBACK_CLASSNAME, "callback")
-                    .beginControlFlow("if (methodIdentifier >= methods.length)")
-                    .addStatement(
-                            "throw new $T(\"Invalid method identifier\" + methodIdentifier)",
-                            IllegalArgumentException.class)
-                    .endControlFlow()
-                    .addStatement("return methods[methodIdentifier].call(context, params, callback)")
-                    .addJavadoc(
-                            "Call the method referenced by {@code methodIdentifier}.\n\n"
-                                    + "<p>If the method is synchronous, this will return a {@link $1T} containing"
-                                    + " the return value, otherwise it will return an empty {@link $1T}. The"
-                                    + " {@link $1T} must be recycled after use.\n",
-                            PARCEL_CLASSNAME)
-                    .build());
+        MethodSpec.methodBuilder("call")
+            .addModifiers(Modifier.PUBLIC)
+            .returns(BUNDLE_CLASSNAME)
+            .addParameter(CONTEXT_CLASSNAME, "context")
+            .addParameter(int.class, "methodIdentifier")
+            .addParameter(BUNDLE_CLASSNAME, "params")
+            .addParameter(CROSS_PROFILE_CALLBACK_CLASSNAME, "callback")
+            .beginControlFlow("if (methodIdentifier >= methods.length)")
+            .addStatement(
+                "throw new $T(\"Invalid method identifier\" + methodIdentifier)",
+                IllegalArgumentException.class)
+            .endControlFlow()
+            .addStatement("return methods[methodIdentifier].call(context, params, callback)")
+            .addJavadoc(
+                "Call the method referenced by {@code methodIdentifier}.\n\n"
+                    + "<p>If the method is synchronous, this will return a {@link $1T} containing"
+                    + " the return value under the key \"return\", otherwise it will return an"
+                    + " empty {@link $1T}.\n",
+                BUNDLE_CLASSNAME)
+            .build());
   }
 
   private CodeBlock getCrossProfileTypeReference(CrossProfileMethodInfo method) {
@@ -415,7 +420,8 @@
   }
 
   static ClassName getInternalCrossProfileClassName(
-          GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
-    return GeneratorUtilities.appendToClassName(crossProfileType.profileClassName(), "_Internal");
+      GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
+    return transformClassName(crossProfileType.generatedClassName(), append("_Internal"));
   }
 }
+
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InternalProviderClassGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InternalProviderClassGenerator.java
index c02bbfb..b5bcb82 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InternalProviderClassGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InternalProviderClassGenerator.java
@@ -15,9 +15,10 @@
  */
 package com.google.android.enterprise.connectedapps.processor;
 
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.classNameInferringPackageFromElement;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLE_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CONTEXT_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CROSS_PROFILE_CALLBACK_CLASSNAME;
-import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PARCEL_CLASSNAME;
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
@@ -29,7 +30,6 @@
 import com.squareup.javapoet.MethodSpec;
 import com.squareup.javapoet.TypeSpec;
 import javax.lang.model.element.Modifier;
-import javax.lang.model.element.PackageElement;
 
 /**
  * Generate the {@code Profile_*_Internal} class for a single provider class.
@@ -146,20 +146,19 @@
     classBuilder.addMethod(
         MethodSpec.methodBuilder("call")
             .addModifiers(Modifier.PUBLIC)
-            .returns(PARCEL_CLASSNAME)
+            .returns(BUNDLE_CLASSNAME)
             .addParameter(CONTEXT_CLASSNAME, "context")
             .addParameter(long.class, "crossProfileTypeIdentifier")
             .addParameter(int.class, "methodIdentifier")
-            .addParameter(PARCEL_CLASSNAME, "params")
+            .addParameter(BUNDLE_CLASSNAME, "params")
             .addParameter(CROSS_PROFILE_CALLBACK_CLASSNAME, "callback")
             .addCode(methodCode.build())
             .addJavadoc(
                 "Call the {@code call} method on the internal type referenced by the {@code"
                     + " crossProfileTypeIdentifier}.\n\n"
-                    + "@return A {@link $1T} which contains the return value (if a synchronous"
-                    + " call) or is empty\n (if asynchronous). This {@link $1T} must be recycled"
-                    + " after use.\n",
-                PARCEL_CLASSNAME)
+                    + "@return A {@link $1T} which contains the return value under the key "
+                    + "\"return\" (if a synchronous call) or is empty\n (if asynchronous).\n",
+                BUNDLE_CLASSNAME)
             .build());
   }
 
@@ -176,11 +175,9 @@
 
   static ClassName getInternalProviderClassName(
       GeneratorContext generatorContext, ProviderClassInfo providerClass) {
-    PackageElement originalPackage =
-        generatorContext.elements().getPackageOf(providerClass.providerClassElement());
-    String internalProviderClassName =
-        String.format("Profile_%s_Internal", providerClass.simpleName());
-
-    return ClassName.get(originalPackage.getQualifiedName().toString(), internalProviderClassName);
+    return classNameInferringPackageFromElement(
+        generatorContext,
+        providerClass.providerClassElement(),
+        String.format("Profile_%s_Internal", providerClass.simpleName()));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/LateValidator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/LateValidator.java
index 4b55483..4b30134 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/LateValidator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/LateValidator.java
@@ -23,10 +23,10 @@
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.android.enterprise.connectedapps.processor.containers.ConnectorInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileConfigurationInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
-import com.google.android.enterprise.connectedapps.processor.containers.ProfileConnectorInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.ProviderClassInfo;
 import com.google.common.collect.Streams;
 import java.util.Collection;
@@ -137,7 +137,7 @@
             .serviceClass()
             .get()
             .toString()
-            .equals(configuration.profileConnector().serviceName().toString())) {
+            .equals(configuration.connectorInfo().serviceName().toString())) {
       showError(INCORRECT_SERVICE_CLASS, configuration.configurationElement());
       isValid = false;
     }
@@ -152,7 +152,7 @@
             .flatMap(m -> getConnectorQualifiedNamesUsedInProviderClass(m).stream())
             .collect(toSet());
     connectorQualifiedNames.add(
-        configuration.profileConnector().connectorElement().asType().toString());
+        configuration.connectorInfo().connectorElement().asType().toString());
     return connectorQualifiedNames;
   }
 
@@ -184,9 +184,9 @@
   private static Collection<String> getConnectorQualifiedNamesUsedInProviderClass(
       ProviderClassInfo providerClass) {
     return providerClass.allCrossProfileTypes().stream()
-        .map(CrossProfileTypeInfo::profileConnector)
+        .map(CrossProfileTypeInfo::connectorInfo)
         .flatMap(Streams::stream)
-        .map(ProfileConnectorInfo::connectorElement)
+        .map(ConnectorInfo::connectorElement)
         .map(Element::asType)
         .map(TypeMirror::toString)
         .collect(toSet());
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/MultipleProfilesGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/MultipleProfilesGenerator.java
index 38b8038..e1dbd69 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/MultipleProfilesGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/MultipleProfilesGenerator.java
@@ -15,6 +15,8 @@
  */
 package com.google.android.enterprise.connectedapps.processor;
 
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.append;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.ASYNC_CALLBACK_PARAM_MULTIMERGER_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CALLBACK_MERGER_EXCEPTION_CALLBACK_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PROFILE_CLASSNAME;
@@ -24,11 +26,11 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileCallbackInterfaceInfo;
+import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileCallbackParameterInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileMethodInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.FutureWrapper;
 import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
-import com.squareup.javapoet.AnnotationSpec;
 import com.squareup.javapoet.ClassName;
 import com.squareup.javapoet.CodeBlock;
 import com.squareup.javapoet.MethodSpec;
@@ -40,8 +42,6 @@
 import java.util.List;
 import java.util.Map;
 import javax.lang.model.element.Modifier;
-import javax.lang.model.element.TypeElement;
-import javax.lang.model.element.VariableElement;
 import javax.lang.model.type.PrimitiveType;
 import javax.lang.model.type.TypeKind;
 import javax.lang.model.type.TypeMirror;
@@ -108,22 +108,6 @@
             .addStatement("this.senders = senders")
             .build());
 
-    classBuilder.addMethod(
-        MethodSpec.methodBuilder("timeout")
-            .addAnnotation(Override.class)
-            .addAnnotation(
-                AnnotationSpec.builder(SuppressWarnings.class)
-                    .addMember("value", "$S", "GoodTime")
-                    .build())
-            .addModifiers(Modifier.PUBLIC)
-            .returns(className)
-            .addParameter(long.class, "timeout")
-            .beginControlFlow("for ($T senderProfile : senders.keySet())", PROFILE_CLASSNAME)
-            .addStatement("senders.put(senderProfile, senders.get(senderProfile).timeout(timeout))")
-            .endControlFlow()
-            .addStatement("return this")
-            .build());
-
     for (CrossProfileMethodInfo method : crossProfileType.crossProfileMethods()) {
       if (method.isBlocking(generatorContext, crossProfileType)) {
         generateBlockingMethodOnMultipleProfilesClass(classBuilder, method, crossProfileType);
@@ -225,11 +209,10 @@
 
     String methodName = method.simpleName();
 
-    VariableElement callbackParameter = method.getCrossProfileCallbackParam(generatorContext).get();
-    TypeElement callbackType =
-        generatorContext.elements().getTypeElement(callbackParameter.asType().toString());
+    CrossProfileCallbackParameterInfo callbackParameter =
+        method.getCrossProfileCallbackParam(generatorContext).get();
     CrossProfileCallbackInterfaceInfo callbackInterface =
-        CrossProfileCallbackInterfaceInfo.create(callbackType);
+        callbackParameter.crossProfileCallbackInterface();
 
     List<ParameterSpec> parameters =
         convertCallbackParametersIntoMulti(
@@ -237,8 +220,7 @@
                 crossProfileType.supportedTypes(),
                 method.methodElement(),
                 REMOVE_AUTOMATICALLY_RESOLVED_PARAMETERS),
-            callbackParameter,
-            callbackInterface);
+            callbackParameter);
 
     TypeMirror paramType =
         callbackInterface.methods().get(0).getParameters().isEmpty()
@@ -352,14 +334,12 @@
   }
 
   private List<ParameterSpec> convertCallbackParametersIntoMulti(
-      List<ParameterSpec> parameters,
-      VariableElement callbackParameter,
-      CrossProfileCallbackInterfaceInfo callbackInterface) {
+      List<ParameterSpec> parameters, CrossProfileCallbackParameterInfo callbackParam) {
     return parameters.stream()
         .map(
             e ->
-                e.name.equals(callbackParameter.getSimpleName().toString())
-                    ? convertCallbackToMulti(e, callbackInterface)
+                e.name.equals(callbackParam.getSimpleName().toString())
+                    ? convertCallbackToMulti(e, callbackParam.crossProfileCallbackInterface())
                     : e)
         .collect(toList());
   }
@@ -386,7 +366,6 @@
 
   static ClassName getMultipleProfilesClassName(
       GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
-    return GeneratorUtilities.appendToClassName(
-        crossProfileType.profileClassName(), "_MultipleProfiles");
+    return transformClassName(crossProfileType.generatedClassName(), append("_MultipleProfiles"));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/OtherProfileGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/OtherProfileGenerator.java
index 08011ca..0188ea7 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/OtherProfileGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/OtherProfileGenerator.java
@@ -15,17 +15,19 @@
  */
 package com.google.android.enterprise.connectedapps.processor;
 
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.append;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLER_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLE_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.EXCEPTION_CALLBACK_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.LOCAL_CALLBACK_CLASSNAME;
-import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PARCEL_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PROFILE_CONNECTOR_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.PROFILE_RUNTIME_EXCEPTION_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.UNAVAILABLE_PROFILE_EXCEPTION_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.containers.CrossProfileMethodInfo.AutomaticallyResolvedParameterFilterBehaviour.REMOVE_AUTOMATICALLY_RESOLVED_PARAMETERS;
 import static com.google.common.base.Preconditions.checkNotNull;
 
-import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.interfaces.CrossProfileAnnotation;
-import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileCallbackInterfaceInfo;
+import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileCallbackParameterInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileMethodInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.FutureWrapper;
@@ -33,12 +35,10 @@
 import com.squareup.javapoet.AnnotationSpec;
 import com.squareup.javapoet.ClassName;
 import com.squareup.javapoet.CodeBlock;
-import com.squareup.javapoet.FieldSpec;
 import com.squareup.javapoet.MethodSpec;
 import com.squareup.javapoet.TypeName;
 import com.squareup.javapoet.TypeSpec;
 import javax.lang.model.element.Modifier;
-import javax.lang.model.element.TypeElement;
 import javax.lang.model.element.VariableElement;
 import javax.lang.model.type.TypeKind;
 import javax.lang.model.type.TypeMirror;
@@ -86,47 +86,19 @@
             .addModifiers(Modifier.PUBLIC, Modifier.FINAL)
             .addSuperinterface(singleSenderCanThrowInterface);
 
-    ClassName connectorClassName =
-        crossProfileType.profileConnector().isPresent()
-            ? crossProfileType.profileConnector().get().connectorClassName()
-            : PROFILE_CONNECTOR_CLASSNAME;
-
-    classBuilder.addField(connectorClassName, "connector", Modifier.PRIVATE, Modifier.FINAL);
+    classBuilder.addField(
+        PROFILE_CONNECTOR_CLASSNAME, "connector", Modifier.PRIVATE, Modifier.FINAL);
 
     classBuilder.addMethod(
         MethodSpec.constructorBuilder()
             .addModifiers(Modifier.PUBLIC)
-            .addParameter(connectorClassName, "connector")
+            .addParameter(PROFILE_CONNECTOR_CLASSNAME, "connector")
             .beginControlFlow("if (connector == null)")
             .addStatement("throw new $T()", NullPointerException.class)
             .endControlFlow()
             .addStatement("this.connector = connector")
             .build());
 
-    classBuilder.addField(
-        FieldSpec.builder(long.class, "timeout")
-            .addAnnotation(
-                AnnotationSpec.builder(SuppressWarnings.class)
-                    .addMember("value", "$S", "GoodTime")
-                    .build())
-            .addModifiers(Modifier.PRIVATE)
-            .initializer("$L", CrossProfileAnnotation.TIMEOUT_MILLIS_NOT_SET)
-            .build());
-
-    classBuilder.addMethod(
-        MethodSpec.methodBuilder("timeout")
-            .addAnnotation(Override.class)
-            .addAnnotation(
-                AnnotationSpec.builder(SuppressWarnings.class)
-                    .addMember("value", "$S", "GoodTime")
-                    .build())
-            .addModifiers(Modifier.PUBLIC)
-            .returns(className)
-            .addParameter(long.class, "timeout")
-            .addStatement("this.timeout = timeout")
-            .addStatement("return this")
-            .build());
-
     ClassName ifAvailableClass =
         IfAvailableGenerator.getIfAvailableClassName(generatorContext, crossProfileType);
 
@@ -177,29 +149,29 @@
         InternalCrossProfileClassGenerator.getInternalCrossProfileClassName(
             generatorContext, crossProfileType));
 
-    // parcel is recycled in this method
-    methodBuilder.addStatement("$1T params = $1T.obtain()", PARCEL_CLASSNAME);
+    methodBuilder.addStatement(
+        "$1T params = new $1T($2T.class.getClassLoader())", BUNDLE_CLASSNAME, BUNDLER_CLASSNAME);
     for (VariableElement param : method.methodElement().getParameters()) {
       if (crossProfileType.supportedTypes().isAutomaticallyResolved(param.asType())) {
         continue;
       }
       methodBuilder.addStatement(
-          "internalCrossProfileClass.bundler().writeToParcel(params, $1L, $2L, /* flags= */ 0)",
+          "internalCrossProfileClass.bundler().writeToBundle(params, $1S, $1L, $2L)",
           param.getSimpleName(),
           TypeUtils.generateBundlerType(param.asType()));
     }
 
     if (method.thrownExceptions().isEmpty()) {
       methodBuilder.addStatement(
-          "$1T returnParcel = connector.crossProfileSender().call($2LL, $3L, params)",
-          PARCEL_CLASSNAME,
+          "$1T returnBundle = connector.crossProfileSender().call($2LL, $3L, params)",
+          BUNDLE_CLASSNAME,
           crossProfileType.identifier(),
           method.identifier());
     } else {
-      methodBuilder.addStatement("$1T returnParcel", PARCEL_CLASSNAME);
+      methodBuilder.addStatement("$1T returnBundle", BUNDLE_CLASSNAME);
       methodBuilder.beginControlFlow("try");
       methodBuilder.addStatement(
-          "returnParcel = connector.crossProfileSender().callWithExceptions($1LL, $2L, params)",
+          "returnBundle = connector.crossProfileSender().callWithExceptions($1LL, $2L, params)",
           crossProfileType.identifier(),
           method.identifier());
       methodBuilder.nextControlFlow("catch ($T e)", UNAVAILABLE_PROFILE_EXCEPTION_CLASSNAME);
@@ -219,20 +191,16 @@
       methodBuilder.endControlFlow();
     }
 
-    methodBuilder.addStatement("params.recycle()");
-
     if (!method.returnType().getKind().equals(TypeKind.VOID)) {
       methodBuilder.addStatement(
           CodeBlock.of(
               "@SuppressWarnings(\"unchecked\") $1T returnValue = ($1T)"
-                  + " internalCrossProfileClass.bundler().readFromParcel(returnParcel,"
-                  + " $2L)",
+                  + " internalCrossProfileClass.bundler().readFromBundle(returnBundle, $2S, "
+                  + " $3L)",
               method.returnType(),
+              "return",
               TypeUtils.generateBundlerType(method.returnType())));
-      methodBuilder.addStatement("returnParcel.recycle()");
       methodBuilder.addStatement("return returnValue");
-    } else {
-      methodBuilder.addStatement("returnParcel.recycle()");
     }
 
     classBuilder.addMethod(methodBuilder.build());
@@ -242,11 +210,8 @@
       TypeSpec.Builder classBuilder,
       CrossProfileMethodInfo method,
       CrossProfileTypeInfo crossProfileType) {
-    VariableElement callbackParameter = method.getCrossProfileCallbackParam(generatorContext).get();
-    TypeElement callbackType =
-        generatorContext.elements().getTypeElement(callbackParameter.asType().toString());
-    CrossProfileCallbackInterfaceInfo callbackInterface =
-        CrossProfileCallbackInterfaceInfo.create(callbackType);
+    CrossProfileCallbackParameterInfo callbackParameter =
+        method.getCrossProfileCallbackParam(generatorContext).get();
 
     MethodSpec.Builder methodBuilder =
         MethodSpec.methodBuilder(method.simpleName())
@@ -265,8 +230,8 @@
         InternalCrossProfileClassGenerator.getInternalCrossProfileClassName(
             generatorContext, crossProfileType));
 
-    // parcel is passed into callAsync where it will be cached and recycled afterwards
-    methodBuilder.addStatement("$1T params = $1T.obtain()", PARCEL_CLASSNAME);
+    methodBuilder.addStatement(
+        "$1T params = new $1T($2T.class.getClassLoader())", BUNDLE_CLASSNAME, BUNDLER_CLASSNAME);
 
     for (VariableElement param : method.methodElement().getParameters()) {
       if (crossProfileType.supportedTypes().isAutomaticallyResolved(param.asType())) {
@@ -276,7 +241,7 @@
         continue;
       }
       methodBuilder.addStatement(
-          "internalCrossProfileClass.bundler().writeToParcel(params, $1L, $2L, /* flags= */ 0)",
+          "internalCrossProfileClass.bundler().writeToBundle(params, $1S, $1L, $2L)",
           param.getSimpleName(),
           TypeUtils.generateBundlerType(param.asType()));
     }
@@ -285,7 +250,7 @@
         "$1T sender = new $2T($3L, exceptionCallback, internalCrossProfileClass.bundler())",
         LOCAL_CALLBACK_CLASSNAME,
         CrossProfileCallbackCodeGenerator.getCrossProfileCallbackSenderClassName(
-            generatorContext, callbackInterface),
+            generatorContext, callbackParameter.crossProfileCallbackInterface()),
         callbackParameter.getSimpleName());
 
     // Suppress GoodTime warning for unboxing Duration.
@@ -294,16 +259,10 @@
             .addMember("value", "$S", "GoodTime")
             .build());
     methodBuilder.addStatement(
-        "connector.crossProfileSender().callAsync($1LL, $2L, params, sender, timeout =="
-            + " $3L ? $4L : timeout)",
+        "connector.crossProfileSender().callAsync($1LL, $2L, params, sender, $3L)",
         crossProfileType.identifier(),
         method.identifier(),
-        CrossProfileAnnotation.TIMEOUT_MILLIS_NOT_SET,
-        method.timeoutMillis());
-
-    methodBuilder.addComment(
-        "We don't recycle the params as they will be stored for the async call and recycled"
-            + " afterwards");
+        callbackParameter.getSimpleName());
 
     classBuilder.addMethod(methodBuilder.build());
   }
@@ -329,14 +288,14 @@
         InternalCrossProfileClassGenerator.getInternalCrossProfileClassName(
             generatorContext, crossProfileType));
 
-    // parcel is passed into callAsync where it will be cached and recycled afterwards
-    methodBuilder.addStatement("$1T params = $1T.obtain()", PARCEL_CLASSNAME);
+    methodBuilder.addStatement(
+        "$1T params = new $1T($2T.class.getClassLoader())", BUNDLE_CLASSNAME, BUNDLER_CLASSNAME);
     for (VariableElement param : method.methodElement().getParameters()) {
       if (crossProfileType.supportedTypes().isAutomaticallyResolved(param.asType())) {
         continue;
       }
       methodBuilder.addStatement(
-          "internalCrossProfileClass.bundler().writeToParcel(params, $1L, $2L, /* flags= */ 0)",
+          "internalCrossProfileClass.bundler().writeToBundle(params, $1S, $1L, $2L)",
           param.getSimpleName(),
           TypeUtils.generateBundlerType(param.asType()));
     }
@@ -360,15 +319,9 @@
             .build());
     methodBuilder.addStatement(
         "connector.crossProfileSender().callAsync($1LL, $2L, params, futureWrapper,"
-            + " timeout == $3L ? $4L : timeout)",
+            + "futureWrapper.getFuture())",
         crossProfileType.identifier(),
-        method.identifier(),
-        CrossProfileAnnotation.TIMEOUT_MILLIS_NOT_SET,
-        method.timeoutMillis());
-
-    methodBuilder.addComment(
-        "We don't recycle the params as they will be stored for the async call and recycled"
-            + " afterwards");
+        method.identifier());
 
     methodBuilder.addStatement("return futureWrapper.getFuture()");
 
@@ -377,7 +330,6 @@
 
   static ClassName getOtherProfileClassName(
       GeneratorContext generatorContext, CrossProfileTypeInfo crossProfileType) {
-    return GeneratorUtilities.appendToClassName(
-        crossProfileType.profileClassName(), "_OtherProfile");
+    return transformClassName(crossProfileType.generatedClassName(), append("_OtherProfile"));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/Processor.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/Processor.java
index 4948196..729f27e 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/Processor.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/Processor.java
@@ -30,9 +30,11 @@
 import com.google.android.enterprise.connectedapps.annotations.CustomUserConnector;
 import com.google.android.enterprise.connectedapps.annotations.GeneratedProfileConnector;
 import com.google.android.enterprise.connectedapps.annotations.GeneratedUserConnector;
+import com.google.android.enterprise.connectedapps.processor.containers.Context;
 import com.google.android.enterprise.connectedapps.processor.containers.FutureWrapper;
 import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
 import com.google.android.enterprise.connectedapps.processor.containers.ParcelableWrapper;
+import com.google.android.enterprise.connectedapps.processor.containers.PreValidatorContext;
 import com.google.android.enterprise.connectedapps.processor.containers.ProfileConnectorInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.UserConnectorInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.ValidatorContext;
@@ -47,7 +49,6 @@
 import java.util.Set;
 import java.util.stream.Collectors;
 import javax.annotation.processing.AbstractProcessor;
-import javax.annotation.processing.ProcessingEnvironment;
 import javax.annotation.processing.RoundEnvironment;
 import javax.annotation.processing.SupportedAnnotationTypes;
 import javax.lang.model.SourceVersion;
@@ -55,11 +56,10 @@
 import javax.lang.model.element.ElementKind;
 import javax.lang.model.element.ExecutableElement;
 import javax.lang.model.element.TypeElement;
-import javax.lang.model.util.Elements;
-import javax.lang.model.util.Types;
 
 /** Processor for generation of cross-profile code. */
 @SupportedAnnotationTypes({
+  "com.google.android.enterprise.connectedapps.annotations.Cacheable",
   "com.google.android.enterprise.connectedapps.annotations.CrossProfile",
   "com.google.android.enterprise.connectedapps.annotations.CrossProfileCallback",
   "com.google.android.enterprise.connectedapps.annotations.CrossProfileConfiguration",
@@ -82,8 +82,6 @@
 @AutoService(javax.annotation.processing.Processor.class)
 public final class Processor extends AbstractProcessor {
 
-  private Types types;
-
   @Override
   public SourceVersion getSupportedSourceVersion() {
     return SourceVersion.latest();
@@ -91,27 +89,26 @@
 
   @Override
   public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
-    Elements elements = processingEnv.getElementUtils();
-    types = processingEnv.getTypeUtils();
+    PreValidatorContext preValidatorContext = new PreValidatorContext(processingEnv);
 
     Collection<ValidatorCrossProfileTestInfo> newCrossProfileTests =
-        findNewCrossProfileTests(roundEnv);
+        findNewCrossProfileTests(preValidatorContext, roundEnv);
     // Only new configurations need code generating - but we need to support types used by methods
     // included in configurations under test
     Collection<ValidatorCrossProfileConfigurationInfo> newConfigurations =
-        findNewConfigurations(roundEnv);
+        findNewConfigurations(preValidatorContext, roundEnv);
     Collection<ValidatorCrossProfileConfigurationInfo> allConfigurations =
-        findAllConfigurations(newConfigurations, newCrossProfileTests);
+        findAllConfigurations(preValidatorContext, newConfigurations, newCrossProfileTests);
 
-    Collection<ValidatorProviderClassInfo> newProviderClasses = findNewProviderClasses(roundEnv);
+    Collection<ValidatorProviderClassInfo> newProviderClasses =
+        findNewProviderClasses(preValidatorContext, roundEnv);
     Collection<ExecutableElement> newProviderMethods = findNewProviderMethods(roundEnv);
     Collection<TypeElement> newGeneratedConnectors = findNewGeneratedConnectors(roundEnv);
     Collection<TypeElement> newGeneratedUserConnectors = findNewGeneratedUserConnectors(roundEnv);
     Collection<ExecutableElement> newCrossProfileMethods = findNewCrossProfileMethods(roundEnv);
     Collection<ExecutableElement> allCrossProfileMethods =
         findAllCrossProfileMethods(
-            processingEnv,
-            elements,
+            preValidatorContext,
             newCrossProfileMethods,
             allConfigurations,
             newProviderMethods,
@@ -132,26 +129,23 @@
     Collection<TypeElement> newCustomFutureWrappers = findNewFutureWrappers(roundEnv);
 
     Collection<FutureWrapper> globalFutureWrappers =
-        FutureWrapper.createGlobalFutureWrappers(elements);
+        FutureWrapper.createGlobalFutureWrappers(preValidatorContext);
     Collection<ParcelableWrapper> globalParcelableWrappers =
-        ParcelableWrapper.createGlobalParcelableWrappers(types, elements, methods);
+        ParcelableWrapper.createGlobalParcelableWrappers(preValidatorContext, methods);
 
     SupportedTypes globalSupportedTypes =
         SupportedTypes.createFromMethods(
-            types, elements, globalParcelableWrappers, globalFutureWrappers, methods);
+            preValidatorContext, globalParcelableWrappers, globalFutureWrappers, methods);
 
     Collection<ValidatorCrossProfileTypeInfo> newCrossProfileTypes =
-        findNewCrossProfileTypes(roundEnv, globalSupportedTypes);
+        findNewCrossProfileTypes(preValidatorContext, roundEnv, globalSupportedTypes);
     Collection<ProfileConnectorInfo> newProfileConnectorInterfaces =
-        findNewProfileConnectorInterfaces(roundEnv, globalSupportedTypes);
+        findNewProfileConnectorInterfaces(preValidatorContext, roundEnv, globalSupportedTypes);
     Collection<UserConnectorInfo> newUserConnectorInterfaces =
-        findNewUserConnectorInterfaces(roundEnv, globalSupportedTypes);
+        findNewUserConnectorInterfaces(preValidatorContext, roundEnv, globalSupportedTypes);
 
     ValidatorContext validatorContext =
-        ValidatorContext.builder()
-            .setProcessingEnv(processingEnv)
-            .setElements(elements)
-            .setTypes(types)
+        ValidatorContext.builderFromPreValidatorContext(preValidatorContext)
             .setGlobalSupportedTypes(globalSupportedTypes)
             .setNewProfileConnectorInterfaces(newProfileConnectorInterfaces)
             .setNewUserConnectorInterfaces(newUserConnectorInterfaces)
@@ -189,27 +183,28 @@
   }
 
   private Collection<ValidatorCrossProfileConfigurationInfo> findNewConfigurations(
-      RoundEnvironment roundEnv) {
+      Context context, RoundEnvironment roundEnv) {
     Set<ValidatorCrossProfileConfigurationInfo> annotations = new HashSet<>();
 
     elementsAnnotatedWithCrossProfileConfiguration(roundEnv)
         .map(
             element ->
                 ValidatorCrossProfileConfigurationInfo.createFromElement(
-                    processingEnv, (TypeElement) element))
+                    context, (TypeElement) element))
         .forEach(annotations::add);
 
     elementsAnnotatedWithCrossProfileConfigurations(roundEnv)
         .map(
             element ->
                 ValidatorCrossProfileConfigurationInfo.createMultipleFromElement(
-                    processingEnv, (TypeElement) element))
+                    context, (TypeElement) element))
         .forEach(annotations::addAll);
 
     return annotations;
   }
 
   private Collection<ValidatorCrossProfileConfigurationInfo> findAllConfigurations(
+      Context context,
       Collection<ValidatorCrossProfileConfigurationInfo> newConfigurations,
       Collection<ValidatorCrossProfileTestInfo> crossProfileTests) {
     Set<ValidatorCrossProfileConfigurationInfo> allConfigurations = new HashSet<>();
@@ -219,18 +214,19 @@
             .flatMap(
                 t ->
                     ValidatorCrossProfileConfigurationInfo.createMultipleFromElement(
-                        processingEnv, t.configurationElement())
+                        context, t.configurationElement())
                         .stream())
             .collect(toSet()));
     return allConfigurations;
   }
 
-  private Collection<ValidatorProviderClassInfo> findNewProviderClasses(RoundEnvironment roundEnv) {
+  private Collection<ValidatorProviderClassInfo> findNewProviderClasses(
+      Context context, RoundEnvironment roundEnv) {
     Set<ValidatorProviderClassInfo> annotatedClasses =
         elementsAnnotatedWithCrossProfileProvider(roundEnv)
             .filter(m -> m instanceof TypeElement)
             .map(m -> (TypeElement) m)
-            .map(m -> ValidatorProviderClassInfo.create(processingEnv, m))
+            .map(m -> ValidatorProviderClassInfo.create(context, m))
             .collect(toSet());
 
     Set<ValidatorProviderClassInfo> unannotatedClasses =
@@ -240,7 +236,7 @@
             .map(Element::getEnclosingElement)
             .map(m -> (TypeElement) m)
             .filter(m -> !hasCrossProfileProviderAnnotation(m))
-            .map(m -> ValidatorProviderClassInfo.create(processingEnv, m))
+            .map(m -> ValidatorProviderClassInfo.create(context, m))
             .collect(toSet());
 
     Collection<ValidatorProviderClassInfo> allProviders = new HashSet<>();
@@ -257,12 +253,12 @@
   }
 
   private Collection<ValidatorCrossProfileTypeInfo> findNewCrossProfileTypes(
-      RoundEnvironment roundEnv, SupportedTypes globalSupportedTypes) {
+      Context context, RoundEnvironment roundEnv, SupportedTypes globalSupportedTypes) {
     Collection<ValidatorCrossProfileTypeInfo> annotatedTypes =
         elementsAnnotatedWithCrossProfile(roundEnv)
             .filter(m -> m instanceof TypeElement)
             .map(m -> (TypeElement) m)
-            .map(m -> ValidatorCrossProfileTypeInfo.create(processingEnv, m, globalSupportedTypes))
+            .map(m -> ValidatorCrossProfileTypeInfo.create(context, m, globalSupportedTypes))
             .collect(toSet());
 
     Collection<ValidatorCrossProfileTypeInfo> unannotatedTypes =
@@ -272,7 +268,7 @@
             .map(ExecutableElement::getEnclosingElement)
             .filter(m -> m instanceof TypeElement)
             .map(m -> (TypeElement) m)
-            .map(m -> ValidatorCrossProfileTypeInfo.create(processingEnv, m, globalSupportedTypes))
+            .map(m -> ValidatorCrossProfileTypeInfo.create(context, m, globalSupportedTypes))
             .collect(toSet());
 
     Collection<ValidatorCrossProfileTypeInfo> allTypes = new HashSet<>();
@@ -289,7 +285,7 @@
   }
 
   private Collection<ProfileConnectorInfo> findNewProfileConnectorInterfaces(
-      RoundEnvironment roundEnv, SupportedTypes globalSupportedTypes) {
+      Context context, RoundEnvironment roundEnv, SupportedTypes globalSupportedTypes) {
     Collection<TypeElement> connectorInterfaces =
         roundEnv.getElementsAnnotatedWith(CustomProfileConnector.class).stream()
             .map(m -> (TypeElement) m)
@@ -302,12 +298,12 @@
             .getTypeElement("com.google.android.enterprise.connectedapps.CrossProfileConnector"));
 
     return connectorInterfaces.stream()
-        .map(t -> ProfileConnectorInfo.create(processingEnv, t, globalSupportedTypes))
+        .map(t -> ProfileConnectorInfo.create(context, t, globalSupportedTypes))
         .collect(Collectors.toSet());
   }
 
   private Collection<UserConnectorInfo> findNewUserConnectorInterfaces(
-      RoundEnvironment roundEnv, SupportedTypes globalSupportedTypes) {
+      Context context, RoundEnvironment roundEnv, SupportedTypes globalSupportedTypes) {
     Collection<TypeElement> connectorInterfaces =
         roundEnv.getElementsAnnotatedWith(CustomUserConnector.class).stream()
             .map(m -> (TypeElement) m)
@@ -320,7 +316,7 @@
             .getTypeElement("com.google.android.enterprise.connectedapps.CrossUserConnector"));
 
     return connectorInterfaces.stream()
-        .map(t -> UserConnectorInfo.create(processingEnv, t, globalSupportedTypes))
+        .map(t -> UserConnectorInfo.create(context, t, globalSupportedTypes))
         .collect(Collectors.toSet());
   }
 
@@ -343,8 +339,7 @@
   }
 
   private static Collection<ExecutableElement> findAllCrossProfileMethods(
-      ProcessingEnvironment processingEnvironment,
-      Elements elements,
+      Context context,
       Collection<ExecutableElement> newCrossProfileMethods,
       Collection<ValidatorCrossProfileConfigurationInfo> configurations,
       Collection<ExecutableElement> newProviderMethods,
@@ -354,7 +349,7 @@
     Collection<ValidatorProviderClassInfo> foundProviderClasses =
         configurations.stream()
             .flatMap(a -> a.providerClassElements().stream())
-            .map(m -> ValidatorProviderClassInfo.create(processingEnvironment, m))
+            .map(m -> ValidatorProviderClassInfo.create(context, m))
             .collect(toSet());
 
     Collection<ExecutableElement> providerMethods =
@@ -370,7 +365,7 @@
 
     Collection<TypeElement> crossProfileTypes =
         providerMethods.stream()
-            .map(e -> elements.getTypeElement(e.getReturnType().toString()))
+            .map(e -> context.elements().getTypeElement(e.getReturnType().toString()))
             .filter(Objects::nonNull)
             .collect(toSet());
     crossProfileTypes.addAll(
@@ -388,10 +383,10 @@
   }
 
   private Collection<ValidatorCrossProfileTestInfo> findNewCrossProfileTests(
-      RoundEnvironment roundEnv) {
+      Context context, RoundEnvironment roundEnv) {
     return elementsAnnotatedWithCrossProfileTest(roundEnv)
         .map(e -> (TypeElement) e)
-        .map(e -> ValidatorCrossProfileTestInfo.create(processingEnv, e))
+        .map(e -> ValidatorCrossProfileTestInfo.create(context, e))
         .collect(toSet());
   }
 
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProfileConnectorCodeGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProfileConnectorCodeGenerator.java
index 68c14f2..87be4fa 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProfileConnectorCodeGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProfileConnectorCodeGenerator.java
@@ -15,6 +15,9 @@
  */
 package com.google.android.enterprise.connectedapps.processor;
 
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.getBuilderClassName;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.prepend;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.ABSTRACT_PROFILE_CONNECTOR_BUILDER_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.ABSTRACT_PROFILE_CONNECTOR_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.AVAILABILITY_RESTRICTIONS_CLASSNAME;
@@ -27,6 +30,7 @@
 import com.google.android.enterprise.connectedapps.annotations.GeneratedProfileConnector;
 import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
 import com.google.android.enterprise.connectedapps.processor.containers.ProfileConnectorInfo;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.squareup.javapoet.ClassName;
 import com.squareup.javapoet.CodeBlock;
 import com.squareup.javapoet.FieldSpec;
@@ -143,6 +147,7 @@
 
     classBuilder.addMethod(
         MethodSpec.methodBuilder("setScheduledExecutorService")
+            .addAnnotation(CanIgnoreReturnValue.class)
             .addModifiers(Modifier.PUBLIC)
             .addParameter(SCHEDULED_EXECUTOR_SERVICE_CLASSNAME, "scheduledExecutorService")
             .returns(builderClassName)
@@ -153,6 +158,7 @@
 
     classBuilder.addMethod(
         MethodSpec.methodBuilder("setBinder")
+            .addAnnotation(CanIgnoreReturnValue.class)
             .addModifiers(Modifier.PUBLIC)
             .addParameter(CONNECTION_BINDER_CLASSNAME, "binder")
             .returns(builderClassName)
@@ -172,18 +178,11 @@
 
   static ClassName getGeneratedProfileConnectorClassName(
       GeneratorContext generatorContext, ProfileConnectorInfo connector) {
-    return ClassName.get(
-        connector.connectorClassName().packageName(),
-        "Generated" + connector.connectorClassName().simpleName());
+    return transformClassName(connector.connectorClassName(), prepend("Generated"));
   }
 
   static ClassName getGeneratedProfileConnectorBuilderClassName(
       GeneratorContext generatorContext, ProfileConnectorInfo connector) {
-    return ClassName.get(
-        connector.connectorClassName().packageName()
-            + "."
-            + "Generated"
-            + connector.connectorClassName().simpleName(),
-        "Builder");
+    return getBuilderClassName(getGeneratedProfileConnectorClassName(generatorContext, connector));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProtoParcelableWrapperGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProtoParcelableWrapperGenerator.java
index 302b420..99753d5 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProtoParcelableWrapperGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProtoParcelableWrapperGenerator.java
@@ -77,7 +77,7 @@
 
     TypeSpec.Builder classBuilder =
         TypeSpec.classBuilder(wrapperClassName)
-            .addModifiers(Modifier.PUBLIC)
+            .addModifiers(Modifier.PUBLIC, Modifier.FINAL)
             .addSuperinterface(PARCELABLE_CLASSNAME)
             .addJavadoc(
                 "Wrapper for reading & writing {@link $T} instances to and from {@link $T}"
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProviderClassCodeGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProviderClassCodeGenerator.java
index 06f6b71..ae25706 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProviderClassCodeGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProviderClassCodeGenerator.java
@@ -17,6 +17,7 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
+import com.google.android.enterprise.connectedapps.processor.containers.ConnectorInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
 import com.google.android.enterprise.connectedapps.processor.containers.ProviderClassInfo;
@@ -28,12 +29,14 @@
   private final GeneratorContext generatorContext;
   private final InternalProviderClassGenerator internalProviderClassGenerator;
   private final ProviderClassInfo providerClass;
+  private final ConnectorInfo connectorInfo;
 
   ProviderClassCodeGenerator(GeneratorContext generatorContext, ProviderClassInfo providerClass) {
     this.generatorContext = checkNotNull(generatorContext);
     this.providerClass = checkNotNull(providerClass);
     this.internalProviderClassGenerator =
         new InternalProviderClassGenerator(generatorContext, providerClass);
+    this.connectorInfo = providerClass.connectorInfo();
   }
 
   void generate() {
@@ -45,9 +48,31 @@
 
     internalProviderClassGenerator.generate();
 
+    generatedSharedCrossConnectionApi();
+    if (connectorInfo.hasCrossProfileConnector()) {
+      generateCrossProfileApi();
+    }
+    if (connectorInfo.hasCrossUserConnector()) {
+      generateCrossUserApi();
+    }
+  }
+
+  private void generatedSharedCrossConnectionApi() {
+    for (CrossProfileTypeInfo crossProfileType : providerClass.allCrossProfileTypes()) {
+      new SharedTypeCodeGenerator(generatorContext, providerClass, crossProfileType).generate();
+    }
+  }
+
+  private void generateCrossProfileApi() {
     for (CrossProfileTypeInfo crossProfileType : providerClass.allCrossProfileTypes()) {
       new CrossProfileTypeCodeGenerator(generatorContext, providerClass, crossProfileType)
           .generate();
     }
   }
+
+  private void generateCrossUserApi() {
+    for (CrossProfileTypeInfo crossProfileType : providerClass.allCrossProfileTypes()) {
+      new CrossUserTypeCodeGenerator(generatorContext, providerClass, crossProfileType).generate();
+    }
+  }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ServiceGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ServiceGenerator.java
index 28fa128..fd8a2ce 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ServiceGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ServiceGenerator.java
@@ -16,6 +16,7 @@
 package com.google.android.enterprise.connectedapps.processor;
 
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BINDER_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.BUNDLE_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CROSSPROFILESERVICE_STUB_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CROSS_PROFILE_CALLBACK_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.INTENT_CLASSNAME;
@@ -82,7 +83,7 @@
                     + "<p>This service must be exposed in a <service> tag in your"
                     + " AndroidManifest.xml\n",
                 configuration.configurationElement(),
-                configuration.profileConnector().connectorClassName(),
+                configuration.connectorInfo().connectorClassName(),
                 getDispatcherClassName(generatorContext, configuration));
 
     addBinder(classBuilder);
@@ -112,8 +113,10 @@
                     .build());
 
     addPrepareCallMethod(binderBuilder);
+    addPrepareBundleMethod(binderBuilder);
     addCallMethod(binderBuilder);
     addFetchResponseMethod(binderBuilder);
+    addFetchResponseBundleMethod(binderBuilder);
 
     classBuilder.addField(
         FieldSpec.builder(CROSSPROFILESERVICE_STUB_CLASSNAME, "binder", Modifier.PRIVATE)
@@ -137,6 +140,20 @@
     classBuilder.addMethod(prepareCallMethod);
   }
 
+  private static void addPrepareBundleMethod(TypeSpec.Builder classBuilder) {
+    MethodSpec prepareCallMethod =
+        MethodSpec.methodBuilder("prepareBundle")
+            .addModifiers(Modifier.PUBLIC)
+            .addAnnotation(Override.class)
+            .addParameter(long.class, "callId")
+            .addParameter(int.class, "bundleId")
+            .addParameter(BUNDLE_CLASSNAME, "bundle")
+            .addStatement(
+                "dispatcher.prepareBundle(getApplicationContext(), callId, bundleId, bundle)")
+            .build();
+    classBuilder.addMethod(prepareCallMethod);
+  }
+
   private static void addCallMethod(TypeSpec.Builder classBuilder) {
     MethodSpec callMethod =
         MethodSpec.methodBuilder("call")
@@ -171,8 +188,22 @@
     classBuilder.addMethod(prepareCallMethod);
   }
 
+  private static void addFetchResponseBundleMethod(TypeSpec.Builder classBuilder) {
+    MethodSpec prepareCallMethod =
+        MethodSpec.methodBuilder("fetchResponseBundle")
+            .addModifiers(Modifier.PUBLIC)
+            .addAnnotation(Override.class)
+            .addParameter(long.class, "callId")
+            .addParameter(int.class, "bundleId")
+            .returns(BUNDLE_CLASSNAME)
+            .addStatement(
+                "return dispatcher.fetchResponseBundle(getApplicationContext(), callId, bundleId)")
+            .build();
+    classBuilder.addMethod(prepareCallMethod);
+  }
+
   static ClassName getConnectedAppsServiceClassName(
       GeneratorContext generatorContext, CrossProfileConfigurationInfo configuration) {
-    return configuration.profileConnector().serviceName();
+    return configuration.connectorInfo().serviceName();
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/SharedTypeCodeGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/SharedTypeCodeGenerator.java
new file mode 100644
index 0000000..ca6c105
--- /dev/null
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/SharedTypeCodeGenerator.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.processor;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
+import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
+import com.google.android.enterprise.connectedapps.processor.containers.ProviderClassInfo;
+
+class SharedTypeCodeGenerator {
+  private boolean generated = false;
+  private final InterfaceGenerator interfaceGenerator;
+  private final CurrentProfileGenerator currentProfileGenerator;
+  private final OtherProfileGenerator otherProfileGenerator;
+  private final IfAvailableGenerator ifAvailableGenerator;
+  private final AlwaysThrowsGenerator alwaysThrowsGenerator;
+  private final InternalCrossProfileClassGenerator internalCrossProfileClassGenerator;
+  private final BundlerGenerator bundlerGenerator;
+
+  public SharedTypeCodeGenerator(
+      GeneratorContext generatorContext,
+      ProviderClassInfo providerClass,
+      CrossProfileTypeInfo crossProfileType) {
+    checkNotNull(generatorContext);
+    checkNotNull(crossProfileType);
+    this.interfaceGenerator = new InterfaceGenerator(generatorContext, crossProfileType);
+    this.currentProfileGenerator = new CurrentProfileGenerator(generatorContext, crossProfileType);
+    this.otherProfileGenerator = new OtherProfileGenerator(generatorContext, crossProfileType);
+    this.ifAvailableGenerator = new IfAvailableGenerator(generatorContext, crossProfileType);
+    this.alwaysThrowsGenerator = new AlwaysThrowsGenerator(generatorContext, crossProfileType);
+    this.internalCrossProfileClassGenerator =
+        new InternalCrossProfileClassGenerator(generatorContext, providerClass, crossProfileType);
+    this.bundlerGenerator = new BundlerGenerator(generatorContext, crossProfileType);
+  }
+
+  void generate() {
+    if (generated) {
+      throw new IllegalStateException("SharedTypeCodeGenerator#generate can only be called once");
+    }
+    generated = true;
+
+    interfaceGenerator.generate();
+    currentProfileGenerator.generate();
+    otherProfileGenerator.generate();
+    ifAvailableGenerator.generate();
+    alwaysThrowsGenerator.generate();
+    internalCrossProfileClassGenerator.generate();
+    bundlerGenerator.generate();
+  }
+}
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/SupportedTypes.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/SupportedTypes.java
index 254c455..51eae3d 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/SupportedTypes.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/SupportedTypes.java
@@ -17,12 +17,12 @@
 
 import static com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationFinder.hasCrossProfileCallbackAnnotation;
 
+import com.google.android.enterprise.connectedapps.processor.containers.Context;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileCallbackInterfaceInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileMethodInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.FutureWrapper;
 import com.google.android.enterprise.connectedapps.processor.containers.ParcelableWrapper;
 import com.google.android.enterprise.connectedapps.processor.containers.Type;
-import com.google.android.enterprise.connectedapps.processor.containers.ValidatorContext;
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableMap;
@@ -50,8 +50,8 @@
   @Override
   public String toString() {
     return "SupportedTypes{" +
-            "usableTypes=" + usableTypes +
-            '}';
+        "usableTypes=" + usableTypes +
+        '}';
   }
 
   @Override
@@ -84,16 +84,16 @@
 
     public static TypeCheckContext create() {
       return new AutoValue_SupportedTypes_TypeCheckContext.Builder()
-              .setWrapped(false)
-              .setOnCrossProfileCallbackInterface(false)
-              .build();
+          .setWrapped(false)
+          .setOnCrossProfileCallbackInterface(false)
+          .build();
     }
 
     public static TypeCheckContext createForCrossProfileCallbackInterface() {
       return new AutoValue_SupportedTypes_TypeCheckContext.Builder()
-              .setWrapped(false)
-              .setOnCrossProfileCallbackInterface(true)
-              .build();
+          .setWrapped(false)
+          .setOnCrossProfileCallbackInterface(true)
+          .build();
     }
 
     @AutoValue.Builder
@@ -124,14 +124,15 @@
         return false; // We don't support generic arrays
       }
       if (TypeUtils.isArray(wrappedType)) {
-        return false; // We don't support multidimensional arrays
+        // We don't support non-primitive multidimensional arrays
+        return TypeUtils.isPrimitiveArray(wrappedType);
       }
       return isValidReturnType(wrappedType, context);
     }
 
     return TypeUtils.isGeneric(type)
-            ? isValidGenericReturnType(type, context)
-            : isValidReturnType(get(type), context);
+        ? isValidGenericReturnType(type, context)
+        : isValidReturnType(get(type), context);
   }
 
   private static boolean isValidReturnType(@Nullable Type supportedType, TypeCheckContext context) {
@@ -194,7 +195,8 @@
         return false; // We don't support generic arrays
       }
       if (TypeUtils.isArray(wrappedType)) {
-        return false; // We don't support multidimensional arrays
+        // We don't support non-primitive multidimensional arrays
+        return TypeUtils.isPrimitiveArray(wrappedType);
       }
       return isValidParameterType(wrappedType, context.toBuilder().setWrapped(true).build());
     }
@@ -213,8 +215,8 @@
     }
 
     return TypeUtils.isGeneric(type)
-            ? isValidGenericParameterType(type, context)
-            : isValidParameterType(get(type));
+        ? isValidGenericParameterType(type, context)
+        : isValidParameterType(get(type));
   }
 
   private static boolean isValidParameterType(Type supportedType) {
@@ -249,13 +251,33 @@
     return usableTypes.getOrDefault(type.toString(), null);
   }
 
+  CodeBlock generatePutIntoBundleCode(
+      String bundleName, Type type, String keyCode, String valueCode) {
+
+    if (type.getPutIntoBundleCode().isPresent()) {
+      return CodeBlock.of(type.getPutIntoBundleCode().get(), bundleName, keyCode, valueCode);
+    }
+
+    throw new IllegalArgumentException(
+        String.format("%s can not put into bundle", type.getQualifiedName()));
+  }
+
+  CodeBlock generateGetFromBundleCode(String bundleName, Type type, String keyCode) {
+    if (type.getGetFromBundleCode().isPresent()) {
+      return CodeBlock.of(type.getGetFromBundleCode().get(), bundleName, keyCode);
+    }
+
+    throw new IllegalArgumentException(
+        String.format("%s can not get from bundle", type.getQualifiedName()));
+  }
+
   CodeBlock generateWriteToParcelCode(String parcelName, Type type, String valueCode) {
     if (type.getWriteToParcelCode().isPresent()) {
       return CodeBlock.of(type.getWriteToParcelCode().get(), parcelName, valueCode);
     }
 
     throw new IllegalArgumentException(
-            String.format("%s can not write to parcel", type.getQualifiedName()));
+        String.format("%s can not write to parcel", type.getQualifiedName()));
   }
 
   CodeBlock generateReadFromParcelCode(String parcelName, Type type) {
@@ -264,7 +286,7 @@
     }
 
     throw new IllegalArgumentException(
-            String.format("%s can not read from parcel", type.getQualifiedName()));
+        String.format("%s can not read from parcel", type.getQualifiedName()));
   }
 
   public Type getType(TypeMirror type) {
@@ -281,50 +303,48 @@
   }
 
   public static SupportedTypes createFromMethods(
-          Types types,
-          Elements elements,
-          Collection<ParcelableWrapper> parcelableWrappers,
-          Collection<FutureWrapper> futureWrappers,
-          Collection<ExecutableElement> methods) {
+      Context context,
+      Collection<ParcelableWrapper> parcelableWrappers,
+      Collection<FutureWrapper> futureWrappers,
+      Collection<ExecutableElement> methods) {
     Map<String, Type> usableTypes = new HashMap<>();
 
-    addDefaultTypes(types, elements, usableTypes);
+    addDefaultTypes(context, usableTypes);
     addParcelableWrapperTypes(usableTypes, parcelableWrappers);
     addFutureWrapperTypes(usableTypes, futureWrappers);
-    addSupportForUsedTypes(types, elements, usableTypes, methods);
+    addSupportForUsedTypes(context, usableTypes, methods);
 
     return new SupportedTypes(usableTypes);
   }
 
   private static void addSupportForUsedTypes(
-          Types types,
-          Elements elements,
-          Map<String, Type> usableTypes,
-          Collection<ExecutableElement> methods) {
+      Context context, Map<String, Type> usableTypes, Collection<ExecutableElement> methods) {
     for (ExecutableElement method : methods) {
-      addSupportForUsedType(types, elements, usableTypes, method.getReturnType());
+      addSupportForUsedType(context, usableTypes, method.getReturnType());
 
       for (VariableElement parameter : method.getParameters()) {
-        addSupportForUsedType(types, elements, usableTypes, parameter.asType());
+        addSupportForUsedType(context, usableTypes, parameter.asType());
       }
     }
   }
 
   private static void addSupportForUsedType(
-          Types types, Elements elements, Map<String, Type> usableTypes, TypeMirror type) {
+      Context context, Map<String, Type> usableTypes, TypeMirror type) {
     if (TypeUtils.isArray(type)) {
-      addSupportForUsedType(types, elements, usableTypes, TypeUtils.extractTypeFromArray(type));
-      if (!TypeUtils.extractTypeFromArray(type).getKind().isPrimitive()) {
-        type = types.getArrayType(elements.getTypeElement("java.lang.Object").asType());
+      if (!TypeUtils.isPrimitiveArray(type)) {
+        addSupportForUsedType(context, usableTypes, TypeUtils.extractTypeFromArray(type));
+        type =
+            context
+                .types()
+                .getArrayType(context.elements().getTypeElement("java.lang.Object").asType());
       }
     }
 
-
     if (TypeUtils.isGeneric(type)) {
-      addSupportForGenericUsedType(types, elements, usableTypes, type);
+      addSupportForGenericUsedType(context, usableTypes, type);
       return;
     }
-    Optional<Type> optionalSupportedType = getSupportedType(types, elements, usableTypes, type);
+    Optional<Type> optionalSupportedType = getSupportedType(context, usableTypes, type);
     if (!optionalSupportedType.isPresent()) {
       // The type isn't supported
       return;
@@ -335,8 +355,8 @@
     // We don't support generic callbacks so any callback interfaces can be picked up here
     if (supportedType.isCrossProfileCallbackInterface()) {
       for (TypeMirror typeMirror :
-              supportedType.getCrossProfileCallbackInterface().get().argumentTypes()) {
-        addSupportForUsedType(types, elements, usableTypes, typeMirror);
+          supportedType.getCrossProfileCallbackInterface().get().argumentTypes()) {
+        addSupportForUsedType(context, usableTypes, typeMirror);
       }
     }
 
@@ -344,11 +364,10 @@
   }
 
   private static void addSupportForGenericUsedType(
-          Types types, Elements elements, Map<String, Type> usableTypes, TypeMirror type) {
+      Context context, Map<String, Type> usableTypes, TypeMirror type) {
     TypeMirror genericType = TypeUtils.removeTypeArguments(type);
 
-    Optional<Type> optionalSupportedType =
-            getSupportedType(types, elements, usableTypes, genericType);
+    Optional<Type> optionalSupportedType = getSupportedType(context, usableTypes, genericType);
     if (!optionalSupportedType.isPresent()) {
       // The base type isn't supported
       return;
@@ -360,13 +379,15 @@
 
     if (!supportedType.isSupportedWithAnyGenericType()) {
       for (TypeMirror typeArgument : TypeUtils.extractTypeArguments(type)) {
-        addSupportForUsedType(types, elements, usableTypes, typeArgument);
+        addSupportForUsedType(context, usableTypes, typeArgument);
       }
     }
   }
 
   private static Optional<Type> getSupportedType(
-          Types types, Elements elements, Map<String, Type> usableTypes, TypeMirror type) {
+      Context context, Map<String, Type> usableTypes, TypeMirror type) {
+    Elements elements = context.elements();
+    Types types = context.types();
     if (usableTypes.containsKey(type.toString())) {
       return Optional.of(usableTypes.get(type.toString()));
     }
@@ -393,37 +414,41 @@
 
   private static Type createCrossProfileCallbackType(TypeElement type) {
     return Type.builder()
-            .setTypeMirror(type.asType())
-            .setAcceptableReturnType(false)
-            .setAcceptableParameterType(true)
-            .setSupportedInsideWrapper(false)
-            .setSupportedInsideCrossProfileCallback(false)
-            .setCrossProfileCallbackInterface(CrossProfileCallbackInterfaceInfo.create(type))
-            .build();
+        .setTypeMirror(type.asType())
+        .setAcceptableReturnType(false)
+        .setAcceptableParameterType(true)
+        .setSupportedInsideWrapper(false)
+        .setSupportedInsideCrossProfileCallback(false)
+        .setCrossProfileCallbackInterface(CrossProfileCallbackInterfaceInfo.create(type))
+        .build();
   }
 
   private static Type createParcelableType(TypeMirror typeMirror) {
     return Type.builder()
-            .setTypeMirror(typeMirror)
-            .setAcceptableReturnType(true)
-            .setAcceptableParameterType(true)
-            .setWriteToParcelCode("$L.writeParcelable($L, flags)")
-            .setReadFromParcelCode("$L.readParcelable(Bundler.class.getClassLoader())")
-            // Parcelables must take care of their own generic types
-            .setSupportedWithAnyGenericType(true)
-            .build();
+        .setTypeMirror(typeMirror)
+        .setAcceptableReturnType(true)
+        .setAcceptableParameterType(true)
+        .setPutIntoBundleCode("$L.putParcelable($L, $L)")
+        .setGetFromBundleCode("$L.getParcelable($L)")
+        .setWriteToParcelCode("$L.writeParcelable($L, flags)")
+        .setReadFromParcelCode("$L.readParcelable(Bundler.class.getClassLoader())")
+        // Parcelables must take care of their own generic types
+        .setSupportedWithAnyGenericType(true)
+        .build();
   }
 
   private static Type createSerializableType(TypeMirror typeMirror) {
     return Type.builder()
-            .setTypeMirror(typeMirror)
-            .setAcceptableReturnType(true)
-            .setAcceptableParameterType(true)
-            .setWriteToParcelCode("$L.writeSerializable($L)")
-            .setReadFromParcelCode("$L.readSerializable()")
-            // Serializables must take care of their own generic types
-            .setSupportedWithAnyGenericType(true)
-            .build();
+        .setTypeMirror(typeMirror)
+        .setAcceptableReturnType(true)
+        .setAcceptableParameterType(true)
+        .setPutIntoBundleCode("$L.putSerializable($L, $L)")
+        .setGetFromBundleCode("$L.getSerializable($L)")
+        .setWriteToParcelCode("$L.writeSerializable($L)")
+        .setReadFromParcelCode("$L.readSerializable()")
+        // Serializables must take care of their own generic types
+        .setSupportedWithAnyGenericType(true)
+        .build();
   }
 
   /** Create a {@link Builder} to create a new {@link SupportedTypes} with modified entries. */
@@ -431,194 +456,326 @@
     return new Builder(usableTypes);
   }
 
-  private static void addDefaultTypes(
-          Types types, Elements elements, Map<String, Type> usableTypes) {
+  private static void addDefaultTypes(Context context, Map<String, Type> usableTypes) {
+    Elements elements = context.elements();
+    Types types = context.types();
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.getNoType(TypeKind.VOID))
-                    .setAcceptableReturnType(true)
-                    .setReadFromParcelCode("null")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.getNoType(TypeKind.VOID))
+            .setAcceptableReturnType(true)
+            .setGetFromBundleCode("null")
+            .setReadFromParcelCode("null")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(elements.getTypeElement("java.lang.Void").asType())
-                    .setAcceptableReturnType(true)
-                    .setReadFromParcelCode("null")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(elements.getTypeElement("java.lang.Void").asType())
+            .setAcceptableReturnType(true)
+            .setGetFromBundleCode("null")
+            .setReadFromParcelCode("null")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(elements.getTypeElement("java.lang.String").asType())
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeString($L)")
-                    .setReadFromParcelCode("$L.readString()")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(elements.getTypeElement("java.lang.String").asType())
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putString($L, $L)")
+            .setGetFromBundleCode("$L.getString($L)")
+            .setWriteToParcelCode("$L.writeString($L)")
+            .setReadFromParcelCode("$L.readString()")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(elements.getTypeElement("java.lang.CharSequence").asType())
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeString(String.valueOf($L))")
-                    .setReadFromParcelCode("$L.readString()")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(elements.getTypeElement("java.lang.CharSequence").asType())
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putString($L, String.valueOf($L))")
+            .setGetFromBundleCode("$L.getString($L)")
+            .setWriteToParcelCode("$L.writeString(String.valueOf($L))")
+            .setReadFromParcelCode("$L.readString()")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.getPrimitiveType(TypeKind.BYTE))
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeByte($L)")
-                    .setReadFromParcelCode("$L.readByte()")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.getPrimitiveType(TypeKind.BYTE))
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putByte($L, $L)")
+            .setGetFromBundleCode("$L.getByte($L)")
+            .setWriteToParcelCode("$L.writeByte($L)")
+            .setReadFromParcelCode("$L.readByte()")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.boxedClass(types.getPrimitiveType(TypeKind.BYTE)).asType())
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeByte($L)")
-                    .setReadFromParcelCode("$L.readByte()")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.boxedClass(types.getPrimitiveType(TypeKind.BYTE)).asType())
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putByte($L, $L)")
+            .setGetFromBundleCode("$L.getByte($L)")
+            .setWriteToParcelCode("$L.writeByte($L)")
+            .setReadFromParcelCode("$L.readByte()")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.getPrimitiveType(TypeKind.SHORT))
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeInt($L)")
-                    .setReadFromParcelCode("(short)$L.readInt()")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.getPrimitiveType(TypeKind.SHORT))
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putShort($L, $L)")
+            .setGetFromBundleCode("$L.getShort($L)")
+            .setWriteToParcelCode("$L.writeInt($L)")
+            .setReadFromParcelCode("(short)$L.readInt()")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.boxedClass(types.getPrimitiveType(TypeKind.SHORT)).asType())
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeInt($L)")
-                    .setReadFromParcelCode("(short)$L.readInt()")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.boxedClass(types.getPrimitiveType(TypeKind.SHORT)).asType())
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putShort($L, $L)")
+            .setGetFromBundleCode("$L.getShort($L)")
+            .setWriteToParcelCode("$L.writeInt($L)")
+            .setReadFromParcelCode("(short)$L.readInt()")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.getPrimitiveType(TypeKind.INT))
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeInt($L)")
-                    .setReadFromParcelCode("$L.readInt()")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.getPrimitiveType(TypeKind.INT))
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putInt($L, $L)")
+            .setGetFromBundleCode("$L.getInt($L)")
+            .setWriteToParcelCode("$L.writeInt($L)")
+            .setReadFromParcelCode("$L.readInt()")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.boxedClass(types.getPrimitiveType(TypeKind.INT)).asType())
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeInt($L)")
-                    .setReadFromParcelCode("$L.readInt()")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.boxedClass(types.getPrimitiveType(TypeKind.INT)).asType())
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putInt($L, $L)")
+            .setGetFromBundleCode("$L.getInt($L)")
+            .setWriteToParcelCode("$L.writeInt($L)")
+            .setReadFromParcelCode("$L.readInt()")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.getPrimitiveType(TypeKind.LONG))
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeLong($L)")
-                    .setReadFromParcelCode("$L.readLong()")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.getPrimitiveType(TypeKind.LONG))
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putLong($L, $L)")
+            .setGetFromBundleCode("$L.getLong($L)")
+            .setWriteToParcelCode("$L.writeLong($L)")
+            .setReadFromParcelCode("$L.readLong()")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.boxedClass(types.getPrimitiveType(TypeKind.LONG)).asType())
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeLong($L)")
-                    .setReadFromParcelCode("$L.readLong()")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.boxedClass(types.getPrimitiveType(TypeKind.LONG)).asType())
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putLong($L, $L)")
+            .setGetFromBundleCode("$L.getLong($L)")
+            .setWriteToParcelCode("$L.writeLong($L)")
+            .setReadFromParcelCode("$L.readLong()")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.getPrimitiveType(TypeKind.FLOAT))
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeFloat($L)")
-                    .setReadFromParcelCode("$L.readFloat()")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.getPrimitiveType(TypeKind.FLOAT))
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putFloat($L, $L)")
+            .setGetFromBundleCode("$L.getFloat($L)")
+            .setWriteToParcelCode("$L.writeFloat($L)")
+            .setReadFromParcelCode("$L.readFloat()")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.boxedClass(types.getPrimitiveType(TypeKind.FLOAT)).asType())
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeFloat($L)")
-                    .setReadFromParcelCode("$L.readFloat()")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.boxedClass(types.getPrimitiveType(TypeKind.FLOAT)).asType())
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putFloat($L, $L)")
+            .setGetFromBundleCode("$L.getFloat($L)")
+            .setWriteToParcelCode("$L.writeFloat($L)")
+            .setReadFromParcelCode("$L.readFloat()")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.getPrimitiveType(TypeKind.DOUBLE))
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeDouble($L)")
-                    .setReadFromParcelCode("$L.readDouble()")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.getPrimitiveType(TypeKind.DOUBLE))
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putDouble($L, $L)")
+            .setGetFromBundleCode("$L.getDouble($L)")
+            .setWriteToParcelCode("$L.writeDouble($L)")
+            .setReadFromParcelCode("$L.readDouble()")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.boxedClass(types.getPrimitiveType(TypeKind.DOUBLE)).asType())
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeDouble($L)")
-                    .setReadFromParcelCode("$L.readDouble()")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.boxedClass(types.getPrimitiveType(TypeKind.DOUBLE)).asType())
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putDouble($L, $L)")
+            .setGetFromBundleCode("$L.getDouble($L)")
+            .setWriteToParcelCode("$L.writeDouble($L)")
+            .setReadFromParcelCode("$L.readDouble()")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.getPrimitiveType(TypeKind.CHAR))
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeInt($L)")
-                    .setReadFromParcelCode("(char)$L.readInt()")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.getPrimitiveType(TypeKind.CHAR))
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putChar($L, $L)")
+            .setGetFromBundleCode("$L.getChar($L)")
+            .setWriteToParcelCode("$L.writeInt($L)")
+            .setReadFromParcelCode("(char)$L.readInt()")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.boxedClass(types.getPrimitiveType(TypeKind.CHAR)).asType())
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeInt($L)")
-                    .setReadFromParcelCode("(char)$L.readInt()")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.boxedClass(types.getPrimitiveType(TypeKind.CHAR)).asType())
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putChar($L, $L)")
+            .setGetFromBundleCode("$L.getChar($L)")
+            .setWriteToParcelCode("$L.writeInt($L)")
+            .setReadFromParcelCode("(char)$L.readInt()")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.getPrimitiveType(TypeKind.BOOLEAN))
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeInt($L ? 1 : 0)")
-                    .setReadFromParcelCode("($L.readInt() == 1)")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.getPrimitiveType(TypeKind.BOOLEAN))
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putBoolean($L, $L)")
+            .setGetFromBundleCode("$L.getBoolean($L)")
+            .setWriteToParcelCode("$L.writeInt($L ? 1 : 0)")
+            .setReadFromParcelCode("($L.readInt() == 1)")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(types.boxedClass(types.getPrimitiveType(TypeKind.BOOLEAN)).asType())
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeInt($L ? 1 : 0)")
-                    .setReadFromParcelCode("($L.readInt() == 1)")
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.boxedClass(types.getPrimitiveType(TypeKind.BOOLEAN)).asType())
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putBoolean($L, $L)")
+            .setGetFromBundleCode("$L.getBoolean($L)")
+            .setWriteToParcelCode("$L.writeInt($L ? 1 : 0)")
+            .setReadFromParcelCode("($L.readInt() == 1)")
+            .build());
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(elements.getTypeElement("android.content.Context").asType())
-                    .setAcceptableParameterType(true)
-                    .setAutomaticallyResolvedReplacement("context")
-                    .setAcceptableReturnType(false)
-                    .setSupportedInsideWrapper(false)
-                    .setSupportedInsideCrossProfileCallback(false)
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(elements.getTypeElement("android.content.Context").asType())
+            .setAcceptableParameterType(true)
+            .setAutomaticallyResolvedReplacement("context")
+            .setAcceptableReturnType(false)
+            .setSupportedInsideWrapper(false)
+            .setSupportedInsideCrossProfileCallback(false)
+            .build());
+
+    addUsableType(
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(elements.getTypeElement("android.os.Parcelable").asType())
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putParcelable($L, $L)")
+            .setGetFromBundleCode("$L.getParcelable($L)")
+            .setWriteToParcelCode("$L.writeParcelable($L, flags)")
+            .setReadFromParcelCode("$L.readParcelable(Bundler.class.getClassLoader())")
+            .build());
+
+    //region ****  Primitive Array Types  ****
+    addUsableType(
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.getArrayType(types.getPrimitiveType(TypeKind.BOOLEAN)))
+            .setAcceptableParameterType(true)
+            .setAcceptableReturnType(true)
+            .setPutIntoBundleCode("$L.putBooleanArray($L, $L)")
+            .setGetFromBundleCode("$L.getBooleanArray($L)")
+            .setWriteToParcelCode("$L.writeBooleanArray($L)")
+            .setReadFromParcelCode("$L.createBooleanArray()")
+            .build());
+    addUsableType(
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.getArrayType(types.getPrimitiveType(TypeKind.BYTE)))
+            .setAcceptableParameterType(true)
+            .setAcceptableReturnType(true)
+            .setPutIntoBundleCode("$L.putByteArray($L, $L)")
+            .setGetFromBundleCode("$L.getByteArray($L)")
+            .setWriteToParcelCode("$L.writeByteArray($L)")
+            .setReadFromParcelCode("$L.createByteArray()")
+            .build());
+    addUsableType(
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.getArrayType(types.getPrimitiveType(TypeKind.CHAR)))
+            .setAcceptableParameterType(true)
+            .setAcceptableReturnType(true)
+            .setPutIntoBundleCode("$L.putCharArray($L, $L)")
+            .setGetFromBundleCode("$L.getCharArray($L)")
+            .setWriteToParcelCode("$L.writeCharArray($L)")
+            .setReadFromParcelCode("$L.createCharArray()")
+            .build());
+    addUsableType(
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.getArrayType(types.getPrimitiveType(TypeKind.DOUBLE)))
+            .setAcceptableParameterType(true)
+            .setAcceptableReturnType(true)
+            .setPutIntoBundleCode("$L.putDoubleArray($L, $L)")
+            .setGetFromBundleCode("$L.getDoubleArray($L)")
+            .setWriteToParcelCode("$L.writeDoubleArray($L)")
+            .setReadFromParcelCode("$L.createDoubleArray()")
+            .build());
+    addUsableType(
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.getArrayType(types.getPrimitiveType(TypeKind.FLOAT)))
+            .setAcceptableParameterType(true)
+            .setAcceptableReturnType(true)
+            .setPutIntoBundleCode("$L.putFloatArray($L, $L)")
+            .setGetFromBundleCode("$L.getFloatArray($L)")
+            .setWriteToParcelCode("$L.writeFloatArray($L)")
+            .setReadFromParcelCode("$L.createFloatArray()")
+            .build());
+    addUsableType(
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.getArrayType(types.getPrimitiveType(TypeKind.INT)))
+            .setAcceptableParameterType(true)
+            .setAcceptableReturnType(true)
+            .setPutIntoBundleCode("$L.putIntArray($L, $L)")
+            .setGetFromBundleCode("$L.getIntArray($L)")
+            .setWriteToParcelCode("$L.writeIntArray($L)")
+            .setReadFromParcelCode("$L.createIntArray()")
+            .build());
+    addUsableType(
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(types.getArrayType(types.getPrimitiveType(TypeKind.LONG)))
+            .setAcceptableParameterType(true)
+            .setAcceptableReturnType(true)
+            .setPutIntoBundleCode("$L.putLongArray($L, $L)")
+            .setGetFromBundleCode("$L.getLongArray($L)")
+            .setWriteToParcelCode("$L.writeLongArray($L)")
+            .setReadFromParcelCode("$L.createLongArray()")
+            .build());
+    //endregion ****  Primitive Array Types  ****
+
   }
 
   private static void addUsableType(Map<String, Type> usableTypes, Type type) {
@@ -626,49 +783,52 @@
   }
 
   private static void addParcelableWrapperTypes(
-          Map<String, Type> usableTypes, Collection<ParcelableWrapper> parcelableWrappers) {
+      Map<String, Type> usableTypes, Collection<ParcelableWrapper> parcelableWrappers) {
     for (ParcelableWrapper parcelableWrapper : parcelableWrappers) {
       addParcelableWrapperType(usableTypes, parcelableWrapper);
     }
   }
 
   private static void addParcelableWrapperType(
-          Map<String, Type> usableTypes, ParcelableWrapper parcelableWrapper) {
+      Map<String, Type> usableTypes, ParcelableWrapper parcelableWrapper) {
     String createParcelableCode = parcelableWrapper.wrapperClassName() + ".of(this, valueType, $L)";
     // "this" will be a Bundler as this code is only run within a Bundler
 
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(parcelableWrapper.wrappedType())
-                    .setAcceptableReturnType(true)
-                    .setAcceptableParameterType(true)
-                    .setWriteToParcelCode("$L.writeParcelable(" + createParcelableCode + ", flags)")
-                    .setReadFromParcelCode(
-                            "(("
-                                    + parcelableWrapper.wrapperClassName()
-                                    + ") $L.readParcelable(Bundler.class.getClassLoader())).get()")
-                    .setParcelableWrapper(parcelableWrapper)
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(parcelableWrapper.wrappedType())
+            .setAcceptableReturnType(true)
+            .setAcceptableParameterType(true)
+            .setPutIntoBundleCode("$L.putParcelable($L, " + createParcelableCode + ")")
+            .setGetFromBundleCode(
+                "((" + parcelableWrapper.wrapperClassName() + ") $L.getParcelable($L)).get()")
+            .setWriteToParcelCode("$L.writeParcelable(" + createParcelableCode + ", flags)")
+            .setReadFromParcelCode(
+                "(("
+                    + parcelableWrapper.wrapperClassName()
+                    + ") $L.readParcelable(Bundler.class.getClassLoader())).get()")
+            .setParcelableWrapper(parcelableWrapper)
+            .build());
   }
 
   private static void addFutureWrapperTypes(
-          Map<String, Type> usableTypes, Collection<FutureWrapper> futureWrappers) {
+      Map<String, Type> usableTypes, Collection<FutureWrapper> futureWrappers) {
     for (FutureWrapper futureWrapper : futureWrappers) {
       addFutureWrapperType(usableTypes, futureWrapper);
     }
   }
 
   private static void addFutureWrapperType(
-          Map<String, Type> usableTypes, FutureWrapper futureWrapper) {
+      Map<String, Type> usableTypes, FutureWrapper futureWrapper) {
     addUsableType(
-            usableTypes,
-            Type.builder()
-                    .setTypeMirror(futureWrapper.wrappedType())
-                    .setAcceptableReturnType(true)
-                    .setSupportedInsideWrapper(false)
-                    .setFutureWrapper(futureWrapper)
-                    .build());
+        usableTypes,
+        Type.builder()
+            .setTypeMirror(futureWrapper.wrappedType())
+            .setAcceptableReturnType(true)
+            .setSupportedInsideWrapper(false)
+            .setFutureWrapper(futureWrapper)
+            .build());
   }
 
   public static final class Builder {
@@ -680,8 +840,7 @@
     }
 
     /** Filtering to only include used types. */
-    public Builder filterUsed(
-            ValidatorContext context, Collection<CrossProfileMethodInfo> methods) {
+    public Builder filterUsed(Context context, Collection<CrossProfileMethodInfo> methods) {
 
       Map<String, Type> usedTypes = new HashMap<>();
 
@@ -695,27 +854,27 @@
     }
 
     private void copySupportedTypesForMethod(
-            ValidatorContext context, Map<String, Type> usedTypes, CrossProfileMethodInfo method) {
+        Context context, Map<String, Type> usedTypes, CrossProfileMethodInfo method) {
       copySupportedType(context, usedTypes, method.returnType());
       for (TypeMirror argumentType : method.parameterTypes()) {
         copySupportedType(context, usedTypes, argumentType);
       }
     }
 
-    private void copySupportedType(
-            ValidatorContext context, Map<String, Type> usedTypes, TypeMirror type) {
+    private void copySupportedType(Context context, Map<String, Type> usedTypes, TypeMirror type) {
       if (TypeUtils.isGeneric(type)) {
         copySupportedGenericType(context, usedTypes, type);
         return;
       }
 
       if (TypeUtils.isArray(type)) {
-        copySupportedType(context, usedTypes, TypeUtils.extractTypeFromArray(type));
-        if (!TypeUtils.extractTypeFromArray(type).getKind().isPrimitive()) {
+        if (!TypeUtils.isPrimitiveArray(type)) {
+          // Primitive arrays aren't resolved recursively
+          copySupportedType(context, usedTypes, TypeUtils.extractTypeFromArray(type));
           type =
-                  context
-                          .types()
-                          .getArrayType(context.elements().getTypeElement("java.lang.Object").asType());
+              context
+                  .types()
+                  .getArrayType(context.elements().getTypeElement("java.lang.Object").asType());
         }
       }
 
@@ -726,7 +885,7 @@
       // We don't support generic callbacks so any callback interfaces can be picked up here
       if (supportedType.isCrossProfileCallbackInterface()) {
         for (TypeMirror typeMirror :
-                supportedType.getCrossProfileCallbackInterface().get().argumentTypes()) {
+            supportedType.getCrossProfileCallbackInterface().get().argumentTypes()) {
           copySupportedType(context, usedTypes, typeMirror);
         }
       }
@@ -739,7 +898,7 @@
     }
 
     private void copySupportedGenericType(
-            ValidatorContext context, Map<String, Type> usedTypes, TypeMirror type) {
+        Context context, Map<String, Type> usedTypes, TypeMirror type) {
       TypeMirror genericType = TypeUtils.removeTypeArguments(type);
 
       // The type must have been seen in when constructing the oldSupportedTypes so this should not
@@ -797,7 +956,7 @@
     }
 
     private void replaceParcelableWrapperPrefix(
-            Map<String, Type> newUsableTypes, ClassName prefix, Type usableType) {
+        Map<String, Type> newUsableTypes, ClassName prefix, Type usableType) {
       ParcelableWrapper parcelableWrapper = usableType.getParcelableWrapper().get();
 
       if (parcelableWrapper.wrapperType().equals(ParcelableWrapper.WrapperType.CUSTOM)) {
@@ -807,16 +966,16 @@
       }
 
       addParcelableWrapperType(
-              newUsableTypes,
-              ParcelableWrapper.create(
-                      parcelableWrapper.wrappedType(),
-                      parcelableWrapper.defaultWrapperClassName(),
-                      prefix(prefix, parcelableWrapper.wrapperClassName()),
-                      parcelableWrapper.wrapperType()));
+          newUsableTypes,
+          ParcelableWrapper.create(
+              parcelableWrapper.wrappedType(),
+              parcelableWrapper.defaultWrapperClassName(),
+              prefix(prefix, parcelableWrapper.wrapperClassName()),
+              parcelableWrapper.wrapperType()));
     }
 
     private void replaceFutureWrapperPrefix(
-            Map<String, Type> newUsableTypes, ClassName prefix, Type usableType) {
+        Map<String, Type> newUsableTypes, ClassName prefix, Type usableType) {
       FutureWrapper futureWrapper = usableType.getFutureWrapper().get();
 
       if (futureWrapper.wrapperType().equals(FutureWrapper.WrapperType.CUSTOM)) {
@@ -826,17 +985,17 @@
       }
 
       addFutureWrapperType(
-              newUsableTypes,
-              FutureWrapper.create(
-                      futureWrapper.wrappedType(),
-                      futureWrapper.defaultWrapperClassName(),
-                      prefix(prefix, futureWrapper.wrapperClassName()),
-                      futureWrapper.wrapperType()));
+          newUsableTypes,
+          FutureWrapper.create(
+              futureWrapper.wrappedType(),
+              futureWrapper.defaultWrapperClassName(),
+              prefix(prefix, futureWrapper.wrapperClassName()),
+              futureWrapper.wrapperType()));
     }
 
     private ClassName prefix(ClassName prefix, ClassName finalName) {
       return ClassName.get(
-              prefix.packageName(), prefix.simpleName() + "_" + finalName.simpleName());
+          prefix.packageName(), prefix.simpleName() + "_" + finalName.simpleName());
     }
 
     /** Build a new {@link SupportedTypes}. */
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/TestCodeGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/TestCodeGenerator.java
index 77483f5..e4f229a 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/TestCodeGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/TestCodeGenerator.java
@@ -17,11 +17,11 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
+import com.google.android.enterprise.connectedapps.processor.containers.ConnectorInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileConfigurationInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTestInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileTypeInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
-import com.google.android.enterprise.connectedapps.processor.containers.ProfileConnectorInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.ProviderClassInfo;
 import java.util.HashSet;
 import java.util.Set;
@@ -37,8 +37,10 @@
 final class TestCodeGenerator {
   private boolean generated = false;
   private final GeneratorContext generatorContext;
-  private final Set<CrossProfileTypeInfo> fakedTypes = new HashSet<>();
-  private final Set<ProfileConnectorInfo> fakedConnectors = new HashSet<>();
+  private final Set<ConnectorInfo> fakedConnectors = new HashSet<>();
+  private final Set<CrossProfileTypeInfo> allFakedTypes = new HashSet<>();
+  private final Set<CrossProfileTypeInfo> crossProfileFakedTypes = new HashSet<>();
+  private final Set<CrossProfileTypeInfo> crossUserFakedTypes = new HashSet<>();
 
   TestCodeGenerator(GeneratorContext generatorContext) {
     this.generatorContext = checkNotNull(generatorContext);
@@ -55,14 +57,29 @@
   }
 
   private void generateFakes() {
-    for (ProfileConnectorInfo connector : fakedConnectors) {
-      new FakeProfileConnectorGenerator(generatorContext, connector).generate();
+    for (ConnectorInfo connectorInfo : fakedConnectors) {
+      if (connectorInfo.hasCrossProfileConnector()) {
+        new FakeProfileConnectorGenerator(generatorContext, connectorInfo.profileConnector().get())
+            .generate();
+      }
+
+      if (connectorInfo.hasCrossUserConnector()) {
+        new FakeUserConnectorGenerator(generatorContext, connectorInfo.userConnector().get())
+            .generate();
+      }
     }
 
-    for (CrossProfileTypeInfo type : fakedTypes) {
-      new FakeCrossProfileTypeGenerator(generatorContext, type).generate();
+    for (CrossProfileTypeInfo type : allFakedTypes) {
       new FakeOtherGenerator(generatorContext, type).generate();
     }
+
+    for (CrossProfileTypeInfo type : crossProfileFakedTypes) {
+      new FakeCrossProfileTypeGenerator(generatorContext, type).generate();
+    }
+
+    for (CrossProfileTypeInfo type : crossUserFakedTypes) {
+      new FakeCrossUserTypeGenerator(generatorContext, type).generate();
+    }
   }
 
   private void collectTestTypes() {
@@ -78,20 +95,28 @@
   }
 
   private void collectTestTypes(CrossProfileConfigurationInfo configuration) {
-    for (ProviderClassInfo provider : configuration.providers()) {
-      collectTestTypes(provider);
+    if (configuration.connectorInfo().hasCrossProfileConnector()) {
+      for (ProviderClassInfo provider : configuration.providers()) {
+        collectTestTypes(crossProfileFakedTypes, provider);
+      }
+    }
+    if (configuration.connectorInfo().hasCrossUserConnector()) {
+      for (ProviderClassInfo provider : configuration.providers()) {
+        collectTestTypes(crossUserFakedTypes, provider);
+      }
     }
 
-    fakedConnectors.add(configuration.profileConnector());
+    fakedConnectors.add(configuration.connectorInfo());
   }
 
-  private void collectTestTypes(ProviderClassInfo provider) {
+  private void collectTestTypes(Set<CrossProfileTypeInfo> targetSet, ProviderClassInfo provider) {
     for (CrossProfileTypeInfo type : provider.allCrossProfileTypes()) {
-      collectTestTypes(type);
+      collectTestTypes(targetSet, type);
     }
   }
 
-  private void collectTestTypes(CrossProfileTypeInfo type) {
-    fakedTypes.add(type);
+  private void collectTestTypes(Set<CrossProfileTypeInfo> targetSet, CrossProfileTypeInfo type) {
+    allFakedTypes.add(type);
+    targetSet.add(type);
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/TypeUtils.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/TypeUtils.java
index ffa68c9..8ec5269 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/TypeUtils.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/TypeUtils.java
@@ -92,28 +92,37 @@
     return CodeBlock.of("$T.of($S)", BUNDLER_TYPE_CLASSNAME, getRawTypeQualifiedName(type));
   }
 
+  static TypeMirror getArrayRootType(TypeMirror type) {
+    while (TypeUtils.isArray(type)) {
+      type = TypeUtils.extractTypeFromArray(type);
+    }
+
+    return type;
+  }
+
+  static boolean isPrimitiveArray(TypeMirror type) {
+    return getArrayRootType(type).getKind().isPrimitive();
+  }
+
   private static CodeBlock generateArrayBundlerType(TypeMirror type) {
     TypeMirror arrayType = extractTypeFromArray(type);
 
-    if (arrayType.getKind().isPrimitive()) {
-      return CodeBlock.of(
-              "$T.of($S)",
-              BUNDLER_TYPE_CLASSNAME,
-              arrayType.toString() + "[]");
+    if (isPrimitiveArray(arrayType)) {
+      return CodeBlock.of("$T.of($S)", BUNDLER_TYPE_CLASSNAME, type);
     }
 
     return CodeBlock.of(
-            "$T.of($S, $L)",
-            BUNDLER_TYPE_CLASSNAME,
-            "java.lang.Object[]",
-            generateBundlerType(arrayType));
+        "$T.of($S, $L)",
+        BUNDLER_TYPE_CLASSNAME,
+        "java.lang.Object[]",
+        generateBundlerType(arrayType));
   }
 
   private static CodeBlock generateGenericBundlerType(TypeMirror type) {
     CodeBlock.Builder typeArgs = CodeBlock.builder();
 
     List<CodeBlock> typeArgBlocks =
-            extractTypeArguments(type).stream().map(TypeUtils::generateBundlerType).collect(toList());
+        extractTypeArguments(type).stream().map(TypeUtils::generateBundlerType).collect(toList());
 
     typeArgs.add(typeArgBlocks.get(0));
     for (CodeBlock typeArgBlock : typeArgBlocks.subList(1, typeArgBlocks.size())) {
@@ -121,7 +130,7 @@
     }
 
     return CodeBlock.of(
-            "$T.of($S, $L)", BUNDLER_TYPE_CLASSNAME, getRawTypeQualifiedName(type), typeArgs.build());
+        "$T.of($S, $L)", BUNDLER_TYPE_CLASSNAME, getRawTypeQualifiedName(type), typeArgs.build());
   }
 
   private TypeUtils() {}
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/UserConnectorCodeGenerator.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/UserConnectorCodeGenerator.java
index 415765b..646f1c7 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/UserConnectorCodeGenerator.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/UserConnectorCodeGenerator.java
@@ -15,17 +15,21 @@
  */
 package com.google.android.enterprise.connectedapps.processor;
 
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.getBuilderClassName;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.prepend;
+import static com.google.android.enterprise.connectedapps.processor.ClassNameUtilities.transformClassName;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.ABSTRACT_USER_CONNECTOR_BUILDER_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.ABSTRACT_USER_CONNECTOR_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.AVAILABILITY_RESTRICTIONS_CLASSNAME;
-import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CONNECTION_BINDER_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.CONTEXT_CLASSNAME;
 import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.SCHEDULED_EXECUTOR_SERVICE_CLASSNAME;
+import static com.google.android.enterprise.connectedapps.processor.CommonClassNames.USER_BINDER_FACTORY_CLASSNAME;
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.android.enterprise.connectedapps.annotations.GeneratedUserConnector;
 import com.google.android.enterprise.connectedapps.processor.containers.GeneratorContext;
 import com.google.android.enterprise.connectedapps.processor.containers.UserConnectorInfo;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.squareup.javapoet.ClassName;
 import com.squareup.javapoet.CodeBlock;
 import com.squareup.javapoet.FieldSpec;
@@ -56,7 +60,7 @@
   void generate() {
     if (generated) {
       throw new IllegalStateException(
-          "ProfileConnectorCodeGenerator#generate can only be called once");
+          "UserConnectorCodeGenerator#generate can only be called once");
     }
     generated = true;
 
@@ -92,7 +96,7 @@
             .addModifiers(Modifier.PRIVATE)
             .addParameter(builderClassName, "builder")
             .addStatement(
-                "super($1T.class, builder.profileConnectorBuilder)", connector.connectorClassName())
+                "super($1T.class, builder.userConnectorBuilder)", connector.connectorClassName())
             .build());
 
     generateUserConnectorBuilder(classBuilder);
@@ -100,7 +104,7 @@
     generatorUtilities.writeClassToFile(className.packageName(), classBuilder);
   }
 
-  private void generateUserConnectorBuilder(TypeSpec.Builder profileConnector) {
+  private void generateUserConnectorBuilder(TypeSpec.Builder userConnector) {
     ClassName connectorClassName = getGeneratedUserConnectorClassName(generatorContext, connector);
     ClassName builderClassName =
         getGeneratedUserConnectorBuilderClassName(generatorContext, connector);
@@ -121,31 +125,33 @@
     classBuilder.addMethod(
         MethodSpec.constructorBuilder()
             .addParameter(CONTEXT_CLASSNAME, "context")
-            .addStatement("profileConnectorBuilder.setContext(context)")
+            .addStatement("userConnectorBuilder.setContext(context)")
             .build());
 
     classBuilder.addField(
-        FieldSpec.builder(ABSTRACT_USER_CONNECTOR_BUILDER_CLASSNAME, "profileConnectorBuilder")
+        FieldSpec.builder(ABSTRACT_USER_CONNECTOR_BUILDER_CLASSNAME, "userConnectorBuilder")
             .addModifiers(Modifier.PRIVATE, Modifier.FINAL)
             .initializer(initialiser)
             .build());
 
     classBuilder.addMethod(
-        MethodSpec.methodBuilder("setScheduledExecutorService")
+        MethodSpec.methodBuilder("setBinderFactory")
+            .addAnnotation(CanIgnoreReturnValue.class)
             .addModifiers(Modifier.PUBLIC)
-            .addParameter(SCHEDULED_EXECUTOR_SERVICE_CLASSNAME, "scheduledExecutorService")
+            .addParameter(USER_BINDER_FACTORY_CLASSNAME, "binderFactory")
             .returns(builderClassName)
-            .addStatement(
-                "profileConnectorBuilder.setScheduledExecutorService(scheduledExecutorService)")
+            .addStatement("userConnectorBuilder.setBinderFactory(binderFactory)")
             .addStatement("return this")
             .build());
 
     classBuilder.addMethod(
-        MethodSpec.methodBuilder("setBinder")
+        MethodSpec.methodBuilder("setScheduledExecutorService")
+            .addAnnotation(CanIgnoreReturnValue.class)
             .addModifiers(Modifier.PUBLIC)
-            .addParameter(CONNECTION_BINDER_CLASSNAME, "binder")
+            .addParameter(SCHEDULED_EXECUTOR_SERVICE_CLASSNAME, "scheduledExecutorService")
             .returns(builderClassName)
-            .addStatement("profileConnectorBuilder.setBinder(binder)")
+            .addStatement(
+                "userConnectorBuilder.setScheduledExecutorService(scheduledExecutorService)")
             .addStatement("return this")
             .build());
 
@@ -156,23 +162,16 @@
             .addStatement("return new $1T(this)", connectorClassName)
             .build());
 
-    profileConnector.addType(classBuilder.build());
+    userConnector.addType(classBuilder.build());
   }
 
   static ClassName getGeneratedUserConnectorClassName(
       GeneratorContext generatorContext, UserConnectorInfo connector) {
-    return ClassName.get(
-        connector.connectorClassName().packageName(),
-        "Generated" + connector.connectorClassName().simpleName());
+    return transformClassName(connector.connectorClassName(), prepend("Generated"));
   }
 
   static ClassName getGeneratedUserConnectorBuilderClassName(
       GeneratorContext generatorContext, UserConnectorInfo connector) {
-    return ClassName.get(
-        connector.connectorClassName().packageName()
-            + "."
-            + "Generated"
-            + connector.connectorClassName().simpleName(),
-        "Builder");
+    return getBuilderClassName(getGeneratedUserConnectorClassName(generatorContext, connector));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/annotationdiscovery/AnnotationFinder.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/annotationdiscovery/AnnotationFinder.java
index 4923567..e051f53 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/annotationdiscovery/AnnotationFinder.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/annotationdiscovery/AnnotationFinder.java
@@ -29,6 +29,7 @@
 import com.google.android.enterprise.connectedapps.annotations.CrossUserConfigurations;
 import com.google.android.enterprise.connectedapps.annotations.CrossUserProvider;
 import com.google.android.enterprise.connectedapps.processor.ValidationMessageFormatter;
+import com.google.android.enterprise.connectedapps.processor.containers.Context;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileAnnotationInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileCallbackAnnotationInfo;
 import com.google.android.enterprise.connectedapps.processor.containers.CrossProfileConfigurationAnnotationInfo;
@@ -46,8 +47,6 @@
 import javax.lang.model.element.Element;
 import javax.lang.model.element.ExecutableElement;
 import javax.lang.model.element.TypeElement;
-import javax.lang.model.util.Elements;
-import javax.lang.model.util.Types;
 
 /** Helper methods to discover all cross-profile annotations of a specific type on elements. */
 public final class AnnotationFinder {
@@ -164,41 +163,39 @@
   }
 
   public static CrossProfileAnnotationInfo extractCrossProfileAnnotationInfo(
-      Element annotatedElement, Types types, Elements elements) {
+      Context context, Element annotatedElement) {
     return new CrossProfileAnnotationInfoExtractor()
-        .extractAnnotationInfo(SUPPORTED_ANNOTATIONS, annotatedElement, types, elements);
+        .extractAnnotationInfo(SUPPORTED_ANNOTATIONS, context, annotatedElement);
   }
 
   public static CrossProfileCallbackAnnotationInfo extractCrossProfileCallbackAnnotationInfo(
-      Element annotatedElement, Types types, Elements elements) {
+      Context context, Element annotatedElement) {
     return new CrossProfileCallbackAnnotationInfoExtractor()
-        .extractAnnotationInfo(SUPPORTED_ANNOTATIONS, annotatedElement, types, elements);
+        .extractAnnotationInfo(SUPPORTED_ANNOTATIONS, context, annotatedElement);
   }
 
   public static CrossProfileConfigurationAnnotationInfo
-      extractCrossProfileConfigurationAnnotationInfo(
-          Element annotatedElement, Types types, Elements elements) {
+      extractCrossProfileConfigurationAnnotationInfo(Context context, Element annotatedElement) {
     return new CrossProfileConfigurationAnnotationInfoExtractor()
-        .extractAnnotationInfo(SUPPORTED_ANNOTATIONS, annotatedElement, types, elements);
+        .extractAnnotationInfo(SUPPORTED_ANNOTATIONS, context, annotatedElement);
   }
 
   public static CrossProfileConfigurationsAnnotationInfo
-      extractCrossProfileConfigurationsAnnotationInfo(
-          Element annotatedElement, Types types, Elements elements) {
+      extractCrossProfileConfigurationsAnnotationInfo(Context context, Element annotatedElement) {
     return new CrossProfileConfigurationsAnnotationInfoExtractor()
-        .extractAnnotationInfo(SUPPORTED_ANNOTATIONS, annotatedElement, types, elements);
+        .extractAnnotationInfo(SUPPORTED_ANNOTATIONS, context, annotatedElement);
   }
 
   public static CrossProfileProviderAnnotationInfo extractCrossProfileProviderAnnotationInfo(
-      Element annotatedElement, Types types, Elements elements) {
+      Context context, Element annotatedElement) {
     return new CrossProfileProviderAnnotationInfoExtractor()
-        .extractAnnotationInfo(SUPPORTED_ANNOTATIONS, annotatedElement, types, elements);
+        .extractAnnotationInfo(SUPPORTED_ANNOTATIONS, context, annotatedElement);
   }
 
   public static CrossProfileTestAnnotationInfo extractCrossProfileTestAnnotationInfo(
-      Element annotatedElement, Types types, Elements elements) {
+      Context context, Element annotatedElement) {
     return new CrossProfileTestAnnotationInfoExtractor()
-        .extractAnnotationInfo(SUPPORTED_ANNOTATIONS, annotatedElement, types, elements);
+        .extractAnnotationInfo(SUPPORTED_ANNOTATIONS, context, annotatedElement);
   }
 
   public static boolean hasCrossProfileAnnotation(Element element) {
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/annotationdiscovery/AnnotationInfoExtractor.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/annotationdiscovery/AnnotationInfoExtractor.java
index 08022c4..06fe9a5 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/annotationdiscovery/AnnotationInfoExtractor.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/annotationdiscovery/AnnotationInfoExtractor.java
@@ -18,6 +18,7 @@
 import com.google.android.enterprise.connectedapps.annotations.CrossProfileProvider;
 import com.google.android.enterprise.connectedapps.annotations.CrossUserProvider;
 import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.interfaces.CrossProfileProviderAnnotation;
+import com.google.android.enterprise.connectedapps.processor.containers.Context;
 import java.lang.annotation.Annotation;
 import java.lang.reflect.Proxy;
 import javax.lang.model.element.Element;
@@ -42,20 +43,19 @@
    */
   AnnotationInfoT extractAnnotationInfo(
       Iterable<? extends AnnotationClasses> availableAnnotations,
-      Element annotatedElement,
-      Types types,
-      Elements elements) {
+      Context context,
+      Element annotatedElement) {
     for (AnnotationClasses annotationClasses : availableAnnotations) {
       Annotation annotation =
           annotatedElement.getAnnotation(supportedAnnotationClass(annotationClasses));
 
       if (annotation != null) {
         return annotationInfoFromAnnotation(
-            wrapAnnotationWithInterface(annotationInterfaceClass, annotation), types);
+            wrapAnnotationWithInterface(annotationInterfaceClass, annotation), context.types());
       }
     }
 
-    return emptyAnnotationInfo(elements);
+    return emptyAnnotationInfo(context.elements());
   }
 
   /**
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/annotationdiscovery/CrossProfileAnnotationInfoExtractor.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/annotationdiscovery/CrossProfileAnnotationInfoExtractor.java
index 49e737d..7403ddb 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/annotationdiscovery/CrossProfileAnnotationInfoExtractor.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/annotationdiscovery/CrossProfileAnnotationInfoExtractor.java
@@ -43,7 +43,6 @@
         CrossProfileAnnotationInfo.builder()
             .setConnectorClass(
                 GeneratorUtilities.extractClassFromAnnotation(types, annotation::connector))
-            .setProfileClassName(annotation.profileClassName())
             .setParcelableWrapperClasses(
                 ImmutableSet.copyOf(
                     GeneratorUtilities.extractClassesFromAnnotation(
@@ -54,12 +53,6 @@
                         types, annotation::futureWrappers)))
             .setIsStatic(annotation.isStatic());
 
-    long timeoutMillis = annotation.timeoutMillis();
-
-    if (timeoutMillis != CrossProfileAnnotation.TIMEOUT_MILLIS_NOT_SET) {
-      builder.setTimeoutMillis(timeoutMillis);
-    }
-
     return builder.build();
   }
 
@@ -69,7 +62,6 @@
         .setConnectorClass(
             elements.getTypeElement(
                 "com.google.android.enterprise.connectedapps.annotations.CrossProfile"))
-        .setProfileClassName("")
         .setParcelableWrapperClasses(ImmutableSet.of())
         .setFutureWrapperClasses(ImmutableSet.of())
         .setIsStatic(false)
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/annotationdiscovery/interfaces/CrossProfileAnnotation.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/annotationdiscovery/interfaces/CrossProfileAnnotation.java
index 8e76d9c..0d3226e 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/annotationdiscovery/interfaces/CrossProfileAnnotation.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/annotationdiscovery/interfaces/CrossProfileAnnotation.java
@@ -17,13 +17,6 @@
 
 /** Elements that can be populated on annotations of type CrossProfile. */
 public interface CrossProfileAnnotation {
-
-  long DEFAULT_TIMEOUT_MILLIS = 10000;
-
-  long TIMEOUT_MILLIS_NOT_SET = -1;
-
-  String profileClassName();
-
   Class<?> connector();
 
   Class<?>[] parcelableWrappers();
@@ -31,6 +24,4 @@
   Class<?>[] futureWrappers();
 
   boolean isStatic();
-
-  long timeoutMillis();
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ConnectorInfo.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ConnectorInfo.java
new file mode 100644
index 0000000..df1ce5e
--- /dev/null
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ConnectorInfo.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.processor.containers;
+
+import com.google.android.enterprise.connectedapps.annotations.UncaughtExceptionsPolicy;
+import com.google.android.enterprise.connectedapps.processor.SupportedTypes;
+import com.google.auto.value.AutoValue;
+import com.squareup.javapoet.ClassName;
+import java.util.Optional;
+import java.util.function.Function;
+import javax.lang.model.element.TypeElement;
+
+/** Wrapper of the connectors specified for a connected app. */
+@AutoValue
+public abstract class ConnectorInfo {
+
+  private static final String CROSS_PROFILE_CONNECTOR_QUALIFIED_NAME =
+      "com.google.android.enterprise.connectedapps.CrossProfileConnector";
+  private static final String CROSS_USER_CONNECTOR_QUALIFIED_NAME =
+      "com.google.android.enterprise.connectedapps.CrossUserConnector";
+  private static final String PROFILE_CONNECTOR_QUALIFIED_NAME =
+      "com.google.android.enterprise.connectedapps.ProfileConnector";
+  private static final String USER_CONNECTOR_QUALIFIED_NAME =
+      "com.google.android.enterprise.connectedapps.UserConnector";
+
+  public static boolean isProfileConnector(Context context, TypeElement connectorElement) {
+    return isConnectorOfType(context, connectorElement, PROFILE_CONNECTOR_QUALIFIED_NAME);
+  }
+
+  public static boolean isUserConnector(Context context, TypeElement connectorElement) {
+    return isConnectorOfType(context, connectorElement, USER_CONNECTOR_QUALIFIED_NAME);
+  }
+
+  private static boolean isConnectorOfType(
+      Context context, TypeElement connectorElement, String requiredType) {
+    return context
+        .types()
+        .isAssignable(
+            connectorElement.asType(), context.elements().getTypeElement(requiredType).asType());
+  }
+
+  public boolean hasCrossProfileConnector() {
+    return profileConnector().isPresent();
+  }
+
+  public boolean hasCrossUserConnector() {
+    return userConnector().isPresent();
+  }
+
+  public abstract Optional<ProfileConnectorInfo> profileConnector();
+
+  public abstract Optional<UserConnectorInfo> userConnector();
+
+  public TypeElement connectorElement() {
+    return getElement(ProfileConnectorInfo::connectorElement, UserConnectorInfo::connectorElement);
+  }
+
+  public ClassName connectorClassName() {
+    return getElement(
+        ProfileConnectorInfo::connectorClassName, UserConnectorInfo::connectorClassName);
+  }
+
+  public ClassName serviceName() {
+    return getElement(ProfileConnectorInfo::serviceName, UserConnectorInfo::serviceName);
+  }
+
+  public SupportedTypes supportedTypes() {
+    return getElement(ProfileConnectorInfo::supportedTypes, UserConnectorInfo::supportedTypes);
+  }
+
+  public UncaughtExceptionsPolicy uncaughtExceptionsPolicy() {
+    return profileConnector()
+        .map(ProfileConnectorInfo::uncaughtExceptionsPolicy)
+        .orElse(UncaughtExceptionsPolicy.NOTIFY_RETHROW);
+  }
+
+  /**
+   * Tries to get an element from {@link #profileConnector()} if present, or from {@link
+   * #userConnector()} otherwise.
+   *
+   * <p>Throws an exception if no connectors are specified, but this should not be possible (now and
+   * in the future).
+   */
+  private <T> T getElement(
+      Function<ProfileConnectorInfo, T> getFromProfileConnector,
+      Function<UserConnectorInfo, T> getFromUserConnector) {
+    return profileConnector()
+        .map(getFromProfileConnector)
+        .orElseGet(
+            () ->
+                userConnector()
+                    .map(getFromUserConnector)
+                    .orElseThrow(
+                        () -> new UnsupportedOperationException("No connectors specified")));
+  }
+
+  public static ConnectorInfo invalid(
+      Context context, TypeElement connector, SupportedTypes globalSupportedTypes) {
+    return noSpecificConnector(context, globalSupportedTypes, connector, connector);
+  }
+
+  public static ConnectorInfo unspecified(Context context, SupportedTypes globalSupportedTypes) {
+    return noSpecificConnector(
+        context,
+        globalSupportedTypes,
+        context.elements().getTypeElement(CROSS_PROFILE_CONNECTOR_QUALIFIED_NAME),
+        context.elements().getTypeElement(CROSS_USER_CONNECTOR_QUALIFIED_NAME));
+  }
+
+  private static ConnectorInfo noSpecificConnector(
+      Context context,
+      SupportedTypes globalSupportedTypes,
+      TypeElement profileConnector,
+      TypeElement userConnector) {
+    return new AutoValue_ConnectorInfo(
+        Optional.of(ProfileConnectorInfo.create(context, profileConnector, globalSupportedTypes)),
+        Optional.of(UserConnectorInfo.create(context, userConnector, globalSupportedTypes)));
+  }
+
+  public static ConnectorInfo forProfileConnector(
+      Context context, TypeElement connectorElement, SupportedTypes globalSupportedTypes) {
+    return new AutoValue_ConnectorInfo(
+        Optional.of(ProfileConnectorInfo.create(context, connectorElement, globalSupportedTypes)),
+        Optional.empty());
+  }
+
+  public static ConnectorInfo forUserConnector(
+      Context context, TypeElement connectorElement, SupportedTypes globalSupportedTypes) {
+    return new AutoValue_ConnectorInfo(
+        Optional.empty(),
+        Optional.of(UserConnectorInfo.create(context, connectorElement, globalSupportedTypes)));
+  }
+}
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileAnnotationInfo.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileAnnotationInfo.java
index f083a69..c75cfc2 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileAnnotationInfo.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileAnnotationInfo.java
@@ -18,7 +18,6 @@
 import com.google.android.enterprise.connectedapps.annotations.CrossProfile;
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableCollection;
-import java.util.Optional;
 import javax.lang.model.element.TypeElement;
 
 /** Wrapper around information contained in an annotation of type {@link CrossProfile}. */
@@ -30,10 +29,6 @@
 
   public abstract TypeElement connectorClass();
 
-  public abstract String profileClassName();
-
-  public abstract Optional<Long> timeoutMillis();
-
   public abstract ImmutableCollection<TypeElement> parcelableWrapperClasses();
 
   public abstract ImmutableCollection<TypeElement> futureWrapperClasses();
@@ -44,10 +39,6 @@
     return connectorClass().asType().toString().equals(DEFAULT_CONNECTOR_NAME);
   }
 
-  public boolean isProfileClassNameDefault() {
-    return profileClassName().isEmpty();
-  }
-
   public static Builder builder() {
     return new AutoValue_CrossProfileAnnotationInfo.Builder();
   }
@@ -57,10 +48,6 @@
 
     public abstract Builder setConnectorClass(TypeElement value);
 
-    public abstract Builder setProfileClassName(String value);
-
-    public abstract Builder setTimeoutMillis(Long value);
-
     public abstract Builder setParcelableWrapperClasses(ImmutableCollection<TypeElement> value);
 
     public abstract Builder setFutureWrapperClasses(ImmutableCollection<TypeElement> value);
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileCallbackParameterInfo.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileCallbackParameterInfo.java
new file mode 100644
index 0000000..4a3279a
--- /dev/null
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileCallbackParameterInfo.java
@@ -0,0 +1,27 @@
+package com.google.android.enterprise.connectedapps.processor.containers;
+
+import com.google.android.enterprise.connectedapps.annotations.CrossProfileCallback;
+import com.google.auto.value.AutoValue;
+import javax.lang.model.element.Name;
+import javax.lang.model.element.VariableElement;
+
+/** Wrapper of a {@link CrossProfileCallback} parameter on a method. */
+@AutoValue
+public abstract class CrossProfileCallbackParameterInfo {
+
+  public abstract CrossProfileCallbackInterfaceInfo crossProfileCallbackInterface();
+
+  public abstract VariableElement variable();
+
+  public Name getSimpleName() {
+    return variable().getSimpleName();
+  }
+
+  public static CrossProfileCallbackParameterInfo create(
+      Context context, VariableElement variableElement) {
+    return new AutoValue_CrossProfileCallbackParameterInfo(
+        CrossProfileCallbackInterfaceInfo.create(
+            context.elements().getTypeElement(variableElement.asType().toString())),
+        variableElement);
+  }
+}
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileConfigurationInfo.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileConfigurationInfo.java
index 2d4ed43..245a6c0 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileConfigurationInfo.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileConfigurationInfo.java
@@ -15,30 +15,19 @@
  */
 package com.google.android.enterprise.connectedapps.processor.containers;
 
-import static java.util.stream.Collectors.toSet;
-
 import com.google.android.enterprise.connectedapps.annotations.CrossProfileConfiguration;
-import com.google.android.enterprise.connectedapps.processor.SupportedTypes;
-import com.google.android.enterprise.connectedapps.processor.TypeUtils;
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
 import com.squareup.javapoet.ClassName;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashSet;
 import java.util.Optional;
 import javax.lang.model.element.TypeElement;
-import javax.lang.model.type.TypeMirror;
 
 /** Wrapper of a {@link CrossProfileConfiguration} annotated class. */
 @AutoValue
 public abstract class CrossProfileConfigurationInfo {
 
-  public static final String CROSS_PROFILE_CONNECTOR_QUALIFIED_NAME =
-      "com.google.android.enterprise.connectedapps.CrossProfileConnector";
-
   public abstract TypeElement configurationElement();
 
   public abstract ImmutableCollection<ProviderClassInfo> providers();
@@ -55,65 +44,44 @@
     return ClassName.get(configurationElement());
   }
 
-  public abstract ProfileConnectorInfo profileConnector();
+  public abstract ConnectorInfo connectorInfo();
 
   public static CrossProfileConfigurationInfo create(
       ValidatorContext context, ValidatorCrossProfileConfigurationInfo configuration) {
-    Collection<ProviderClassInfo> providerClasses =
-        configuration.providerClassElements().stream()
-            .map(
-                m ->
-                    ProviderClassInfo.create(
-                        context, ValidatorProviderClassInfo.create(context.processingEnv(), m)))
-            .collect(toSet());
+    ImmutableSet.Builder<ProviderClassInfo> providerClassesBuilder = ImmutableSet.builder();
+    configuration.providerClassElements().stream()
+        .map(m -> ProviderClassInfo.create(context, ValidatorProviderClassInfo.create(context, m)))
+        .forEach(providerClassesBuilder::add);
+    ImmutableSet<ProviderClassInfo> providerClasses = providerClassesBuilder.build();
 
-    ProfileConnectorInfo profileConnectorInfo =
+    ConnectorInfo connectorInfo =
         providerClasses.stream()
             .flatMap(m -> m.allCrossProfileTypes().stream())
-            .map(CrossProfileTypeInfo::profileConnector)
+            .map(CrossProfileTypeInfo::connectorInfo)
             .flatMap(Streams::stream)
             .findFirst()
-            .orElseGet(
-                () ->
-                    ProfileConnectorInfo.create(
-                        context.processingEnv(),
-                        getConfiguredConnectorOrDefault(context, configuration),
-                        context.globalSupportedTypes()));
+            .orElseGet(() -> defaultConnector(context, configuration));
 
     return new AutoValue_CrossProfileConfigurationInfo(
         configuration.configurationElement(),
-        ImmutableSet.copyOf(providerClasses),
+        providerClasses,
         configuration.serviceSuperclass(),
         configuration.serviceClass(),
-        profileConnectorInfo);
+        connectorInfo);
   }
 
-  private static TypeElement getConfiguredConnectorOrDefault(
+  private static ConnectorInfo defaultConnector(
       ValidatorContext context, ValidatorCrossProfileConfigurationInfo configuration) {
-    return configuration
-        .connector()
-        .orElseGet(() -> context.elements().getTypeElement(CROSS_PROFILE_CONNECTOR_QUALIFIED_NAME));
-  }
-
-  private static Collection<Type> convertTypeMirrorToSupportedTypes(
-      SupportedTypes supportedTypes, TypeMirror typeMirror) {
-    if (TypeUtils.isGeneric(typeMirror)) {
-      return convertGenericTypeMirrorToSupportedTypes(supportedTypes, typeMirror);
-    }
-    return Collections.singleton(supportedTypes.getType(typeMirror));
-  }
-
-  private static Collection<Type> convertGenericTypeMirrorToSupportedTypes(
-      SupportedTypes supportedTypes, TypeMirror typeMirror) {
-    Collection<Type> types = new HashSet<>();
-    TypeMirror genericType = TypeUtils.removeTypeArguments(typeMirror);
-    Type supportedType = supportedTypes.getType(genericType);
-    if (!supportedType.isSupportedWithAnyGenericType()) {
-      for (TypeMirror typeArgument : TypeUtils.extractTypeArguments(typeMirror)) {
-        types.addAll(convertTypeMirrorToSupportedTypes(supportedTypes, typeArgument));
+    if (configuration.connector().isPresent()) {
+      if (ConnectorInfo.isProfileConnector(context, configuration.connector().get())) {
+        return ConnectorInfo.forProfileConnector(
+            context, configuration.connector().get(), context.globalSupportedTypes());
+      } else if (ConnectorInfo.isUserConnector(context, configuration.connector().get())) {
+        return ConnectorInfo.forUserConnector(
+            context, configuration.connector().get(), context.globalSupportedTypes());
       }
     }
-    types.add(supportedTypes.getType(genericType));
-    return types;
+
+    return ConnectorInfo.unspecified(context, context.globalSupportedTypes());
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileMethodInfo.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileMethodInfo.java
index 72831a9..6a90652 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileMethodInfo.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileMethodInfo.java
@@ -15,17 +15,15 @@
  */
 package com.google.android.enterprise.connectedapps.processor.containers;
 
-import static com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationFinder.hasCrossProfileAnnotation;
 import static com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationFinder.hasCrossProfileCallbackAnnotation;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.android.enterprise.connectedapps.annotations.Cacheable;
 import com.google.android.enterprise.connectedapps.annotations.CrossProfile;
 import com.google.android.enterprise.connectedapps.annotations.CrossProfileCallback;
 import com.google.android.enterprise.connectedapps.processor.SupportedTypes;
 import com.google.android.enterprise.connectedapps.processor.TypeUtils;
-import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationFinder;
-import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.interfaces.CrossProfileAnnotation;
 import com.google.auto.value.AutoValue;
 import com.squareup.javapoet.ClassName;
 import com.squareup.javapoet.TypeName;
@@ -50,6 +48,8 @@
 
   public abstract boolean isStatic();
 
+  public abstract boolean isCacheable();
+
   public String simpleName() {
     return methodElement().getSimpleName().toString();
   }
@@ -75,12 +75,6 @@
   }
 
   /**
-   * The number of milliseconds to timeout async calls. This is either set on the method, the type,
-   * or defaults to {@link CrossProfileAnnotation#DEFAULT_TIMEOUT_MILLIS}.
-   */
-  public abstract long timeoutMillis();
-
-  /**
    * Specify behaviour when encountering parameters of a type which is automatically resolved by the
    * SDK.
    */
@@ -165,13 +159,11 @@
 
   /** True if there is only a single {@link CrossProfileCallback} argument and it is simple. */
   public boolean isSimpleCrossProfileCallback(GeneratorContext generatorContext) {
-    Optional<VariableElement> param = getCrossProfileCallbackParam(generatorContext);
+    Optional<CrossProfileCallbackParameterInfo> param =
+        getCrossProfileCallbackParam(generatorContext);
 
     if (param.isPresent()) {
-      CrossProfileCallbackInterfaceInfo callbackInterface =
-          CrossProfileCallbackInterfaceInfo.create(
-              (TypeElement) generatorContext.types().asElement(param.get().asType()));
-      return callbackInterface.isSimple();
+      return param.get().crossProfileCallbackInterface().isSimple();
     }
 
     return false;
@@ -187,16 +179,18 @@
   }
 
   /** Return the {@link CrossProfileCallback} annotated parameter, if any. */
-  public Optional<VariableElement> getCrossProfileCallbackParam(GeneratorContext generatorContext) {
-    return getCrossProfileCallbackParam(generatorContext.elements(), methodElement());
+  public Optional<CrossProfileCallbackParameterInfo> getCrossProfileCallbackParam(
+      GeneratorContext generatorContext) {
+    return getCrossProfileCallbackParam(generatorContext, methodElement());
   }
 
-  public static Optional<VariableElement> getCrossProfileCallbackParam(
-      Elements elements, ExecutableElement method) {
+  public static Optional<CrossProfileCallbackParameterInfo> getCrossProfileCallbackParam(
+      Context context, ExecutableElement method) {
     return method.getParameters().stream()
-        .filter(v -> isCrossProfileCallbackInterface(elements, v.asType()))
+        .filter(v -> isCrossProfileCallbackInterface(context.elements(), v.asType()))
         .findFirst()
-        .map(e -> (VariableElement) e);
+        .map(e -> (VariableElement) e)
+        .map(e -> CrossProfileCallbackParameterInfo.create(context, e));
   }
 
   private static boolean isCrossProfileCallbackInterface(Elements elements, TypeMirror type) {
@@ -213,19 +207,6 @@
         methodElement,
         identifier,
         methodElement.getModifiers().contains(Modifier.STATIC),
-        findTimeoutMillis(type, methodElement, context));
-  }
-
-  private static long findTimeoutMillis(
-      ValidatorCrossProfileTypeInfo type, ExecutableElement methodElement, Context context) {
-    if (hasCrossProfileAnnotation(methodElement)) {
-      return AnnotationFinder.extractCrossProfileAnnotationInfo(
-              methodElement, context.types(), context.elements())
-          .timeoutMillis()
-          .filter(timeout -> timeout > 0)
-          .orElse(type.timeoutMillis());
-    }
-
-    return type.timeoutMillis();
+        methodElement.getAnnotation(Cacheable.class) != null);
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileTestInfo.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileTestInfo.java
index a2219be..51d6c49 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileTestInfo.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileTestInfo.java
@@ -36,7 +36,7 @@
 
     Set<CrossProfileConfigurationInfo> configurations =
         ValidatorCrossProfileConfigurationInfo.createMultipleFromElement(
-                context.processingEnv(), validatorCrossProfileTest.configurationElement())
+                context, validatorCrossProfileTest.configurationElement())
             .stream()
             .map(b -> CrossProfileConfigurationInfo.create(context, b))
             .collect(toSet());
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileTypeInfo.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileTypeInfo.java
index fffe4a1..869eb15 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileTypeInfo.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/CrossProfileTypeInfo.java
@@ -15,14 +15,11 @@
  */
 package com.google.android.enterprise.connectedapps.processor.containers;
 
-import static com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationFinder.hasCrossProfileAnnotation;
-import static java.util.stream.Collectors.toSet;
+import static java.util.stream.Collectors.toCollection;
 
 import com.google.android.enterprise.connectedapps.annotations.CrossProfile;
 import com.google.android.enterprise.connectedapps.processor.ProcessorConfiguration;
 import com.google.android.enterprise.connectedapps.processor.SupportedTypes;
-import com.google.android.enterprise.connectedapps.processor.TypeUtils;
-import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.interfaces.CrossProfileAnnotation;
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableSet;
@@ -30,15 +27,12 @@
 import com.squareup.javapoet.ClassName;
 import java.nio.charset.StandardCharsets;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Optional;
 import java.util.stream.IntStream;
 import javax.lang.model.element.ExecutableElement;
-import javax.lang.model.element.PackageElement;
 import javax.lang.model.element.TypeElement;
-import javax.lang.model.type.TypeMirror;
 
 /** Wrapper of a {@link CrossProfile} type. */
 @AutoValue
@@ -50,15 +44,13 @@
 
   public abstract SupportedTypes supportedTypes();
 
-  public abstract Optional<ProfileConnectorInfo> profileConnector();
-
-  public abstract ClassName profileClassName();
+  public abstract Optional<ConnectorInfo> connectorInfo();
 
   /**
-   * The specified timeout for async calls, or {@link CrossProfileAnnotation#DEFAULT_TIMEOUT_MILLIS}
-   * if unspecified.
+   * The verbatim (not prefixed) name of the interface class used to make cross-profile or
+   * cross-user calls.
    */
-  public abstract long timeoutMillis();
+  public abstract ClassName generatedClassName();
 
   public String simpleName() {
     return crossProfileTypeElement().getSimpleName().toString();
@@ -96,7 +88,7 @@
                 t ->
                     CrossProfileMethodInfo.create(
                         t, crossProfileType, crossProfileMethodElements.get(t), context))
-            .collect(toSet());
+            .collect(toCollection(LinkedHashSet::new));
 
     SupportedTypes.Builder supportedTypesBuilder = crossProfileType.supportedTypes().asBuilder();
 
@@ -112,60 +104,14 @@
         crossProfileTypeElement,
         ImmutableSet.copyOf(crossProfileMethods),
         supportedTypesBuilder.build(),
-        crossProfileType.profileConnector(),
-        findProfileClassName(context, crossProfileTypeElement, crossProfileType),
-        crossProfileType.timeoutMillis());
+        crossProfileType.connectorInfo(),
+        findGeneratedClassName(context, crossProfileTypeElement));
   }
 
-  private static ClassName findProfileClassName(
-      ValidatorContext context,
-      TypeElement typeElement,
-      ValidatorCrossProfileTypeInfo crossProfileType) {
-    return hasCrossProfileAnnotation(typeElement)
-        ? findAnnotatedProfileClassName(context, typeElement, crossProfileType)
-        : createDefaultProfileClassName(context, typeElement);
-  }
-
-  private static ClassName createDefaultProfileClassName(
+  private static ClassName findGeneratedClassName(
       ValidatorContext context, TypeElement typeElement) {
-    PackageElement originalPackage = context.elements().getPackageOf(typeElement);
-    String profileAwareClassName =
-        String.format("Profile%s", typeElement.getSimpleName().toString());
-
-    return ClassName.get(originalPackage.getQualifiedName().toString(), profileAwareClassName);
-  }
-
-  private static ClassName findAnnotatedProfileClassName(
-      ValidatorContext context,
-      TypeElement typeElement,
-      ValidatorCrossProfileTypeInfo crossProfileType) {
-    String profileClassName = crossProfileType.profileClassName();
-    if (!profileClassName.isEmpty()) {
-      return ClassName.bestGuess(profileClassName);
-    }
-
-    return createDefaultProfileClassName(context, typeElement);
-  }
-
-  private static Collection<Type> convertTypeMirrorToSupportedTypes(
-      SupportedTypes supportedTypes, TypeMirror typeMirror) {
-    if (TypeUtils.isGeneric(typeMirror)) {
-      return convertGenericTypeMirrorToSupportedTypes(supportedTypes, typeMirror);
-    }
-    return Collections.singleton(supportedTypes.getType(typeMirror));
-  }
-
-  private static Collection<Type> convertGenericTypeMirrorToSupportedTypes(
-      SupportedTypes supportedTypes, TypeMirror typeMirror) {
-    Collection<Type> types = new HashSet<>();
-    TypeMirror genericType = TypeUtils.removeTypeArguments(typeMirror);
-    Type supportedType = supportedTypes.getType(genericType);
-    if (!supportedType.isSupportedWithAnyGenericType()) {
-      for (TypeMirror typeArgument : TypeUtils.extractTypeArguments(typeMirror)) {
-        types.addAll(convertTypeMirrorToSupportedTypes(supportedTypes, typeArgument));
-      }
-    }
-    types.add(supportedTypes.getType(genericType));
-    return types;
+    return ClassName.get(
+        context.elements().getPackageOf(typeElement).getQualifiedName().toString(),
+        typeElement.getSimpleName().toString());
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/FutureWrapper.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/FutureWrapper.java
index 5208e7c..e5f705a 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/FutureWrapper.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/FutureWrapper.java
@@ -22,8 +22,6 @@
 import java.util.Collection;
 import javax.lang.model.element.TypeElement;
 import javax.lang.model.type.TypeMirror;
-import javax.lang.model.util.Elements;
-import javax.lang.model.util.Types;
 
 /** Information about future wrapper. */
 @AutoValue
@@ -60,18 +58,18 @@
         wrappedType, defaultWrapperClassName, wrapperClassName, wrapperType);
   }
 
-  public static Collection<FutureWrapper> createGlobalFutureWrappers(Elements elements) {
+  public static Collection<FutureWrapper> createGlobalFutureWrappers(Context context) {
     Collection<FutureWrapper> wrappers = new ArrayList<>();
 
-    addDefaultFutureWrappers(elements, wrappers);
+    addDefaultFutureWrappers(context, wrappers);
 
     return wrappers;
   }
 
   private static void addDefaultFutureWrappers(
-      Elements elements, Collection<FutureWrapper> wrappers) {
+      Context context, Collection<FutureWrapper> wrappers) {
     tryAddWrapper(
-        elements,
+        context,
         wrappers,
         "com.google.common.util.concurrent.ListenableFuture",
         ClassName.get(FUTURE_WRAPPER_PACKAGE, "ListenableFutureWrapper"),
@@ -79,29 +77,25 @@
   }
 
   public static Collection<FutureWrapper> createCustomFutureWrappers(
-      Types types, Elements elements, Collection<TypeElement> customFutureWrappers) {
+      Context context, Collection<TypeElement> customFutureWrappers) {
     Collection<FutureWrapper> wrappers = new ArrayList<>();
 
-    addCustomFutureWrappers(types, elements, wrappers, customFutureWrappers);
+    addCustomFutureWrappers(context, wrappers, customFutureWrappers);
 
     return wrappers;
   }
 
   private static void addCustomFutureWrappers(
-      Types types,
-      Elements elements,
+      Context context,
       Collection<FutureWrapper> wrappers,
       Collection<TypeElement> customFutureWrappers) {
     for (TypeElement customFutureWrapper : customFutureWrappers) {
-      addCustomFutureWrapper(types, elements, wrappers, customFutureWrapper);
+      addCustomFutureWrapper(context, wrappers, customFutureWrapper);
     }
   }
 
   private static void addCustomFutureWrapper(
-      Types types,
-      Elements elements,
-      Collection<FutureWrapper> wrappers,
-      TypeElement customFutureWrapper) {
+      Context context, Collection<FutureWrapper> wrappers, TypeElement customFutureWrapper) {
     CustomFutureWrapper customFutureWrapperAnnotation =
         customFutureWrapper.getAnnotation(CustomFutureWrapper.class);
 
@@ -111,10 +105,10 @@
     }
 
     tryAddWrapper(
-        elements,
+        context,
         wrappers,
         FutureWrapperAnnotationInfo.extractFromFutureWrapperAnnotation(
-                types, customFutureWrapperAnnotation)
+                context, customFutureWrapperAnnotation)
             .originalType()
             .toString(),
         ClassName.get(customFutureWrapper),
@@ -122,12 +116,12 @@
   }
 
   private static void tryAddWrapper(
-      Elements elements,
+      Context context,
       Collection<FutureWrapper> wrappers,
       String typeQualifiedName,
       ClassName wrapperClassName,
       WrapperType wrapperType) {
-    TypeElement typeElement = elements.getTypeElement(typeQualifiedName);
+    TypeElement typeElement = context.elements().getTypeElement(typeQualifiedName);
 
     if (typeElement == null) {
       // The type isn't supported at compile-time - so won't be included in this app
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/FutureWrapperAnnotationInfo.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/FutureWrapperAnnotationInfo.java
index 18fb34b..169ac33 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/FutureWrapperAnnotationInfo.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/FutureWrapperAnnotationInfo.java
@@ -19,7 +19,6 @@
 import com.google.android.enterprise.connectedapps.processor.GeneratorUtilities;
 import com.google.auto.value.AutoValue;
 import javax.lang.model.element.TypeElement;
-import javax.lang.model.util.Types;
 
 /**
  * Wrapper around information contained in a {@link
@@ -31,14 +30,14 @@
   public abstract TypeElement originalType();
 
   public static FutureWrapperAnnotationInfo extractFromFutureWrapperAnnotation(
-      Types types, CustomFutureWrapper customFutureWrapperAnnotation) {
+      Context context, CustomFutureWrapper customFutureWrapperAnnotation) {
     if (customFutureWrapperAnnotation == null) {
       throw new NullPointerException("customFutureWrapperAnnotation must not be null");
     }
 
     TypeElement originalType =
         GeneratorUtilities.extractClassFromAnnotation(
-            types, customFutureWrapperAnnotation::originalType);
+            context.types(), customFutureWrapperAnnotation::originalType);
 
     return new AutoValue_FutureWrapperAnnotationInfo(originalType);
   }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/GeneratorContext.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/GeneratorContext.java
index ac47218..d61621c 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/GeneratorContext.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/GeneratorContext.java
@@ -55,9 +55,7 @@
             .map(
                 t ->
                     ProfileConnectorInfo.create(
-                        validatorContext.processingEnv(),
-                        t,
-                        validatorContext.globalSupportedTypes()))
+                        validatorContext, t, validatorContext.globalSupportedTypes()))
             .collect(toSet());
 
     Collection<UserConnectorInfo> generatedUserConnectors =
@@ -65,9 +63,7 @@
             .map(
                 t ->
                     UserConnectorInfo.create(
-                        validatorContext.processingEnv(),
-                        t,
-                        validatorContext.globalSupportedTypes()))
+                        validatorContext, t, validatorContext.globalSupportedTypes()))
             .collect(toSet());
 
     Collection<CrossProfileTestInfo> crossProfileTests =
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ParcelableWrapper.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ParcelableWrapper.java
index 114abfb..87f699f 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ParcelableWrapper.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ParcelableWrapper.java
@@ -31,7 +31,6 @@
 import javax.lang.model.element.TypeElement;
 import javax.lang.model.type.TypeMirror;
 import javax.lang.model.util.Elements;
-import javax.lang.model.util.Types;
 
 /** Information about a Parcelable Wrapper. */
 @AutoValue
@@ -70,23 +69,23 @@
   }
 
   public static Collection<ParcelableWrapper> createCustomParcelableWrappers(
-      Types types, Elements elements, Collection<TypeElement> customParcelableWrappers) {
+      Context context, Collection<TypeElement> customParcelableWrappers) {
     Collection<ParcelableWrapper> wrappers = new ArrayList<>();
 
-    addCustomParcelableWrappers(types, wrappers, customParcelableWrappers);
+    addCustomParcelableWrappers(context, wrappers, customParcelableWrappers);
 
     return wrappers;
   }
 
   public static Collection<ParcelableWrapper> createGlobalParcelableWrappers(
-      Types types, Elements elements, Collection<ExecutableElement> methods) {
+      Context context, Collection<ExecutableElement> methods) {
     Collection<ParcelableWrapper> wrappers = new ArrayList<>();
 
-    addDefaultParcelableWrappers(types, elements, wrappers);
+    addDefaultParcelableWrappers(context, wrappers);
 
     Collection<TypeMirror> usedTypes = extractTypesFromMethods(methods);
 
-    addGeneratedProtoParcelableWrappers(types, elements, wrappers, usedTypes);
+    addGeneratedProtoParcelableWrappers(context, wrappers, usedTypes);
 
     return wrappers;
   }
@@ -129,16 +128,16 @@
   }
 
   private static void addCustomParcelableWrappers(
-      Types types,
+      Context context,
       Collection<ParcelableWrapper> wrappers,
       Collection<TypeElement> customParcelableWrappers) {
     for (TypeElement parcelableWrapper : customParcelableWrappers) {
-      addCustomParcelableWrapper(types, wrappers, parcelableWrapper);
+      addCustomParcelableWrapper(context, wrappers, parcelableWrapper);
     }
   }
 
   private static void addCustomParcelableWrapper(
-      Types types, Collection<ParcelableWrapper> wrappers, TypeElement parcelableWrapper) {
+      Context context, Collection<ParcelableWrapper> wrappers, TypeElement parcelableWrapper) {
 
     CustomParcelableWrapper customParcelableWrapperAnnotation =
         parcelableWrapper.getAnnotation(CustomParcelableWrapper.class);
@@ -150,7 +149,7 @@
 
     ParcelableWrapperAnnotationInfo annotationInfo =
         ParcelableWrapperAnnotationInfo.extractFromParcelableWrapperAnnotation(
-            types, customParcelableWrapperAnnotation);
+            context, customParcelableWrapperAnnotation);
     wrappers.add(
         ParcelableWrapper.create(
             annotationInfo.originalType().asType(),
@@ -159,7 +158,8 @@
   }
 
   private static void addDefaultParcelableWrappers(
-      Types types, Elements elements, Collection<ParcelableWrapper> wrappers) {
+      Context context, Collection<ParcelableWrapper> wrappers) {
+    Elements elements = context.elements();
     tryAddWrapper(
         elements,
         wrappers,
@@ -214,15 +214,18 @@
         "android.graphics.Bitmap",
         ClassName.get(PARCELABLE_WRAPPER_PACKAGE, "ParcelableBitmap"));
 
-    addArrayWrappers(types, elements, wrappers);
+    tryAddWrapper(
+        elements,
+        wrappers,
+        "android.graphics.drawable.Drawable",
+        ClassName.get(PARCELABLE_WRAPPER_PACKAGE, "ParcelableDrawable"));
+
+    addArrayWrappers(context, wrappers);
   }
 
   private static void addGeneratedProtoParcelableWrappers(
-      Types types,
-      Elements elements,
-      Collection<ParcelableWrapper> wrappers,
-      Collection<TypeMirror> usedTypes) {
-    TypeElement protoElement = elements.getTypeElement("com.google.protobuf.MessageLite");
+      Context context, Collection<ParcelableWrapper> wrappers, Collection<TypeMirror> usedTypes) {
+    TypeElement protoElement = context.elements().getTypeElement("com.google.protobuf.MessageLite");
     if (protoElement == null) {
       // Protos are not included at compile-time
       return;
@@ -231,12 +234,12 @@
 
     Collection<TypeMirror> protoTypes =
         usedTypes.stream()
-                // <any> is the value when the compiler encounters a type which isn't accessible
-                // or does not exist. This passes the types.isAssignable filter, which makes such
-                // bugs hard to debug. This will already fail because the Java compiler won't allow
-                // it - so this is just to suppress strange test failures
+            // <any> is the value when the compiler encounters a type which isn't accessible
+            // or does not exist. This passes the types.isAssignable filter, which makes such
+            // bugs hard to debug. This will already fail because the Java compiler won't allow
+            // it - so this is just to suppress strange test failures
             .filter(t -> !t.toString().equals("<any>"))
-            .filter(t -> types.isAssignable(t, proto))
+            .filter(t -> context.types().isAssignable(t, proto))
             .collect(toSet());
 
     for (TypeMirror protoType : protoTypes) {
@@ -246,10 +249,9 @@
     }
   }
 
-  private static void addArrayWrappers(
-      Types types, Elements elements, Collection<ParcelableWrapper> wrappers) {
-    TypeElement typeElement = elements.getTypeElement("java.lang.Object");
-    TypeMirror typeMirror = types.getArrayType(typeElement.asType());
+  private static void addArrayWrappers(Context context, Collection<ParcelableWrapper> wrappers) {
+    TypeElement typeElement = context.elements().getTypeElement("java.lang.Object");
+    TypeMirror typeMirror = context.types().getArrayType(typeElement.asType());
 
     ClassName wrapperClassName = ClassName.get(PARCELABLE_WRAPPER_PACKAGE, "ParcelableArray");
 
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ParcelableWrapperAnnotationInfo.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ParcelableWrapperAnnotationInfo.java
index d9e7949..2d87109 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ParcelableWrapperAnnotationInfo.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ParcelableWrapperAnnotationInfo.java
@@ -19,7 +19,6 @@
 import com.google.android.enterprise.connectedapps.processor.GeneratorUtilities;
 import com.google.auto.value.AutoValue;
 import javax.lang.model.element.TypeElement;
-import javax.lang.model.util.Types;
 
 /** Wrapper around information contained in a {@link CustomParcelableWrapper} annotation. */
 @AutoValue
@@ -28,14 +27,14 @@
   public abstract TypeElement originalType();
 
   public static ParcelableWrapperAnnotationInfo extractFromParcelableWrapperAnnotation(
-      Types types, CustomParcelableWrapper customParcelableWrapperAnnotation) {
+      Context context, CustomParcelableWrapper customParcelableWrapperAnnotation) {
     if (customParcelableWrapperAnnotation == null) {
       throw new NullPointerException("parcelableWrapperAnnotation must not be null");
     }
 
     TypeElement originalType =
         GeneratorUtilities.extractClassFromAnnotation(
-            types, customParcelableWrapperAnnotation::originalType);
+            context.types(), customParcelableWrapperAnnotation::originalType);
 
     return new AutoValue_ParcelableWrapperAnnotationInfo(originalType);
   }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/PreValidatorContext.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/PreValidatorContext.java
new file mode 100644
index 0000000..803db78
--- /dev/null
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/PreValidatorContext.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.processor.containers;
+
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.lang.model.util.Elements;
+import javax.lang.model.util.Types;
+
+/** Context for connected apps code run before validation. */
+public final class PreValidatorContext extends Context {
+
+  private final ProcessingEnvironment processingEnv;
+
+  public PreValidatorContext(ProcessingEnvironment processingEnv) {
+    this.processingEnv = processingEnv;
+  }
+
+  @Override
+  public ProcessingEnvironment processingEnv() {
+    return processingEnv;
+  }
+
+  @Override
+  public Elements elements() {
+    return processingEnv.getElementUtils();
+  }
+
+  @Override
+  public Types types() {
+    return processingEnv.getTypeUtils();
+  }
+}
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ProfileConnectorInfo.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ProfileConnectorInfo.java
index 9a68099..6f4f71d 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ProfileConnectorInfo.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ProfileConnectorInfo.java
@@ -18,6 +18,7 @@
 import com.google.android.enterprise.connectedapps.annotations.AvailabilityRestrictions;
 import com.google.android.enterprise.connectedapps.annotations.CustomProfileConnector;
 import com.google.android.enterprise.connectedapps.annotations.CustomProfileConnector.ProfileType;
+import com.google.android.enterprise.connectedapps.annotations.UncaughtExceptionsPolicy;
 import com.google.android.enterprise.connectedapps.processor.GeneratorUtilities;
 import com.google.android.enterprise.connectedapps.processor.SupportedTypes;
 import com.google.auto.value.AutoValue;
@@ -27,7 +28,6 @@
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Set;
-import javax.annotation.processing.ProcessingEnvironment;
 import javax.lang.model.element.PackageElement;
 import javax.lang.model.element.TypeElement;
 import javax.lang.model.util.Elements;
@@ -49,6 +49,8 @@
     abstract ImmutableCollection<TypeElement> importsClasses();
 
     abstract AvailabilityRestrictions availabilityRestrictions();
+
+    abstract UncaughtExceptionsPolicy uncaughtExceptionsPolicy();
   }
 
   public abstract TypeElement connectorElement();
@@ -71,22 +73,21 @@
 
   public abstract AvailabilityRestrictions availabilityRestrictions();
 
-  public static ProfileConnectorInfo create(
-      ProcessingEnvironment processingEnv,
-      TypeElement connectorElement,
-      SupportedTypes globalSupportedTypes) {
+  public abstract UncaughtExceptionsPolicy uncaughtExceptionsPolicy();
 
-    Elements elements = processingEnv.getElementUtils();
+  public static ProfileConnectorInfo create(
+      Context context, TypeElement connectorElement, SupportedTypes globalSupportedTypes) {
+    Elements elements = context.elements();
 
     CustomProfileConnectorAnnotationInfo annotationInfo =
-        extractFromCustomProfileConnectorAnnotation(processingEnv, elements, connectorElement);
+        extractFromCustomProfileConnectorAnnotation(context, elements, connectorElement);
 
     Set<TypeElement> parcelableWrappers = new HashSet<>(annotationInfo.parcelableWrapperClasses());
     Set<TypeElement> futureWrappers = new HashSet<>(annotationInfo.futureWrapperClasses());
 
     for (TypeElement importConnectorClass : annotationInfo.importsClasses()) {
       ProfileConnectorInfo importConnector =
-          ProfileConnectorInfo.create(processingEnv, importConnectorClass, globalSupportedTypes);
+          ProfileConnectorInfo.create(context, importConnectorClass, globalSupportedTypes);
       parcelableWrappers.addAll(importConnector.parcelableWrapperClasses());
       futureWrappers.addAll(importConnector.futureWrapperClasses());
     }
@@ -98,22 +99,18 @@
         globalSupportedTypes
             .asBuilder()
             .addParcelableWrappers(
-                ParcelableWrapper.createCustomParcelableWrappers(
-                    processingEnv.getTypeUtils(),
-                    processingEnv.getElementUtils(),
-                    parcelableWrappers))
-            .addFutureWrappers(
-                FutureWrapper.createCustomFutureWrappers(
-                    processingEnv.getTypeUtils(), processingEnv.getElementUtils(), futureWrappers))
+                ParcelableWrapper.createCustomParcelableWrappers(context, parcelableWrappers))
+            .addFutureWrappers(FutureWrapper.createCustomFutureWrappers(context, futureWrappers))
             .build(),
         ImmutableSet.copyOf(parcelableWrappers),
         ImmutableSet.copyOf(futureWrappers),
         annotationInfo.importsClasses(),
-        annotationInfo.availabilityRestrictions());
+        annotationInfo.availabilityRestrictions(),
+        annotationInfo.uncaughtExceptionsPolicy());
   }
 
   private static CustomProfileConnectorAnnotationInfo extractFromCustomProfileConnectorAnnotation(
-      ProcessingEnvironment processingEnv, Elements elements, TypeElement connectorElement) {
+      Context context, Elements elements, TypeElement connectorElement) {
     CustomProfileConnector customProfileConnector =
         connectorElement.getAnnotation(CustomProfileConnector.class);
 
@@ -124,18 +121,19 @@
           ImmutableSet.of(),
           ImmutableSet.of(),
           ImmutableSet.of(),
-          AvailabilityRestrictions.DEFAULT);
+          AvailabilityRestrictions.DEFAULT,
+          UncaughtExceptionsPolicy.NOTIFY_RETHROW);
     }
 
     Collection<TypeElement> parcelableWrappers =
         GeneratorUtilities.extractClassesFromAnnotation(
-            processingEnv.getTypeUtils(), customProfileConnector::parcelableWrappers);
+            context.types(), customProfileConnector::parcelableWrappers);
     Collection<TypeElement> futureWrappers =
         GeneratorUtilities.extractClassesFromAnnotation(
-            processingEnv.getTypeUtils(), customProfileConnector::futureWrappers);
+            context.types(), customProfileConnector::futureWrappers);
     Collection<TypeElement> imports =
         GeneratorUtilities.extractClassesFromAnnotation(
-            processingEnv.getTypeUtils(), customProfileConnector::imports);
+            context.types(), customProfileConnector::imports);
 
     String serviceClassName = customProfileConnector.serviceClassName();
 
@@ -147,7 +145,8 @@
         ImmutableSet.copyOf(parcelableWrappers),
         ImmutableSet.copyOf(futureWrappers),
         ImmutableSet.copyOf(imports),
-        customProfileConnector.availabilityRestrictions());
+        customProfileConnector.availabilityRestrictions(),
+        customProfileConnector.uncaughtExceptionsPolicy());
   }
 
   public static ClassName getDefaultServiceName(Elements elements, TypeElement connectorElement) {
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ProviderClassInfo.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ProviderClassInfo.java
index 02adf0c..d3cc922 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ProviderClassInfo.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ProviderClassInfo.java
@@ -16,16 +16,13 @@
 package com.google.android.enterprise.connectedapps.processor.containers;
 
 import static com.google.android.enterprise.connectedapps.processor.GeneratorUtilities.findCrossProfileProviderMethodsInClass;
-import static java.util.stream.Collectors.toSet;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.squareup.javapoet.ClassName;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.Set;
+import java.util.stream.Stream;
 import javax.lang.model.element.ElementKind;
 import javax.lang.model.element.ExecutableElement;
 import javax.lang.model.element.Modifier;
@@ -40,10 +37,10 @@
   public abstract TypeElement providerClassElement();
 
   public ImmutableCollection<CrossProfileTypeInfo> allCrossProfileTypes() {
-    Set<CrossProfileTypeInfo> types = new HashSet<>();
+    ImmutableSet.Builder<CrossProfileTypeInfo> types = ImmutableSet.builder();
     types.addAll(nonStaticTypes());
     types.addAll(staticTypes());
-    return ImmutableSet.copyOf(types);
+    return types.build();
   }
 
   public abstract ImmutableCollection<CrossProfileTypeInfo> nonStaticTypes();
@@ -58,6 +55,8 @@
     return ClassName.get(providerClassElement());
   }
 
+  public abstract ConnectorInfo connectorInfo();
+
   public ImmutableCollection<VariableElement> publicConstructorArgumentTypes() {
     return ImmutableList.copyOf(
         providerClassElement().getEnclosedElements().stream()
@@ -91,40 +90,52 @@
 
   public static ProviderClassInfo create(
       ValidatorContext context, ValidatorProviderClassInfo provider) {
-    Set<CrossProfileTypeInfo> nonStaticTypes =
-        extractCrossProfileTypeElementsFromReturnValues(
-                context.elements(), provider.providerClassElement())
-            .stream()
-            .map(
-                crossProfileTypeElement ->
-                    ValidatorCrossProfileTypeInfo.create(
-                        context.processingEnv(),
-                        crossProfileTypeElement,
-                        context.globalSupportedTypes()))
-            .map(crossProfileType -> CrossProfileTypeInfo.create(context, crossProfileType))
-            .collect(toSet());
+    ImmutableSet.Builder<CrossProfileTypeInfo> nonStaticTypesBuilder = ImmutableSet.builder();
+    extractCrossProfileTypeElementsFromReturnValues(
+            context.elements(), provider.providerClassElement())
+        .stream()
+        .map(
+            crossProfileTypeElement ->
+                ValidatorCrossProfileTypeInfo.create(
+                    context, crossProfileTypeElement, context.globalSupportedTypes()))
+        .map(crossProfileType -> CrossProfileTypeInfo.create(context, crossProfileType))
+        .forEach(nonStaticTypesBuilder::add);
+    ImmutableSet<CrossProfileTypeInfo> nonStaticTypes = nonStaticTypesBuilder.build();
 
-    Set<CrossProfileTypeInfo> staticTypes =
-        provider.staticTypes().stream()
-            .map(
-                crossProfileTypeElement ->
-                    ValidatorCrossProfileTypeInfo.create(
-                        context.processingEnv(),
-                        crossProfileTypeElement,
-                        context.globalSupportedTypes()))
-            .map(crossProfileType -> CrossProfileTypeInfo.create(context, crossProfileType))
-            .collect(toSet());
+    ImmutableSet.Builder<CrossProfileTypeInfo> staticTypesBuilder = ImmutableSet.builder();
+    provider.staticTypes().stream()
+        .map(
+            crossProfileTypeElement ->
+                ValidatorCrossProfileTypeInfo.create(
+                    context, crossProfileTypeElement, context.globalSupportedTypes()))
+        .map(crossProfileType -> CrossProfileTypeInfo.create(context, crossProfileType))
+        .forEach(staticTypesBuilder::add);
+    ImmutableSet<CrossProfileTypeInfo> staticTypes = staticTypesBuilder.build();
 
     return new AutoValue_ProviderClassInfo(
         provider.providerClassElement(),
-        ImmutableSet.copyOf(nonStaticTypes),
-        ImmutableSet.copyOf(staticTypes));
+        nonStaticTypes,
+        staticTypes,
+        findConnector(context, staticTypes, nonStaticTypes));
   }
 
-  public static Collection<TypeElement> extractCrossProfileTypeElementsFromReturnValues(
+  public static ImmutableSet<TypeElement> extractCrossProfileTypeElementsFromReturnValues(
       Elements elements, TypeElement providerClassElement) {
-    return findCrossProfileProviderMethodsInClass(providerClassElement).stream()
+    ImmutableSet.Builder<TypeElement> result = ImmutableSet.builder();
+    findCrossProfileProviderMethodsInClass(providerClassElement).stream()
         .map(e -> elements.getTypeElement(e.getReturnType().toString()))
-        .collect(toSet());
+        .forEach(result::add);
+    return result.build();
+  }
+
+  private static ConnectorInfo findConnector(
+      ValidatorContext context,
+      ImmutableSet<CrossProfileTypeInfo> staticTypes,
+      ImmutableSet<CrossProfileTypeInfo> nonStaticTypes) {
+    return Stream.concat(staticTypes.stream(), nonStaticTypes.stream())
+        .filter(typeInfo -> typeInfo.connectorInfo().isPresent())
+        .map(typeInfo -> typeInfo.connectorInfo().get())
+        .findFirst()
+        .orElse(ConnectorInfo.unspecified(context, context.globalSupportedTypes()));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/Type.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/Type.java
index d81f6cd..d52a1e6 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/Type.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/Type.java
@@ -51,7 +51,7 @@
   }
 
   public boolean canBeBundled() {
-    return getWriteToParcelCode().isPresent() && getReadFromParcelCode().isPresent();
+    return getPutIntoBundleCode().isPresent() && getGetFromBundleCode().isPresent();
   }
 
   public boolean isPrimitive() {
@@ -96,6 +96,10 @@
   // (e.g. ParcelableList)
   public abstract Optional<ParcelableWrapper> getParcelableWrapper();
 
+  public abstract Optional<String> getPutIntoBundleCode();
+
+  public abstract Optional<String> getGetFromBundleCode();
+
   public abstract Optional<String> getWriteToParcelCode();
 
   public abstract Optional<String> getReadFromParcelCode();
@@ -125,6 +129,10 @@
     public abstract Builder setCrossProfileCallbackInterface(
         CrossProfileCallbackInterfaceInfo crossProfileCallbackInterface);
 
+    public abstract Builder setPutIntoBundleCode(String putIntoBundleCode);
+
+    public abstract Builder setGetFromBundleCode(String getFromBundleCode);
+
     public abstract Builder setWriteToParcelCode(String writeToParcelCode);
 
     public abstract Builder setReadFromParcelCode(String readFromParcelCode);
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/UserConnectorInfo.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/UserConnectorInfo.java
index 33a4d56..82395f9 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/UserConnectorInfo.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/UserConnectorInfo.java
@@ -26,10 +26,8 @@
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Set;
-import javax.annotation.processing.ProcessingEnvironment;
 import javax.lang.model.element.PackageElement;
 import javax.lang.model.element.TypeElement;
-import javax.lang.model.util.Elements;
 
 /** Wrapper of an interface used as a user connector. */
 @AutoValue
@@ -67,19 +65,16 @@
   public abstract AvailabilityRestrictions availabilityRestrictions();
 
   public static UserConnectorInfo create(
-      ProcessingEnvironment processingEnv,
-      TypeElement connectorElement,
-      SupportedTypes globalSupportedTypes) {
-    Elements elements = processingEnv.getElementUtils();
+      Context context, TypeElement connectorElement, SupportedTypes globalSupportedTypes) {
     CustomUserConnectorAnnotationInfo annotationInfo =
-        extractFromCustomUserConnectorAnnotation(processingEnv, elements, connectorElement);
+        extractFromCustomUserConnectorAnnotation(context, connectorElement);
 
     Set<TypeElement> parcelableWrappers = new HashSet<>(annotationInfo.parcelableWrapperClasses());
     Set<TypeElement> futureWrappers = new HashSet<>(annotationInfo.futureWrapperClasses());
 
     for (TypeElement importConnectorClass : annotationInfo.importsClasses()) {
       UserConnectorInfo importConnector =
-          UserConnectorInfo.create(processingEnv, importConnectorClass, globalSupportedTypes);
+          UserConnectorInfo.create(context, importConnectorClass, globalSupportedTypes);
       parcelableWrappers.addAll(importConnector.parcelableWrapperClasses());
       futureWrappers.addAll(importConnector.futureWrapperClasses());
     }
@@ -90,13 +85,8 @@
         globalSupportedTypes
             .asBuilder()
             .addParcelableWrappers(
-                ParcelableWrapper.createCustomParcelableWrappers(
-                    processingEnv.getTypeUtils(),
-                    processingEnv.getElementUtils(),
-                    parcelableWrappers))
-            .addFutureWrappers(
-                FutureWrapper.createCustomFutureWrappers(
-                    processingEnv.getTypeUtils(), processingEnv.getElementUtils(), futureWrappers))
+                ParcelableWrapper.createCustomParcelableWrappers(context, parcelableWrappers))
+            .addFutureWrappers(FutureWrapper.createCustomFutureWrappers(context, futureWrappers))
             .build(),
         ImmutableSet.copyOf(parcelableWrappers),
         ImmutableSet.copyOf(futureWrappers),
@@ -105,13 +95,13 @@
   }
 
   private static CustomUserConnectorAnnotationInfo extractFromCustomUserConnectorAnnotation(
-      ProcessingEnvironment processingEnv, Elements elements, TypeElement connectorElement) {
+      Context context, TypeElement connectorElement) {
     CustomUserConnector customUserConnector =
         connectorElement.getAnnotation(CustomUserConnector.class);
 
     if (customUserConnector == null) {
       return new AutoValue_UserConnectorInfo_CustomUserConnectorAnnotationInfo(
-          getDefaultServiceName(elements, connectorElement),
+          getDefaultServiceName(context, connectorElement),
           ImmutableSet.of(),
           ImmutableSet.of(),
           ImmutableSet.of(),
@@ -120,19 +110,19 @@
 
     Collection<TypeElement> parcelableWrappers =
         GeneratorUtilities.extractClassesFromAnnotation(
-            processingEnv.getTypeUtils(), customUserConnector::parcelableWrappers);
+            context.types(), customUserConnector::parcelableWrappers);
     Collection<TypeElement> futureWrappers =
         GeneratorUtilities.extractClassesFromAnnotation(
-            processingEnv.getTypeUtils(), customUserConnector::futureWrappers);
+            context.types(), customUserConnector::futureWrappers);
     Collection<TypeElement> imports =
         GeneratorUtilities.extractClassesFromAnnotation(
-            processingEnv.getTypeUtils(), customUserConnector::imports);
+            context.types(), customUserConnector::imports);
 
     String serviceClassName = customUserConnector.serviceClassName();
 
     return new AutoValue_UserConnectorInfo_CustomUserConnectorAnnotationInfo(
         serviceClassName.isEmpty()
-            ? getDefaultServiceName(elements, connectorElement)
+            ? getDefaultServiceName(context, connectorElement)
             : ClassName.bestGuess(serviceClassName),
         ImmutableSet.copyOf(parcelableWrappers),
         ImmutableSet.copyOf(futureWrappers),
@@ -140,8 +130,8 @@
         customUserConnector.availabilityRestrictions());
   }
 
-  public static ClassName getDefaultServiceName(Elements elements, TypeElement connectorElement) {
-    PackageElement originalPackage = elements.getPackageOf(connectorElement);
+  public static ClassName getDefaultServiceName(Context context, TypeElement connectorElement) {
+    PackageElement originalPackage = context.elements().getPackageOf(connectorElement);
 
     return ClassName.get(
         originalPackage.getQualifiedName().toString(),
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorContext.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorContext.java
index e13f0c5..8b6c98f 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorContext.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorContext.java
@@ -34,8 +34,11 @@
 @AutoValue
 public abstract class ValidatorContext extends Context {
 
-  public static Builder builder() {
-    return new AutoValue_ValidatorContext.Builder();
+  public static Builder builderFromPreValidatorContext(PreValidatorContext preValidatorContext) {
+    return new AutoValue_ValidatorContext.Builder()
+        .setProcessingEnv(preValidatorContext.processingEnv())
+        .setElements(preValidatorContext.elements())
+        .setTypes(preValidatorContext.types());
   }
 
   public abstract SupportedTypes globalSupportedTypes();
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorCrossProfileConfigurationInfo.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorCrossProfileConfigurationInfo.java
index 973714c..2109108 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorCrossProfileConfigurationInfo.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorCrossProfileConfigurationInfo.java
@@ -24,7 +24,6 @@
 import com.google.common.collect.ImmutableSet;
 import com.squareup.javapoet.ClassName;
 import java.util.Optional;
-import javax.annotation.processing.ProcessingEnvironment;
 import javax.lang.model.element.Element;
 import javax.lang.model.element.TypeElement;
 
@@ -43,18 +42,15 @@
   public abstract Optional<TypeElement> connector();
 
   public static ImmutableSet<ValidatorCrossProfileConfigurationInfo> createMultipleFromElement(
-      ProcessingEnvironment processingEnvironment, TypeElement annotatedElement) {
+      Context context, TypeElement annotatedElement) {
     ImmutableSet<CrossProfileConfigurationAnnotationInfo> infos =
-        AnnotationFinder.extractCrossProfileConfigurationsAnnotationInfo(
-                annotatedElement,
-                processingEnvironment.getTypeUtils(),
-                processingEnvironment.getElementUtils())
+        AnnotationFinder.extractCrossProfileConfigurationsAnnotationInfo(context, annotatedElement)
             .configurations();
     ImmutableSet.Builder<ValidatorCrossProfileConfigurationInfo> configurations =
         ImmutableSet.builder();
 
     if (infos.isEmpty()) {
-      configurations.add(createFromElement(processingEnvironment, annotatedElement));
+      configurations.add(createFromElement(context, annotatedElement));
     } else {
       for (CrossProfileConfigurationAnnotationInfo info : infos) {
         configurations.add(createFromAnnotationInfo(info, annotatedElement));
@@ -65,9 +61,9 @@
   }
 
   public static ValidatorCrossProfileConfigurationInfo createFromElement(
-      ProcessingEnvironment processingEnv, TypeElement annotatedElement) {
+      Context context, TypeElement annotatedElement) {
     CrossProfileConfigurationAnnotationInfo annotationInfo =
-        extractFromCrossProfileConfigurationAnnotation(annotatedElement, processingEnv);
+        extractFromCrossProfileConfigurationAnnotation(context, annotatedElement);
 
     return createFromAnnotationInfo(annotationInfo, annotatedElement);
   }
@@ -117,9 +113,8 @@
   }
 
   private static CrossProfileConfigurationAnnotationInfo
-      extractFromCrossProfileConfigurationAnnotation(
-          Element annotatedElement, ProcessingEnvironment processingEnv) {
+      extractFromCrossProfileConfigurationAnnotation(Context context, Element annotatedElement) {
     return AnnotationFinder.extractCrossProfileConfigurationAnnotationInfo(
-        annotatedElement, processingEnv.getTypeUtils(), processingEnv.getElementUtils());
+        context, annotatedElement);
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorCrossProfileTestInfo.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorCrossProfileTestInfo.java
index 172c8ed..d7a93e3 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorCrossProfileTestInfo.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorCrossProfileTestInfo.java
@@ -18,7 +18,6 @@
 import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationFinder;
 import com.google.android.enterprise.connectedapps.testing.annotations.CrossProfileTest;
 import com.google.auto.value.AutoValue;
-import javax.annotation.processing.ProcessingEnvironment;
 import javax.lang.model.element.TypeElement;
 
 /** Wrapper of a {@link CrossProfileTest} annotated class. */
@@ -30,10 +29,9 @@
   public abstract TypeElement configurationElement();
 
   public static ValidatorCrossProfileTestInfo create(
-      ProcessingEnvironment processingEnv, TypeElement crossProfileTestElement) {
+      Context context, TypeElement crossProfileTestElement) {
     CrossProfileTestAnnotationInfo annotationInfo =
-        AnnotationFinder.extractCrossProfileTestAnnotationInfo(
-            crossProfileTestElement, processingEnv.getTypeUtils(), processingEnv.getElementUtils());
+        AnnotationFinder.extractCrossProfileTestAnnotationInfo(context, crossProfileTestElement);
     return new AutoValue_ValidatorCrossProfileTestInfo(
         crossProfileTestElement, annotationInfo.configuration());
   }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorCrossProfileTypeInfo.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorCrossProfileTypeInfo.java
index c757ba4..bc828ae 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorCrossProfileTypeInfo.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorCrossProfileTypeInfo.java
@@ -21,14 +21,12 @@
 import com.google.android.enterprise.connectedapps.annotations.CrossProfile;
 import com.google.android.enterprise.connectedapps.processor.SupportedTypes;
 import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationFinder;
-import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.interfaces.CrossProfileAnnotation;
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Optional;
-import javax.annotation.processing.ProcessingEnvironment;
 import javax.lang.model.element.ExecutableElement;
 import javax.lang.model.element.TypeElement;
 
@@ -40,7 +38,7 @@
 
   public abstract ImmutableList<ExecutableElement> crossProfileMethods();
 
-  public abstract Optional<ProfileConnectorInfo> profileConnector();
+  public abstract Optional<ConnectorInfo> connectorInfo();
 
   public abstract SupportedTypes supportedTypes();
 
@@ -48,30 +46,15 @@
 
   public abstract ImmutableCollection<TypeElement> futureWrapperClasses();
 
-  public abstract String profileClassName();
-
   public abstract boolean isStatic();
 
-  /**
-   * The specified timeout for async calls, or {@link CrossProfileAnnotation#DEFAULT_TIMEOUT_MILLIS}
-   * if unspecified.
-   */
-  public abstract long timeoutMillis();
-
   public static ValidatorCrossProfileTypeInfo create(
-      ProcessingEnvironment processingEnv,
-      TypeElement crossProfileTypeElement,
-      SupportedTypes globalSupportedTypes) {
+      Context context, TypeElement crossProfileTypeElement, SupportedTypes globalSupportedTypes) {
     CrossProfileAnnotationInfo annotationInfo =
-        AnnotationFinder.extractCrossProfileAnnotationInfo(
-            crossProfileTypeElement, processingEnv.getTypeUtils(), processingEnv.getElementUtils());
+        AnnotationFinder.extractCrossProfileAnnotationInfo(context, crossProfileTypeElement);
 
-    Optional<ProfileConnectorInfo> profileConnectorElement =
-        annotationInfo.connectorIsDefault()
-            ? Optional.empty()
-            : Optional.of(
-                ProfileConnectorInfo.create(
-                    processingEnv, annotationInfo.connectorClass(), globalSupportedTypes));
+    Optional<ConnectorInfo> connectorInfo =
+        createConnectorInfo(context, annotationInfo, globalSupportedTypes);
 
     List<ExecutableElement> crossProfileMethodElements =
         findCrossProfileMethodsInClass(crossProfileTypeElement).stream()
@@ -79,37 +62,46 @@
             .collect(toList());
 
     SupportedTypes incomingSupportedTypes =
-        profileConnectorElement.isPresent()
-            ? profileConnectorElement.get().supportedTypes()
-            : globalSupportedTypes;
+        connectorInfo.isPresent() ? connectorInfo.get().supportedTypes() : globalSupportedTypes;
 
     SupportedTypes supportedTypes =
         incomingSupportedTypes
             .asBuilder()
             .addParcelableWrappers(
                 ParcelableWrapper.createCustomParcelableWrappers(
-                    processingEnv.getTypeUtils(),
-                    processingEnv.getElementUtils(),
-                    annotationInfo.parcelableWrapperClasses()))
+                    context, annotationInfo.parcelableWrapperClasses()))
             .addFutureWrappers(
                 FutureWrapper.createCustomFutureWrappers(
-                    processingEnv.getTypeUtils(),
-                    processingEnv.getElementUtils(),
-                    annotationInfo.futureWrapperClasses()))
+                    context, annotationInfo.futureWrapperClasses()))
             .build();
 
     return new AutoValue_ValidatorCrossProfileTypeInfo(
         crossProfileTypeElement,
         ImmutableList.copyOf(crossProfileMethodElements),
-        profileConnectorElement,
+        connectorInfo,
         supportedTypes,
         annotationInfo.parcelableWrapperClasses(),
         annotationInfo.futureWrapperClasses(),
-        annotationInfo.profileClassName(),
-        annotationInfo.isStatic(),
-        annotationInfo
-            .timeoutMillis()
-            .filter(value -> value != CrossProfileAnnotation.TIMEOUT_MILLIS_NOT_SET)
-            .orElse(CrossProfileAnnotation.DEFAULT_TIMEOUT_MILLIS));
+        annotationInfo.isStatic());
+  }
+
+  private static Optional<ConnectorInfo> createConnectorInfo(
+      Context context,
+      CrossProfileAnnotationInfo annotationInfo,
+      SupportedTypes globalSupportedTypes) {
+    if (annotationInfo.connectorIsDefault()) {
+      return Optional.empty();
+    } else if (ConnectorInfo.isProfileConnector(context, annotationInfo.connectorClass())) {
+      return Optional.of(
+          ConnectorInfo.forProfileConnector(
+              context, annotationInfo.connectorClass(), globalSupportedTypes));
+    } else if (ConnectorInfo.isUserConnector(context, annotationInfo.connectorClass())) {
+      return Optional.of(
+          ConnectorInfo.forUserConnector(
+              context, annotationInfo.connectorClass(), globalSupportedTypes));
+    }
+
+    return Optional.of(
+        ConnectorInfo.invalid(context, annotationInfo.connectorClass(), globalSupportedTypes));
   }
 }
diff --git a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorProviderClassInfo.java b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorProviderClassInfo.java
index c3ffc28..99427fa 100644
--- a/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorProviderClassInfo.java
+++ b/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/containers/ValidatorProviderClassInfo.java
@@ -20,7 +20,6 @@
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableSet;
 import com.squareup.javapoet.ClassName;
-import javax.annotation.processing.ProcessingEnvironment;
 import javax.lang.model.element.TypeElement;
 
 /** Wrapper of basic information for a cross-profile provider class. */
@@ -40,10 +39,9 @@
   }
 
   public static ValidatorProviderClassInfo create(
-      ProcessingEnvironment processingEnv, TypeElement providerClassElement) {
+      Context context, TypeElement providerClassElement) {
     CrossProfileProviderAnnotationInfo annotationInfo =
-        AnnotationFinder.extractCrossProfileProviderAnnotationInfo(
-            providerClassElement, processingEnv.getTypeUtils(), processingEnv.getElementUtils());
+        AnnotationFinder.extractCrossProfileProviderAnnotationInfo(context, providerClassElement);
 
     return new AutoValue_ValidatorProviderClassInfo(
         providerClassElement, ImmutableSet.copyOf(annotationInfo.staticTypes()));
diff --git a/processor/src/main/resources/futurewrappers/ListenableFutureWrapper.java b/processor/src/main/resources/futurewrappers/ListenableFutureWrapper.java
index f9fe728..6af3332 100644
--- a/processor/src/main/resources/futurewrappers/ListenableFutureWrapper.java
+++ b/processor/src/main/resources/futurewrappers/ListenableFutureWrapper.java
@@ -75,7 +75,7 @@
             directExecutor());
   }
 
-  private static class MergerFutureCallback<E> implements FutureCallback<E> {
+  private static final class MergerFutureCallback<E> implements FutureCallback<E> {
 
     private final Profile profileId;
     private final CrossProfileCallbackMultiMerger<E> merger;
diff --git a/processor/src/main/resources/parcelablewrappers/ParcelableDrawable.java b/processor/src/main/resources/parcelablewrappers/ParcelableDrawable.java
new file mode 100644
index 0000000..b9852aa
--- /dev/null
+++ b/processor/src/main/resources/parcelablewrappers/ParcelableDrawable.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.parcelablewrappers;
+
+import android.graphics.Bitmap;
+import android.graphics.Bitmap.Config;
+import android.graphics.Canvas;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.os.Parcel;
+import android.os.Parcelable;
+import com.google.android.enterprise.connectedapps.internal.Bundler;
+import com.google.android.enterprise.connectedapps.internal.BundlerType;
+
+/**
+ * Wrapper for reading & writing {@link Drawable} instances from and to {@link Parcel} instances.
+ *
+ * <p>Note that all {@link Drawable} instances are converted to {@link Bitmap} when parcelling.
+ */
+public class ParcelableDrawable implements Parcelable {
+
+  private static final int NULL = -1;
+  private static final int NOT_NULL = 1;
+
+  private final Drawable drawable;
+
+  /** Create a wrapper for a given drawable. */
+  public static ParcelableDrawable of(Bundler bundler, BundlerType type, Drawable drawable) {
+    return new ParcelableDrawable(drawable);
+  }
+
+  public Drawable get() {
+    return drawable;
+  }
+
+  private ParcelableDrawable(Drawable drawable) {
+    this.drawable = drawable;
+  }
+
+  private ParcelableDrawable(Parcel in) {
+    int present = in.readInt();
+
+    if (present == NULL) {
+      drawable = null;
+      return;
+    }
+
+    Bitmap bitmap = (Bitmap) in.readParcelable(Bundler.class.getClassLoader());
+    drawable = new BitmapDrawable(bitmap);
+  }
+
+  @Override
+  public void writeToParcel(Parcel dest, int flags) {
+    if (drawable == null) {
+      dest.writeInt(NULL);
+      return;
+    }
+
+    dest.writeInt(NOT_NULL);
+
+    Bitmap bitmap = drawableToBitmap(drawable);
+    dest.writeParcelable(bitmap, /* flags= */ flags);
+  }
+
+  @Override
+  public int describeContents() {
+    return 0;
+  }
+
+  @SuppressWarnings("rawtypes")
+  public static final Creator<ParcelableDrawable> CREATOR =
+      new Creator<ParcelableDrawable>() {
+        @Override
+        public ParcelableDrawable createFromParcel(Parcel in) {
+          return new ParcelableDrawable(in);
+        }
+
+        @Override
+        public ParcelableDrawable[] newArray(int size) {
+          return new ParcelableDrawable[size];
+        }
+      };
+
+  private static Bitmap drawableToBitmap(Drawable drawable) {
+    if (drawable instanceof BitmapDrawable) {
+      return ((BitmapDrawable) drawable).getBitmap();
+    }
+
+    Bitmap bitmap =
+        Bitmap.createBitmap(
+            drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Config.ARGB_8888);
+    drawable.draw(new Canvas(bitmap));
+
+    return bitmap;
+  }
+}
diff --git a/proguard.pgcfg b/proguard.pgcfg
index e69de29..b92adab 100644
--- a/proguard.pgcfg
+++ b/proguard.pgcfg
@@ -0,0 +1,8 @@
+-keepclasseswithmembers class com.google.android.enterprise.connectedapps.internal.BundleCallReceiver {
+  android.os.Bundle getPreparedCall(long, int, byte[]);
+  byte[] prepareResponse(long, android.os.Bundle);
+}
+
+-keepclasseswithmembers class com.google.android.enterprise.connectedapps.internal.CrossProfileBundleCallSender {
+  android.os.Bundle makeBundleCall(android.os.Bundle);
+}
\ No newline at end of file
diff --git a/sdk/build.gradle b/sdk/build.gradle
index f9a6886..e3b7203 100644
--- a/sdk/build.gradle
+++ b/sdk/build.gradle
@@ -4,10 +4,15 @@
 }
 
 dependencies {
-    api deps.checkerFramework
+    api(deps.errorprone, {
+        exclude group: 'org.checkerframework', module: 'dataflow-errorprone'
+    })
     implementation project(path: ':connectedapps-annotations')
+    implementation(deps.errorprone, {
+        exclude group: 'org.checkerframework', module: 'dataflow-errorprone'
+    })
     testImplementation project(path: ':connectedapps-sharedtests')
-    testImplementation 'org.robolectric:robolectric:4.4'
+    testImplementation deps.robolectric
     testImplementation 'junit:junit:4.13.1'
     testImplementation 'com.google.truth:truth:1.1.2'
     testImplementation 'androidx.test:core:1.3.0'
diff --git a/sdk/src/main/aidl/com/google/android/enterprise/connectedapps/ICrossProfileCallback.aidl b/sdk/src/main/aidl/com/google/android/enterprise/connectedapps/ICrossProfileCallback.aidl
index 581da13..443baa4 100644
--- a/sdk/src/main/aidl/com/google/android/enterprise/connectedapps/ICrossProfileCallback.aidl
+++ b/sdk/src/main/aidl/com/google/android/enterprise/connectedapps/ICrossProfileCallback.aidl
@@ -15,8 +15,11 @@
  */
  package com.google.android.enterprise.connectedapps;
 
+import android.os.Bundle;
+
 interface ICrossProfileCallback {
   void prepareResult(long callId, int blockId, int numBytes, in byte[] params);
+  void prepareBundle(long callId, int bundleId, in Bundle bundle);
   void onResult(long callId, int blockId, int methodIdentifier, in byte[] params);
   void onException(long callId, int blockId, in byte[] params);
 }
\ No newline at end of file
diff --git a/sdk/src/main/aidl/com/google/android/enterprise/connectedapps/ICrossProfileService.aidl b/sdk/src/main/aidl/com/google/android/enterprise/connectedapps/ICrossProfileService.aidl
index 126f2d4..22e30b4 100644
--- a/sdk/src/main/aidl/com/google/android/enterprise/connectedapps/ICrossProfileService.aidl
+++ b/sdk/src/main/aidl/com/google/android/enterprise/connectedapps/ICrossProfileService.aidl
@@ -16,6 +16,7 @@
  package com.google.android.enterprise.connectedapps;
 
 import com.google.android.enterprise.connectedapps.ICrossProfileCallback;
+import android.os.Bundle;
 
 interface ICrossProfileService {
   // When making a call containing params larger than
@@ -29,6 +30,9 @@
   // and is used to prepare the cache with the first use of prepareCall
   void prepareCall(long callId, int blockId, int numBytes, in byte[] params);
 
+
+  void prepareBundle(long callId, int bundleId, in Bundle bundle);
+
   // When making a call with params smaller than
   // CrossProfileSender.MAX_BYTES_PER_BLOCK bytes bytes, or with the final
   // block in a larger call, this method is used.
@@ -38,4 +42,6 @@
     ICrossProfileCallback callback);
 
   byte[] fetchResponse(long callId, int blockId);
+
+  Bundle fetchResponseBundle(long callId, int bundleId);
 }
\ No newline at end of file
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/AbstractProfileBinder.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/AbstractProfileBinder.java
index b4e908f..15b4ae0 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/AbstractProfileBinder.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/AbstractProfileBinder.java
@@ -117,6 +117,11 @@
       PackageInfo packageInfo =
           packageManager.getPackageInfo(context.getPackageName(), PackageManager.GET_PERMISSIONS);
 
+      if (packageInfo == null || packageInfo.requestedPermissions == null) {
+        hasCachedPermissionRequests = true;
+        return;
+      }
+
       for (String permission : packageInfo.requestedPermissions) {
         if (permission.equals(INTERACT_ACROSS_PROFILES)) {
           requestsInteractAcrossProfiles = true;
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/AbstractProfileConnector.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/AbstractProfileConnector.java
index dd84c7d..92184a5 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/AbstractProfileConnector.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/AbstractProfileConnector.java
@@ -81,28 +81,21 @@
   }
 
   @Override
-  public void startConnecting() {
-    if (VERSION.SDK_INT < VERSION_CODES.O) {
-      return;
-    }
-    crossProfileSender().startManuallyBinding();
+  public ProfileConnectionHolder connect() throws UnavailableProfileException {
+    return connect(CrossProfileSender.MANUAL_MANAGEMENT_CONNECTION_HOLDER);
   }
 
   @Override
-  public void connect() throws UnavailableProfileException {
+
+  public ProfileConnectionHolder connect(Object connectionHolder)
+      throws UnavailableProfileException {
     if (VERSION.SDK_INT < VERSION_CODES.O) {
       throw new UnavailableProfileException(
           "Cross-profile calls are not supported on this version of Android");
     }
-    crossProfileSender().manuallyBind();
-  }
+    crossProfileSender().manuallyBind(connectionHolder);
 
-  @Override
-  public void stopManualConnectionManagement() {
-    if (VERSION.SDK_INT < VERSION_CODES.O) {
-      return;
-    }
-    crossProfileSender().stopManualConnectionManagement();
+    return ProfileConnectionHolder.create(this, connectionHolder);
   }
 
   @Override
@@ -125,12 +118,12 @@
   }
 
   @Override
-  public void registerConnectionListener(ConnectionListener listener) {
+  public void addConnectionListener(ConnectionListener listener) {
     connectionListeners.add(listener);
   }
 
   @Override
-  public void unregisterConnectionListener(ConnectionListener listener) {
+  public void removeConnectionListener(ConnectionListener listener) {
     connectionListeners.remove(listener);
   }
 
@@ -146,12 +139,12 @@
   }
 
   @Override
-  public void registerAvailabilityListener(AvailabilityListener listener) {
+  public void addAvailabilityListener(AvailabilityListener listener) {
     availabilityListeners.add(listener);
   }
 
   @Override
-  public void unregisterAvailabilityListener(AvailabilityListener listener) {
+  public void removeAvailabilityListener(AvailabilityListener listener) {
     availabilityListeners.remove(listener);
   }
 
@@ -214,8 +207,37 @@
   }
 
   @Override
-  public boolean isManuallyManagingConnection() {
-    return crossProfileSender().isManuallyManagingConnection();
+  public ProfileConnectionHolder addConnectionHolder(Object connectionHolder) {
+    if (VERSION.SDK_INT < VERSION_CODES.O) {
+      return ProfileConnectionHolder.create(this, connectionHolder);
+    }
+    crossProfileSender().addConnectionHolder(connectionHolder);
+
+    return ProfileConnectionHolder.create(this, connectionHolder);
+  }
+
+  @Override
+  public void addConnectionHolderAlias(Object key, Object value) {
+    if (VERSION.SDK_INT < VERSION_CODES.O) {
+      return;
+    }
+    crossProfileSender().addConnectionHolderAlias(key, value);
+  }
+
+  @Override
+  public void removeConnectionHolder(Object connectionHolder) {
+    if (VERSION.SDK_INT < VERSION_CODES.O) {
+      return;
+    }
+    crossProfileSender().removeConnectionHolder(connectionHolder);
+  }
+
+  @Override
+  public void clearConnectionHolders() {
+    if (VERSION.SDK_INT < VERSION_CODES.O) {
+      return;
+    }
+    crossProfileSender().clearConnectionHolders();
   }
 
   /** A builder for an {@link AbstractProfileConnector}. */
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/AbstractUserConnector.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/AbstractUserConnector.java
index fa07fe2..544b6af 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/AbstractUserConnector.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/AbstractUserConnector.java
@@ -16,89 +16,261 @@
 package com.google.android.enterprise.connectedapps;
 
 import android.content.Context;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
 import android.os.UserHandle;
 import com.google.android.enterprise.connectedapps.annotations.AvailabilityRestrictions;
 import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
 import org.checkerframework.checker.nullness.qual.Nullable;
 
 /** Standard implementation of {@link UserConnector}. */
-public abstract class AbstractUserConnector
-    implements UserConnector, ConnectionListener, AvailabilityListener {
+public abstract class AbstractUserConnector implements UserConnector {
+
+  private final Context context;
+  private final UserBinderFactory binderFactory;
+  private final ScheduledExecutorService scheduledExecutorService;
+  private final String serviceClassName;
+  private final AvailabilityRestrictions availabilityRestrictions;
+
+  private final Map<UserHandle, UserConnection> userConnections = new HashMap<>();
+
+  private static final class UserConnection {
+    private final ConnectionBinder binder;
+
+    private final CrossProfileSender crossProfileSender;
+
+    private final Set<ConnectionListener> connectionListeners;
+
+    private final Set<AvailabilityListener> availabilityListeners;
+
+    private UserConnection(
+        ConnectionBinder binder,
+        CrossProfileSender crossProfileSender,
+        Set<ConnectionListener> connectionListeners,
+        Set<AvailabilityListener> availabilityListeners) {
+      this.binder = binder;
+      this.crossProfileSender = crossProfileSender;
+      this.connectionListeners = connectionListeners;
+      this.availabilityListeners = availabilityListeners;
+    }
+
+    public CrossProfileSender crossProfileSender() {
+      return crossProfileSender;
+    }
+
+    public Set<ConnectionListener> connectionListeners() {
+      return connectionListeners;
+    }
+
+    public Set<AvailabilityListener> availabilityListeners() {
+      return availabilityListeners;
+    }
+
+    public static UserConnection create(
+        ConnectionBinder binder, CrossProfileSender crossProfileSender) {
+      return new UserConnection(
+          binder, crossProfileSender, new CopyOnWriteArraySet<>(), new CopyOnWriteArraySet<>());
+    }
+  }
 
   public AbstractUserConnector(Class<? extends UserConnector> userConnectorClass, Builder builder) {
     if (userConnectorClass == null || builder == null || builder.context == null) {
       throw new NullPointerException();
     }
+
+    if (builder.binderFactory == null) {
+      binderFactory = DefaultUserBinder::new;
+    } else {
+      binderFactory = builder.binderFactory;
+    }
+
+    if (builder.scheduledExecutorService == null) {
+      scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
+    } else {
+      scheduledExecutorService = builder.scheduledExecutorService;
+    }
+
+    context = builder.context.getApplicationContext();
+    availabilityRestrictions = builder.availabilityRestrictions;
+
+    if (builder.serviceClassName == null) {
+      throw new NullPointerException("serviceClassName must be specified");
+    }
+    serviceClassName = builder.serviceClassName;
   }
 
   @Override
-  public void availabilityChanged() {}
-
-  @Override
-  public void connectionChanged() {}
-
-  @Override
-  public void startConnecting(UserHandle userHandle) {}
-
-  @Override
-  public void connect(UserHandle userHandle) throws UnavailableProfileException {}
-
-  @Override
-  public void stopManualConnectionManagement(UserHandle userHandle) {}
-
-  @Override
-  public CrossProfileSender crossProfileSender(UserHandle userHandle) {
-    return null;
-  }
-
-  @Override
-  public void registerConnectionListener(UserHandle userHandle, ConnectionListener listener) {}
-
-  @Override
-  public void unregisterConnectionListener(UserHandle userHandle, ConnectionListener listener) {}
-
-  @Override
-  public void registerAvailabilityListener(UserHandle userHandle, AvailabilityListener listener) {}
-
-  @Override
-  public void unregisterAvailabilityListener(
-      UserHandle userHandle, AvailabilityListener listener) {}
-
-  @Override
   public boolean isAvailable(UserHandle userHandle) {
-    return false;
+    if (VERSION.SDK_INT < VERSION_CODES.O) {
+      return false;
+    }
+    return crossProfileSender(userHandle).isBindingPossible();
+  }
+
+  @Override
+  public UserConnectionHolder connect(UserHandle userHandle) throws UnavailableProfileException {
+    return connect(userHandle, new Object());
+  }
+
+  @Override
+  public UserConnectionHolder connect(UserHandle userHandle, Object connectionHolder)
+      throws UnavailableProfileException {
+    if (VERSION.SDK_INT < VERSION_CODES.O) {
+      throw new UnavailableProfileException(
+          "Cross-user calls are not supported on this version of Android");
+    }
+    crossProfileSender(userHandle).manuallyBind(connectionHolder);
+
+    return UserConnectionHolder.create(this, userHandle, connectionHolder);
   }
 
   @Override
   public boolean isConnected(UserHandle userHandle) {
-    return false;
-  }
-
-  @Override
-  public ConnectedAppsUtils utils(UserHandle userHandle) {
-    return null;
+    if (VERSION.SDK_INT < VERSION_CODES.O) {
+      return false;
+    }
+    return crossProfileSender(userHandle).isBound();
   }
 
   @Override
   public Permissions permissions(UserHandle userHandle) {
-    return null;
+    return new PermissionsImpl(context, userConnection(userHandle).binder);
   }
 
   @Override
   public Context applicationContext(UserHandle userHandle) {
-    return null;
+    return context;
   }
 
   @Override
-  public boolean isManuallyManagingConnection(UserHandle userHandle) {
-    return false;
+  public CrossProfileSender crossProfileSender(UserHandle userHandle) {
+    if (VERSION.SDK_INT < VERSION_CODES.O) {
+      return null;
+    }
+    return userConnection(userHandle).crossProfileSender();
+  }
+
+  private UserConnection userConnection(UserHandle userHandle) {
+    if (userConnections.containsKey(userHandle)) {
+      return userConnections.get(userHandle);
+    }
+
+    ConnectionBinder binder = binderFactory.createBinder(userHandle);
+    UserHandleEventForwarder userHandleEventForwarder =
+        new UserHandleEventForwarder(this, userHandle);
+    CrossProfileSender crossProfileSender =
+        new CrossProfileSender(
+            context.getApplicationContext(),
+            serviceClassName,
+            binder,
+            /* connectionListener= */ userHandleEventForwarder,
+            /* availabilityListener= */ userHandleEventForwarder,
+            scheduledExecutorService,
+            availabilityRestrictions);
+    UserConnection userConnection = UserConnection.create(binder, crossProfileSender);
+    userConnections.put(userHandle, userConnection);
+    return userConnection;
+  }
+
+  private static final class UserHandleEventForwarder
+      implements ConnectionListener, AvailabilityListener {
+
+    private final AbstractUserConnector connector;
+
+    private final UserHandle userHandle;
+
+    private UserHandleEventForwarder(AbstractUserConnector connector, UserHandle userHandle) {
+      this.connector = connector;
+      this.userHandle = userHandle;
+    }
+
+    @Override
+    public void connectionChanged() {
+      connector.connectionChanged(userHandle);
+    }
+
+    @Override
+    public void availabilityChanged() {
+      connector.availabilityChanged(userHandle);
+    }
+  }
+
+  @Override
+  public void addConnectionListener(UserHandle userHandle, ConnectionListener listener) {
+    userConnection(userHandle).connectionListeners().add(listener);
+  }
+
+  @Override
+  public void removeConnectionListener(UserHandle userHandle, ConnectionListener listener) {
+    userConnection(userHandle).connectionListeners().remove(listener);
+  }
+
+  private void availabilityChanged(UserHandle userHandle) {
+    for (AvailabilityListener listener : userConnection(userHandle).availabilityListeners()) {
+      listener.availabilityChanged();
+    }
+  }
+
+  @Override
+  public void addAvailabilityListener(UserHandle userHandle, AvailabilityListener listener) {
+    userConnection(userHandle).availabilityListeners().add(listener);
+  }
+
+  @Override
+  public void removeAvailabilityListener(UserHandle userHandle, AvailabilityListener listener) {
+    userConnection(userHandle).availabilityListeners().remove(listener);
+  }
+
+  private void connectionChanged(UserHandle userHandle) {
+    for (ConnectionListener listener : userConnection(userHandle).connectionListeners()) {
+      listener.connectionChanged();
+    }
+  }
+
+  @Override
+  public UserConnectionHolder addConnectionHolder(UserHandle userHandle, Object connectionHolder) {
+    if (VERSION.SDK_INT < VERSION_CODES.O) {
+      return UserConnectionHolder.create(this, userHandle, connectionHolder);
+    }
+    crossProfileSender(userHandle).addConnectionHolder(connectionHolder);
+
+    return UserConnectionHolder.create(this, userHandle, connectionHolder);
+  }
+
+  @Override
+  public void addConnectionHolderAlias(UserHandle userHandle, Object key, Object value) {
+    if (VERSION.SDK_INT < VERSION_CODES.O) {
+      return;
+    }
+    crossProfileSender(userHandle).addConnectionHolderAlias(key, value);
+  }
+
+  @Override
+  public void removeConnectionHolder(UserHandle userHandle, Object connectionHolder) {
+    if (VERSION.SDK_INT < VERSION_CODES.O) {
+      return;
+    }
+    crossProfileSender(userHandle).removeConnectionHolder(connectionHolder);
+  }
+
+  @Override
+  public void clearConnectionHolders(UserHandle userHandle) {
+    if (VERSION.SDK_INT < VERSION_CODES.O) {
+      return;
+    }
+    crossProfileSender(userHandle).clearConnectionHolders();
   }
 
   /** A builder for an {@link AbstractUserConnector}. */
   public static final class Builder {
+    @Nullable UserBinderFactory binderFactory;
     @Nullable ScheduledExecutorService scheduledExecutorService;
-    @Nullable ConnectionBinder binder;
     @Nullable AvailabilityRestrictions availabilityRestrictions;
     Context context;
     String serviceClassName;
@@ -113,8 +285,8 @@
       return this;
     }
 
-    public Builder setBinder(ConnectionBinder binder) {
-      this.binder = binder;
+    public Builder setBinderFactory(UserBinderFactory binderFactory) {
+      this.binderFactory = binderFactory;
       return this;
     }
 
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/ConnectionBinder.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/ConnectionBinder.java
index 0d793f4..0438b65 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/ConnectionBinder.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/ConnectionBinder.java
@@ -20,6 +20,7 @@
 import android.content.ServiceConnection;
 import com.google.android.enterprise.connectedapps.annotations.AvailabilityRestrictions;
 import com.google.android.enterprise.connectedapps.exceptions.MissingApiException;
+import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
 
 /** {@link ConnectionBinder} instances are used to establish bindings with other profiles. */
 public interface ConnectionBinder {
@@ -37,7 +38,7 @@
       ComponentName bindToService,
       ServiceConnection connection,
       AvailabilityRestrictions availabilityRestrictions)
-      throws MissingApiException;
+      throws MissingApiException, UnavailableProfileException;
 
   /**
    * Return true if there is a profile available to bind to, while enforcing the passed in {@link
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/CrossProfileConnector.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/CrossProfileConnector.java
index b534152..2a332d5 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/CrossProfileConnector.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/CrossProfileConnector.java
@@ -45,6 +45,9 @@
      * Use an alternative {@link ScheduledExecutorService}.
      *
      * <p>Defaults to {@link Executors#newSingleThreadScheduledExecutor()}.
+     *
+     * <p>This {@link ScheduledExecutorService} must be single threaded or sequential. Failure to do
+     * so will result in undefined behavior when using the SDK.
      */
     public Builder setScheduledExecutorService(ScheduledExecutorService scheduledExecutorService) {
       implBuilder.setScheduledExecutorService(scheduledExecutorService);
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/CrossProfileSender.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/CrossProfileSender.java
index 9cc287b..0432696 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/CrossProfileSender.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/CrossProfileSender.java
@@ -17,9 +17,11 @@
 
 import static com.google.android.enterprise.connectedapps.CrossProfileSDKUtilities.filterUsersByAvailabilityRestrictions;
 import static com.google.android.enterprise.connectedapps.CrossProfileSDKUtilities.selectUserHandleToBind;
-
 import static java.util.Collections.newSetFromMap;
 import static java.util.Collections.synchronizedSet;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static java.util.concurrent.TimeUnit.SECONDS;
 
 import android.content.BroadcastReceiver;
 import android.content.ComponentName;
@@ -33,7 +35,6 @@
 import android.os.Bundle;
 import android.os.IBinder;
 import android.os.Looper;
-import android.os.Parcel;
 import android.os.UserHandle;
 import android.os.UserManager;
 import android.util.Log;
@@ -41,42 +42,44 @@
 import com.google.android.enterprise.connectedapps.exceptions.MissingApiException;
 import com.google.android.enterprise.connectedapps.exceptions.ProfileRuntimeException;
 import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
-import com.google.android.enterprise.connectedapps.internal.CrossProfileParcelCallSender;
-import com.google.android.enterprise.connectedapps.internal.ParcelCallReceiver;
-import com.google.android.enterprise.connectedapps.internal.ParcelUtilities;
+import com.google.android.enterprise.connectedapps.internal.BundleCallReceiver;
+import com.google.android.enterprise.connectedapps.internal.BundleUtilities;
+import com.google.android.enterprise.connectedapps.internal.Bundler;
+import com.google.android.enterprise.connectedapps.internal.CrossProfileBundleCallSender;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
 import java.util.WeakHashMap;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentLinkedDeque;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 import org.checkerframework.checker.nullness.qual.Nullable;
 
-/** This class is used internally by the Connected Apps SDK to send messages cross-profile. */
-public class CrossProfileSender {
+/**
+ * This class is used internally by the Connected Apps SDK to send messages across users and
+ * profiles.
+ */
+public final class CrossProfileSender {
 
-  private static final class CrossProfileCall {
+  private static final class CrossProfileCall implements ExceptionCallback {
     private final long crossProfileTypeIdentifier;
     private final int methodIdentifier;
-    private final Parcel params;
+    private final Bundle params;
     private final LocalCallback callback;
-    private final long timeoutMillis;
 
     CrossProfileCall(
         long crossProfileTypeIdentifier,
         int methodIdentifier,
-        Parcel params,
-        LocalCallback callback,
-        long timeoutMillis) {
+        Bundle params,
+        LocalCallback callback) {
       if (params == null || callback == null) {
         throw new NullPointerException();
       }
@@ -84,15 +87,10 @@
       this.methodIdentifier = methodIdentifier;
       this.params = params;
       this.callback = callback;
-      this.timeoutMillis = timeoutMillis;
-    }
-
-    void recycle() {
-      params.recycle();
     }
 
     @Override
-    public boolean equals(Object o) {
+    public boolean equals(@Nullable Object o) {
       if (this == o) {
         return true;
       }
@@ -103,108 +101,70 @@
       return crossProfileTypeIdentifier == that.crossProfileTypeIdentifier
           && methodIdentifier == that.methodIdentifier
           && params.equals(that.params)
-          && callback.equals(that.callback)
-          && timeoutMillis == that.timeoutMillis;
+          && callback.equals(that.callback);
     }
 
     @Override
     public int hashCode() {
-      return Objects.hash(
-          crossProfileTypeIdentifier, methodIdentifier, params, callback, timeoutMillis);
+      return Objects.hash(crossProfileTypeIdentifier, methodIdentifier, params, callback);
+    }
+
+    @Override
+    public void onException(Throwable throwable) {
+      callback.onException(createThrowableBundle(throwable));
     }
   }
 
   private static final class OngoingCrossProfileCall extends ICrossProfileCallback.Stub {
 
     private final CrossProfileSender sender;
-    private final LocalCallback originalCallback;
-    private final AtomicBoolean complete = new AtomicBoolean(false);
-    private ScheduledFuture<?> timeoutFuture;
-    private final long timeoutMillis;
-    private final ParcelCallReceiver parcelCallReceiver = new ParcelCallReceiver();
+    private final CrossProfileCall call;
+    private final BundleCallReceiver bundleCallReceiver = new BundleCallReceiver();
 
-    private OngoingCrossProfileCall(
-        CrossProfileSender sender, LocalCallback originalCallback, long timeoutMillis) {
-      if (sender == null || originalCallback == null) {
+    private OngoingCrossProfileCall(CrossProfileSender sender, CrossProfileCall call) {
+      if (sender == null || call == null) {
         throw new NullPointerException();
       }
       this.sender = sender;
-      this.originalCallback = originalCallback;
-      this.timeoutMillis = timeoutMillis;
-    }
-
-    void scheduleTimeout(ScheduledExecutorService timeoutExecutor) {
-      if (this.timeoutFuture != null) {
-        throw new IllegalStateException("Each call can only have a single timeout scheduled.");
-      }
-      if (complete.get()) {
-        return;
-      }
-      this.timeoutFuture =
-          timeoutExecutor.schedule(this::onTimeout, timeoutMillis, TimeUnit.MILLISECONDS);
-    }
-
-    private void onTimeout() {
-      if (complete.get()) {
-        return;
-      }
-      Parcel throwableParcel =
-          createThrowableParcel(
-              new UnavailableProfileException(
-                  "The call timed out after " + timeoutMillis + " milliseconds"));
-
-      onException(throwableParcel);
-      throwableParcel.recycle();
+      this.call = call;
     }
 
     @Override
     public void prepareResult(long callId, int blockId, int numBytes, byte[] params) {
-      parcelCallReceiver.prepareCall(callId, blockId, numBytes, params);
+      bundleCallReceiver.prepareCall(callId, blockId, numBytes, params);
+    }
+
+    @Override
+    public void prepareBundle(long callId, int bundleId, Bundle bundle) {
+      bundleCallReceiver.prepareBundle(callId, bundleId, bundle);
     }
 
     @Override
     public void onResult(long callId, int blockId, int methodIdentifier, byte[] paramsBytes) {
-      if (complete.getAndSet(true)) {
-        return;
-      }
-      if (timeoutFuture != null) {
-        timeoutFuture.cancel(/* mayInterruptIfRunning= */ true);
-      }
-      sender.ongoingCallComplete(this);
+      sender.removeConnectionHolder(call);
 
-      Parcel parcel = parcelCallReceiver.getPreparedCall(callId, blockId, paramsBytes);
+      Bundle bundle = bundleCallReceiver.getPreparedCall(callId, blockId, paramsBytes);
 
-      originalCallback.onResult(methodIdentifier, parcel);
-      parcel.recycle();
-
-      sender.maybeScheduleAutomaticDisconnection();
+      call.callback.onResult(methodIdentifier, bundle);
     }
 
     @Override
     public void onException(long callId, int blockId, byte[] paramsBytes) {
-      Parcel parcel = parcelCallReceiver.getPreparedCall(callId, blockId, paramsBytes);
+      Bundle bundle = bundleCallReceiver.getPreparedCall(callId, blockId, paramsBytes);
 
-      onException(parcel);
-
-      parcel.recycle();
+      onException(bundle);
     }
 
-    public void onException(Parcel exception) {
-      if (complete.getAndSet(true)) {
-        return;
-      }
-      if (timeoutFuture != null) {
-        timeoutFuture.cancel(/* mayInterruptIfRunning= */ true);
-      }
-      sender.ongoingCallComplete(this);
+    public void onException(Bundle exception) {
+      sender.removeConnectionHolder(call);
 
-      originalCallback.onException(exception);
+      call.callback.onException(exception);
 
-      sender.maybeScheduleAutomaticDisconnection();
+      sender.scheduledExecutorService.execute(sender::maybeScheduleAutomaticDisconnection);
     }
 
     @Override
-    public boolean equals(Object o) {
+    public boolean equals(@Nullable Object o) {
       if (this == o) {
         return true;
       }
@@ -212,20 +172,17 @@
         return false;
       }
       OngoingCrossProfileCall that = (OngoingCrossProfileCall) o;
-      return sender.equals(that.sender)
-          && originalCallback.equals(that.originalCallback)
-          && complete.equals(that.complete);
+      return sender.equals(that.sender) && call.equals(that.call);
     }
 
     @Override
     public int hashCode() {
-      return Objects.hash(sender, originalCallback, complete);
+      return Objects.hash(sender, call);
     }
   }
 
-  private void ongoingCallComplete(OngoingCrossProfileCall call) {
-    ongoingCrossProfileCalls.removeFirstOccurrence(call);
-  }
+  // Temporary variable until deprecated methods are removed
+  public static final Object MANUAL_MANAGEMENT_CONNECTION_HOLDER = new Object();
 
   public static final int MAX_BYTES_PER_BLOCK = 250000;
 
@@ -233,38 +190,115 @@
   private static final long INITIAL_BIND_RETRY_DELAY_MS = 500;
   private static final int DEFAULT_AUTOMATIC_DISCONNECTION_TIMEOUT_SECONDS = 30;
 
-  private final ScheduledExecutorService scheduledExecutorService;
-  private final Context context;
-  private final ComponentName bindToService;
-  private boolean canUseReflectedApis;
-  private long bindRetryDelayMs = 500;
-  private AtomicBoolean isBinding = new AtomicBoolean(false);
-  private final AtomicReference<ICrossProfileService> iCrossProfileService =
-      new AtomicReference<>();
-  private final ConnectionListener connectionListener;
-  private final AvailabilityListener availabilityListener;
-  private final ConnectionBinder binder;
-  @Nullable private volatile ScheduledFuture<Void> automaticDisconnectionFuture;
-  private final AvailabilityRestrictions availabilityRestrictions;
-
-  // This is synchronized which isn't massively performant but it only gets accessed once straight
-  // after creating a Sender, and once each time availability changes
-  private static final Set<CrossProfileSender> senders =
-          synchronizedSet(newSetFromMap(new WeakHashMap<>()));
-
-  private boolean isManuallyManagingConnection = false;
-  private ConcurrentLinkedDeque<OngoingCrossProfileCall> ongoingCrossProfileCalls =
-      new ConcurrentLinkedDeque<>();
-  private ConcurrentLinkedDeque<CrossProfileCall> asyncCallQueue = new ConcurrentLinkedDeque<>();
-
   private static final int NONE = 0;
   private static final int UNAVAILABLE = 1;
   private static final int AVAILABLE = 2;
   private static final int DISCONNECTED = UNAVAILABLE;
   private static final int CONNECTED = AVAILABLE;
 
-  private ScheduledFuture<?> scheduledTryBind;
+  private final ScheduledExecutorService scheduledExecutorService;
+  private final Context context;
+  private final ComponentName bindToService;
+  private final boolean canUseReflectedApis;
+  private final ConnectionListener connectionListener;
+  private final AvailabilityListener availabilityListener;
+  private final ConnectionBinder binder;
+  private final AvailabilityRestrictions availabilityRestrictions;
 
+  private final AtomicReference<@Nullable ICrossProfileService> iCrossProfileService =
+      new AtomicReference<>();
+  private final AtomicReference<@Nullable ScheduledFuture<?>> scheduledTryBind =
+      new AtomicReference<>();
+  private final AtomicReference<ScheduledFuture<?>> scheduledBindTimeout = new AtomicReference<>();
+
+  // Interaction with connectionHolders, and connectionHolderAliases must
+  //  take place on the scheduled executor thread
+  private final Set<Object> connectionHolders = Collections.newSetFromMap(new WeakHashMap<>());
+  private final Map<Object, Set<Object>> connectionHolderAliases = new WeakHashMap<>();
+  private final Set<ExceptionCallback> unavailableProfileExceptionWatchers =
+      Collections.newSetFromMap(new ConcurrentHashMap<>());
+  private final ConcurrentLinkedDeque<CrossProfileCall> asyncCallQueue =
+      new ConcurrentLinkedDeque<>();
+
+  private final ServiceConnection connection =
+      new ServiceConnection() {
+
+        @Override
+        public void onBindingDied(ComponentName name) {
+          Log.e(LOG_TAG, "onBindingDied for component " + name);
+          scheduledExecutorService.execute(
+              () -> onBindingAttemptFailed("onBindingDied", /* terminal= */ true));
+        }
+
+        @Override
+        public void onNullBinding(ComponentName name) {
+          Log.e(LOG_TAG, "onNullBinding for component " + name);
+          scheduledExecutorService.execute(
+              () -> onBindingAttemptFailed("onNullBinding", /* terminal= */ true));
+        }
+
+        // Called when the connection with the service is established
+        @Override
+        public void onServiceConnected(ComponentName name, IBinder service) {
+          Log.i(LOG_TAG, "onServiceConnected for component " + name);
+          scheduledExecutorService.execute(
+              () -> {
+                if (connectionHolders.isEmpty()) {
+                  Log.i(LOG_TAG, "Connected but no holders. Disconnecting.");
+                  unbind();
+                  return;
+                }
+                iCrossProfileService.set(ICrossProfileService.Stub.asInterface(service));
+
+                tryMakeAsyncCalls();
+                checkConnected();
+                onBindingAttemptSucceeded();
+              });
+        }
+
+        // Called when the connection with the service disconnects unexpectedly
+        @Override
+        public void onServiceDisconnected(ComponentName name) {
+          Log.e(LOG_TAG, "Unexpected disconnection for component " + name);
+          attemptReconnect();
+        }
+
+        private void attemptReconnect() {
+          scheduledExecutorService.execute(
+              () -> {
+                unbind();
+                throwUnavailableException(
+                    new UnavailableProfileException("Lost connection to other profile"));
+                // These disconnections can be temporary - so to avoid an exception on an async
+                // call leading to bad user experience - we send the availability update again
+                // to prompt a retry/refresh
+                updateAvailability();
+                checkConnected();
+                cancelAutomaticDisconnection();
+                bind();
+              });
+        }
+      };
+
+  // This is synchronized which isn't massively performant but it only gets accessed once straight
+  // after creating a Sender, and once each time availability changes
+  private static final Set<CrossProfileSender> senders =
+      synchronizedSet(newSetFromMap(new WeakHashMap<>()));
+
+  private static final BroadcastReceiver profileAvailabilityReceiver = new BroadcastReceiver() {
+    @Override
+    public void onReceive(Context context, Intent intent) {
+      for (CrossProfileSender sender : senders) {
+        sender.scheduledExecutorService.execute(sender::checkAvailability);
+      }
+    }
+  };
+
+  private final AtomicReference<ScheduledFuture<Void>> automaticDisconnectionFuture =
+      new AtomicReference<>();
+  private volatile @Nullable CountDownLatch manuallyBindLatch;
+
+  private long bindRetryDelayMs = INITIAL_BIND_RETRY_DELAY_MS;
   private int lastReportedAvailabilityStatus = NONE;
   private int lastReportedConnectedStatus = NONE;
 
@@ -296,94 +330,33 @@
     beginMonitoringAvailabilityChanges();
   }
 
-  private static final BroadcastReceiver profileAvailabilityReceiver =
-      new BroadcastReceiver() {
-        @Override
-        public void onReceive(Context context, Intent intent) {
-          for (CrossProfileSender sender : senders) {
-            sender.scheduledExecutorService.execute(sender::checkAvailability);
-          }
-        }
-      };
-
-  private final ServiceConnection connection =
-      new ServiceConnection() {
-        // Called when the connection with the service is established
-        @Override
-        public void onServiceConnected(ComponentName className, IBinder service) {
-          scheduledExecutorService.execute(
-              () -> {
-                if (!isBinding.get()) {
-                  unbind();
-                  return;
-                }
-                iCrossProfileService.set(ICrossProfileService.Stub.asInterface(service));
-
-                tryMakeAsyncCalls();
-                checkConnected();
-                onBindingAttemptSucceeded();
-              });
-        }
-
-        // Called when the connection with the service disconnects unexpectedly
-        @Override
-        public void onServiceDisconnected(ComponentName className) {
-          scheduledExecutorService.execute(
-              () -> {
-                Log.e(LOG_TAG, "Unexpected disconnection");
-                if (!asyncCallQueue.isEmpty() || !ongoingCrossProfileCalls.isEmpty()) {
-                  Log.d(LOG_TAG, "Found in progress calls");
-                  throwExceptionForAsyncCalls(
-                      new UnavailableProfileException("Lost connection to other profile"));
-                  // These disconnections can be temporary - so to avoid an exception on an async
-                  // call leading to bad user experience - we send the availability update again
-                  // to prompt a retry/refresh
-                  updateAvailability();
-                }
-                iCrossProfileService.set(null);
-                checkConnected();
-                cancelAutomaticDisconnection();
-                startTryBinding();
-              });
-        }
-      };
-
-  private final Object automaticDisconnectionFutureLock = new Object();
-
   private void cancelAutomaticDisconnection() {
-    if (automaticDisconnectionFuture != null) {
-      synchronized (automaticDisconnectionFutureLock) {
-        if (automaticDisconnectionFuture != null) {
-          automaticDisconnectionFuture.cancel(/* mayInterruptIfRunning= */ true);
-          automaticDisconnectionFuture = null;
-        }
-      }
+    ScheduledFuture<?> disconnectionFuture = automaticDisconnectionFuture.getAndSet(null);
+    if (disconnectionFuture != null) {
+      disconnectionFuture.cancel(/* mayInterruptIfRunning= */ true);
     }
   }
 
   private void maybeScheduleAutomaticDisconnection() {
-    if (!isManuallyManagingConnection
-        && asyncCallQueue.isEmpty()
-        && ongoingCrossProfileCalls.isEmpty()
-        && isBound()
-        && automaticDisconnectionFuture == null) {
-      synchronized (automaticDisconnectionFutureLock) {
-        if (automaticDisconnectionFuture == null) {
-          automaticDisconnectionFuture =
-              scheduledExecutorService.schedule(
-                  this::automaticallyDisconnect,
-                  DEFAULT_AUTOMATIC_DISCONNECTION_TIMEOUT_SECONDS,
-                  TimeUnit.SECONDS);
-        }
+    // Always called on scheduled executor service thread
+    if (connectionHolders.isEmpty() && isBound()) {
+      Log.i(LOG_TAG, "Scheduling automatic disconnection");
+      ScheduledFuture<Void> scheduledDisconnection =
+          scheduledExecutorService.schedule(
+              this::automaticallyDisconnect,
+              DEFAULT_AUTOMATIC_DISCONNECTION_TIMEOUT_SECONDS,
+              SECONDS);
+
+      if (!automaticDisconnectionFuture.compareAndSet(null, scheduledDisconnection)) {
+        Log.i(LOG_TAG, "Already scheduled");
+        scheduledDisconnection.cancel(/* mayInterruptIfRunning= */ true);
       }
     }
   }
 
   private Void automaticallyDisconnect() {
-    if (!isManuallyManagingConnection
-        && asyncCallQueue.isEmpty()
-        && ongoingCrossProfileCalls.isEmpty()
-        && isBound()) {
+    // Always called on scheduled executor service thread
+    if (connectionHolders.isEmpty() && isBound()) {
       unbind();
     }
     return null;
@@ -403,9 +376,7 @@
     context.registerReceiver(profileAvailabilityReceiver, filter);
   }
 
-  private volatile CountDownLatch manuallyBindLatch;
-
-  void manuallyBind() throws UnavailableProfileException {
+  void manuallyBind(Object connectionHolder) throws UnavailableProfileException {
     Log.e(LOG_TAG, "Calling manuallyBind");
     if (isRunningOnUIThread()) {
       throw new IllegalStateException("connect()/manuallyBind() cannot be called from UI thread");
@@ -420,7 +391,11 @@
     }
 
     cancelAutomaticDisconnection();
-    isManuallyManagingConnection = true;
+
+    scheduledExecutorService.execute(
+        () -> {
+          connectionHolders.add(connectionHolder);
+        });
 
     if (isBound()) {
       // If we're already bound there's no need to block the thread
@@ -447,8 +422,8 @@
     }
 
     if (!isBound()) {
-      unbind(); // ensure we don't continue trying to connect if we throw an exception
-      isManuallyManagingConnection = false;
+      unbind();
+      scheduledExecutorService.execute(() -> removeConnectionHolderAndAliases(connectionHolder));
       throw new UnavailableProfileException("Profile not available");
     }
   }
@@ -457,58 +432,46 @@
     return Looper.myLooper() == Looper.getMainLooper();
   }
 
-  /**
-   * Start trying to bind to the other profile and start manually managing the connection.
-   *
-   * <p>This will mean that the connection will not be dropped automatically to save resources.
-   *
-   * <p>Must be called before interacting with synchronous cross-profile methods.
-   */
-  void startManuallyBinding() {
-    cancelAutomaticDisconnection();
-    isManuallyManagingConnection = true;
-    bind();
-  }
-
-  /**
-   * Stop manual connection management.
-   *
-   * <p>This can be called after {@link #startManuallyBinding()} or {@link #manuallyBind()} to
-   * return connection management responsibilities to the SDK.
-   *
-   * <p>You should not make any synchronous cross-profile calls after calling this method.
-   */
-  public void stopManualConnectionManagement() {
-    isManuallyManagingConnection = false;
-    maybeScheduleAutomaticDisconnection();
-  }
-
-  /**
-   * Attempt to bind to the other profile.
-   *
-   * <p>This will continually attempt to form a binding to the other profile in a background thread.
-   */
   private void bind() {
-    if (isBinding.getAndSet(true)) {
-      return;
-    }
-
-    startTryBinding();
+    bindRetryDelayMs = INITIAL_BIND_RETRY_DELAY_MS;
+    scheduledExecutorService.execute(this::tryBind);
   }
 
   private void onBindingAttemptSucceeded() {
+    clearScheduledBindTimeout();
     Log.i(LOG_TAG, "Binding attempt succeeded");
     checkTriggerManualConnectionLock();
   }
 
   private void onBindingAttemptFailed(String reason) {
-    onBindingAttemptFailed(reason, /* terminal= */ false);
+    onBindingAttemptFailed(reason, /* exception= */ null, /* terminal= */ false);
+  }
+
+  private void onBindingAttemptFailed(Exception exception) {
+    onBindingAttemptFailed(exception.getMessage(), exception, /* terminal= */ false);
+  }
+
+  private void onBindingAttemptFailed(String reason, Exception exception) {
+    onBindingAttemptFailed(reason, exception, /* terminal= */ false);
   }
 
   private void onBindingAttemptFailed(String reason, boolean terminal) {
-    Log.i(LOG_TAG, "Binding attempt failed: " + reason);
-    throwExceptionForAsyncCalls(new UnavailableProfileException(reason));
-    if (terminal || !isManuallyManagingConnection || manuallyBindLatch != null) {
+    onBindingAttemptFailed(reason, /* exception= */ null, terminal);
+  }
+
+  private void onBindingAttemptFailed(
+      String reason, @Nullable Exception exception, boolean terminal) {
+    // Always called on scheduled executor service thread
+    clearScheduledBindTimeout();
+    if (exception == null) {
+      Log.i(LOG_TAG, "Binding attempt failed: " + reason);
+      throwUnavailableException(new UnavailableProfileException(reason));
+    } else {
+      Log.i(LOG_TAG, "Binding attempt failed: " + reason, exception);
+      throwUnavailableException(new UnavailableProfileException(reason, exception));
+    }
+
+    if (terminal || connectionHolders.isEmpty() || manuallyBindLatch != null) {
       unbind();
       checkTriggerManualConnectionLock();
     } else {
@@ -516,6 +479,13 @@
     }
   }
 
+  private void clearScheduledBindTimeout() {
+    ScheduledFuture<?> scheduledTimeout = scheduledBindTimeout.getAndSet(null);
+    if (scheduledTimeout != null) {
+      scheduledTimeout.cancel(/* mayInterruptIfRunning= */ true);
+    }
+  }
+
   private void checkTriggerManualConnectionLock() {
     if (manuallyBindLatch != null) {
       synchronized (this) {
@@ -532,16 +502,16 @@
    *
    * <p>If there is already a binding present, it will be killed.
    */
-  void unbind() {
+  private void unbind() {
     Log.i(LOG_TAG, "Unbind");
-    throwExceptionForAsyncCalls(new UnavailableProfileException("No profile available"));
-    isBinding.set(false);
     if (isBound()) {
       context.unbindService(connection);
       iCrossProfileService.set(null);
       checkConnected();
       cancelAutomaticDisconnection();
     }
+    clearScheduledBindTimeout();
+    throwUnavailableException(new UnavailableProfileException("No profile available"));
     checkTriggerManualConnectionLock();
   }
 
@@ -549,17 +519,13 @@
     return binder.bindingIsPossible(context, availabilityRestrictions);
   }
 
-  private void startTryBinding() {
-    bindRetryDelayMs = INITIAL_BIND_RETRY_DELAY_MS;
-    scheduledExecutorService.execute(this::tryBind);
-  }
-
   private void tryBind() {
+    // Always called on scheduled executor service thread
     Log.i(LOG_TAG, "Attempting to bind");
 
-    if (scheduledTryBind != null) {
-      scheduledTryBind.cancel(/* mayInterruptIfRunning= */ false);
-      scheduledTryBind = null;
+    ScheduledFuture<?> scheduledFuture = scheduledTryBind.getAndSet(null);
+    if (scheduledFuture != null) {
+      scheduledFuture.cancel(/* mayInterruptIfRunning= */ false);
     }
 
     if (!canUseReflectedApis) {
@@ -567,13 +533,14 @@
       return;
     }
 
-    if (!isBinding.get()) {
-      onBindingAttemptFailed("Not trying to bind");
+    if (isBound()) {
+      Log.i(LOG_TAG, "Already bound");
+      onBindingAttemptSucceeded();
       return;
     }
 
-    if (isBound()) {
-      onBindingAttemptSucceeded();
+    if (connectionHolders.isEmpty()) {
+      onBindingAttemptFailed("Not trying to bind");
       return;
     }
 
@@ -587,24 +554,43 @@
       return;
     }
 
+    if (scheduledBindTimeout.get() != null) {
+      Log.i(LOG_TAG, "Already waiting to bind");
+      return;
+    }
+
     try {
+      // Schedule a timeout in case something happens and we never reach onServiceConnected
+      scheduledBindTimeout.set(scheduledExecutorService.schedule(this::timeoutBinding, 1, MINUTES));
       if (!binder.tryBind(context, bindToService, connection, availabilityRestrictions)) {
-        onBindingAttemptFailed("No profile available or app not installed in other profile");
+        onBindingAttemptFailed(
+            "No profile available, app not installed in other profile, or service not included in"
+                + " manifest");
+      } else {
+        Log.i(LOG_TAG, "binder.tryBind returned true, expecting onServiceConnected");
       }
     } catch (MissingApiException e) {
       Log.e(LOG_TAG, "MissingApiException when trying to bind", e);
-      onBindingAttemptFailed("Missing API");
+      onBindingAttemptFailed("Missing API", e);
+    } catch (UnavailableProfileException e) {
+      Log.e(LOG_TAG, "Error while trying to bind", e);
+      onBindingAttemptFailed(e);
     }
   }
 
+  private void timeoutBinding() {
+    onBindingAttemptFailed("Timed out while waiting for onServiceConnected");
+  }
+
   private void scheduleBindAttempt() {
-    if (scheduledTryBind != null && !scheduledTryBind.isDone()) {
+    ScheduledFuture<?> scheduledFuture = scheduledTryBind.get();
+    if (scheduledFuture != null && !scheduledFuture.isDone()) {
       return;
     }
 
     bindRetryDelayMs *= 2;
-    scheduledTryBind =
-        scheduledExecutorService.schedule(this::tryBind, bindRetryDelayMs, TimeUnit.MILLISECONDS);
+    scheduledTryBind.set(
+        scheduledExecutorService.schedule(this::tryBind, bindRetryDelayMs, MILLISECONDS));
   }
 
   boolean isBound() {
@@ -614,18 +600,18 @@
   /**
    * Make a synchronous cross-profile call.
    *
-   * @return A {@link Parcel} containing the return value. This must be recycled after use.
+   * @return A {@link Bundle} containing the return value under the key \"return\".
    * @throws UnavailableProfileException if a connection is not already established
    */
-  public Parcel call(long crossProfileTypeIdentifier, int methodIdentifier, Parcel params)
-          throws UnavailableProfileException {
+  public Bundle call(long crossProfileTypeIdentifier, int methodIdentifier, Bundle params)
+      throws UnavailableProfileException {
     try {
       return callWithExceptions(crossProfileTypeIdentifier, methodIdentifier, params);
     } catch (UnavailableProfileException | RuntimeException | Error e) {
       StackTraceElement[] remoteStack = e.getStackTrace();
       StackTraceElement[] localStack = Thread.currentThread().getStackTrace();
       StackTraceElement[] totalStack =
-              Arrays.copyOf(remoteStack, remoteStack.length + localStack.length - 1);
+          Arrays.copyOf(remoteStack, remoteStack.length + localStack.length - 1);
       // We cut off the first element of localStack as it is just getting the stack trace
       System.arraycopy(localStack, 1, totalStack, remoteStack.length, localStack.length - 1);
       e.setStackTrace(totalStack);
@@ -638,100 +624,71 @@
   /**
    * Make a synchronous cross-profile call which expects some checked exceptions to be thrown.
    *
-   * <p>Behaves the same as {@link #call(long, int, Parcel)} except that it deals with checked
+   * <p>Behaves the same as {@link #call(long, int, Bundle)} except that it deals with checked
    * exceptions by throwing {@link Throwable}.
    *
-   * @return A {@link Parcel} containing the return value. This must be recycled after use.
+   * @return A {@link Bundle} containing the return value under the "return" key.
    * @throws UnavailableProfileException if a connection is not already established
    */
-  public Parcel callWithExceptions(
-      long crossProfileTypeIdentifier, int methodIdentifier, Parcel params) throws Throwable {
-
-    if (!isBound()) {
+  public Bundle callWithExceptions(
+      long crossProfileTypeIdentifier, int methodIdentifier, Bundle params) throws Throwable {
+    ICrossProfileService service = iCrossProfileService.get();
+    if (service == null) {
       throw new UnavailableProfileException("Could not access other profile");
     }
 
-    if (!isManuallyManagingConnection) {
-      throw new UnavailableProfileException(
-          "Synchronous calls can only be used when manually connected");
-    }
+    CrossProfileBundleCallSender callSender =
+        new CrossProfileBundleCallSender(
+            service, crossProfileTypeIdentifier, methodIdentifier, /* callback= */ null);
+    Bundle returnBundle = callSender.makeBundleCall(params);
 
-    CrossProfileParcelCallSender callSender =
-        new CrossProfileParcelCallSender(
-            iCrossProfileService.get(),
-            crossProfileTypeIdentifier,
-            methodIdentifier,
-            /* callback= */ null);
-    Parcel parcel = callSender.makeParcelCall(params); // Recycled by caller
-    boolean hasError = parcel.readInt() == 1;
-
-    if (hasError) {
-      Throwable t = ParcelUtilities.readThrowableFromParcel(parcel);
+    if (returnBundle.containsKey("throwable")) {
+      Throwable t = BundleUtilities.readThrowableFromBundle(returnBundle, "throwable");
       if (t instanceof RuntimeException) {
-        throw new ProfileRuntimeException((RuntimeException) t);
+        throw new ProfileRuntimeException(t);
       }
       throw t;
     }
 
-    return parcel;
+    return returnBundle;
   }
 
-  /**
-   * Make an asynchronous cross-profile call.
-   *
-   * @param params These will be cached and will be recycled after the call is complete.
-   */
+  /** Make an asynchronous cross-profile call. */
   public void callAsync(
       long crossProfileTypeIdentifier,
       int methodIdentifier,
-      Parcel params,
+      Bundle params,
       LocalCallback callback,
-      long timeoutMillis) {
-
-    cancelAutomaticDisconnection();
-
-    asyncCallQueue.add(
-        new CrossProfileCall(
-            crossProfileTypeIdentifier, methodIdentifier, params, callback, timeoutMillis));
-
-    tryMakeAsyncCalls();
-    if (isManuallyManagingConnection) {
-      if (!isBindingPossible()) {
-        throwExceptionForAsyncCalls(new UnavailableProfileException("Profile not available"));
-      }
-    } else {
-      bind();
+      Object connectionHolderAlias) {
+    if (!isBindingPossible()) {
+      throwUnavailableException(new UnavailableProfileException("Profile not available"));
     }
+
+    scheduledExecutorService.execute(
+        () -> {
+          CrossProfileCall crossProfileCall =
+              new CrossProfileCall(crossProfileTypeIdentifier, methodIdentifier, params, callback);
+          connectionHolders.add(crossProfileCall);
+          cancelAutomaticDisconnection();
+          addConnectionHolderAlias(connectionHolderAlias, crossProfileCall);
+          unavailableProfileExceptionWatchers.add(crossProfileCall);
+
+          asyncCallQueue.add(crossProfileCall);
+
+          tryMakeAsyncCalls();
+          bind();
+        });
   }
 
-  private void throwExceptionForAsyncCalls(Throwable throwable) {
-    Parcel throwableParcel = createThrowableParcel(throwable);
-
-    while (true) {
-      CrossProfileCall call = asyncCallQueue.pollFirst();
-      if (call == null) {
-        break;
-      }
-
-      call.callback.onException(throwableParcel);
-      throwableParcel.setDataPosition(0);
-      call.recycle();
+  private void throwUnavailableException(Throwable throwable) {
+    for (ExceptionCallback callback : unavailableProfileExceptionWatchers) {
+      removeConnectionHolder(callback);
+      callback.onException(throwable);
     }
-
-    while (true) {
-      OngoingCrossProfileCall call = ongoingCrossProfileCalls.pollFirst();
-      if (call == null) {
-        break;
-      }
-
-      call.onException(throwableParcel);
-      throwableParcel.setDataPosition(0);
-    }
-
-    throwableParcel.recycle();
   }
 
   private void tryMakeAsyncCalls() {
+    Log.i(LOG_TAG, "tryMakeAsyncCalls");
     if (!isBound()) {
       return;
     }
@@ -740,46 +697,43 @@
   }
 
   private void drainAsyncQueue() {
+    Log.i(LOG_TAG, "drainAsyncQueue");
     while (true) {
       CrossProfileCall call = asyncCallQueue.pollFirst();
       if (call == null) {
-        break;
+        return;
       }
-      OngoingCrossProfileCall ongoingCall =
-          new OngoingCrossProfileCall(this, call.callback, call.timeoutMillis);
-      ongoingCrossProfileCalls.add(ongoingCall);
+      OngoingCrossProfileCall ongoingCall = new OngoingCrossProfileCall(this, call);
 
       try {
-        CrossProfileParcelCallSender callSender =
-            new CrossProfileParcelCallSender(
-                iCrossProfileService.get(),
-                call.crossProfileTypeIdentifier,
-                call.methodIdentifier,
-                ongoingCall);
-        Parcel p = callSender.makeParcelCall(call.params);
+        ICrossProfileService service = iCrossProfileService.get();
+        if (service == null) {
+          Log.w(LOG_TAG, "OngoingCrossProfileCall: not bound anymore, adding back to queue");
+          asyncCallQueue.add(call);
+          return;
+        }
+        CrossProfileBundleCallSender callSender =
+            new CrossProfileBundleCallSender(
+                service, call.crossProfileTypeIdentifier, call.methodIdentifier, ongoingCall);
 
-        boolean hasError = p.readInt() == 1;
-        call.recycle();
+        Bundle p = callSender.makeBundleCall(call.params);
 
-        if (hasError) {
+        if (p.containsKey("throwable")) {
           RuntimeException exception =
-              (RuntimeException) ParcelUtilities.readThrowableFromParcel(p);
-          p.recycle();
-          ongoingCrossProfileCalls.remove(ongoingCall);
+              (RuntimeException) BundleUtilities.readThrowableFromBundle(p, "throwable");
+          removeConnectionHolder(ongoingCall.call);
           throw new ProfileRuntimeException(exception);
         }
-
-        p.recycle();
-        ongoingCall.scheduleTimeout(scheduledExecutorService);
       } catch (UnavailableProfileException e) {
-        ongoingCrossProfileCalls.remove(ongoingCall);
+        Log.w(
+            LOG_TAG, "OngoingCrossProfileCall: UnavailableProfileException, adding back to queue");
         asyncCallQueue.add(call);
         return;
       }
     }
   }
 
-  void checkAvailability() {
+  private void checkAvailability() {
     if (isBindingPossible() && (lastReportedAvailabilityStatus != AVAILABLE)) {
       updateAvailability();
     } else if (!isBindingPossible() && (lastReportedAvailabilityStatus != UNAVAILABLE)) {
@@ -787,39 +741,31 @@
     }
   }
 
-  void updateAvailability() {
-    scheduledExecutorService.execute(availabilityListener::availabilityChanged);
+  private void updateAvailability() {
+    // This is only executed on the executor thread
+    availabilityListener.availabilityChanged();
     lastReportedAvailabilityStatus = isBindingPossible() ? AVAILABLE : UNAVAILABLE;
   }
 
-  void checkConnected() {
+  private void checkConnected() {
+    // This is only executed on the executor thread
     if (isBound() && lastReportedConnectedStatus != CONNECTED) {
-      scheduledExecutorService.execute(connectionListener::connectionChanged);
+      connectionListener.connectionChanged();
       lastReportedConnectedStatus = CONNECTED;
     } else if (!isBound() && lastReportedConnectedStatus != DISCONNECTED) {
-      scheduledExecutorService.execute(connectionListener::connectionChanged);
+      connectionListener.connectionChanged();
       lastReportedConnectedStatus = DISCONNECTED;
     }
   }
 
-  boolean isManuallyManagingConnection() {
-    return isManuallyManagingConnection;
+  /** Create a {@link Bundle} containing a {@link Throwable}. */
+  private static Bundle createThrowableBundle(Throwable throwable) {
+    Bundle bundle = new Bundle(Bundler.class.getClassLoader());
+    BundleUtilities.writeThrowableToBundle(bundle, "throwable", throwable);
+    return bundle;
   }
 
-  /**
-   * Create a {@link Parcel} containing a {@link Throwable}.
-   *
-   * <p>The {@link Parcel} must be recycled after use.
-   */
-  private static Parcel createThrowableParcel(Throwable throwable) {
-    Parcel parcel = Parcel.obtain(); // Recycled by caller
-    ParcelUtilities.writeThrowableToParcel(parcel, throwable);
-    parcel.setDataPosition(0);
-    return parcel;
-  }
-
-  @Nullable
-  static UserHandle getOtherUserHandle(
+  static @Nullable UserHandle getOtherUserHandle(
       Context context, AvailabilityRestrictions availabilityRestrictions) {
     if (VERSION.SDK_INT < VERSION_CODES.P) {
       // CrossProfileApps was introduced in P
@@ -835,8 +781,7 @@
     return selectUserHandleToBind(context, otherUsers);
   }
 
-  @Nullable
-  private static UserHandle findDifferentRunningUser(
+  private static @Nullable UserHandle findDifferentRunningUser(
       Context context,
       UserHandle ignoreUserHandle,
       AvailabilityRestrictions availabilityRestrictions) {
@@ -854,4 +799,81 @@
 
     return selectUserHandleToBind(context, otherUsers);
   }
+
+  void addConnectionHolder(Object o) {
+    scheduledExecutorService.execute(
+        () -> {
+          connectionHolders.add(o);
+
+          cancelAutomaticDisconnection();
+          bind();
+        });
+  }
+
+  void removeConnectionHolder(Object o) {
+    if (o == null) {
+        throw new NullPointerException("Connection holder cannot be null");
+    }
+
+    scheduledExecutorService.execute(
+        () -> {
+          removeConnectionHolderAndAliases(o);
+
+          maybeScheduleAutomaticDisconnection();
+        });
+  }
+
+  void clearConnectionHolders() {
+    scheduledExecutorService.execute(
+        () -> {
+          connectionHolders.clear();
+          connectionHolderAliases.clear();
+
+          maybeScheduleAutomaticDisconnection();
+        });
+  }
+
+  private void removeConnectionHolderAndAliases(Object o) {
+    // Always called on scheduled executor thread
+    Set<Object> aliases = connectionHolderAliases.get(o);
+    if (aliases != null) {
+      connectionHolderAliases.remove(o);
+      for (Object alias : aliases) {
+        removeConnectionHolderAndAliases(alias);
+      }
+    }
+
+    connectionHolders.remove(o);
+    unavailableProfileExceptionWatchers.remove(o);
+  }
+
+  /**
+   * Registers a connection holder alias.
+   *
+   * <p>This means that if the key is removed, then the value will also be removed. If the value is
+   * removed, the key will not be removed.
+   */
+  void addConnectionHolderAlias(Object key, Object value) {
+    scheduledExecutorService.execute(
+        () -> {
+          Set<Object> aliases = connectionHolderAliases.get(key);
+          if (aliases == null) {
+            aliases = Collections.newSetFromMap(new WeakHashMap<>());
+          }
+
+          aliases.add(value);
+
+          connectionHolderAliases.put(key, aliases);
+        });
+  }
+
+  /**
+   * Clear static state.
+   *
+   * <p>This should not be required in production.
+   */
+  public static void clearStaticState() {
+    isMonitoringAvailabilityChanges.set(false);
+    senders.clear();
+  }
 }
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/CrossUserConnector.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/CrossUserConnector.java
index 2e7dc66..67d1567 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/CrossUserConnector.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/CrossUserConnector.java
@@ -42,6 +42,9 @@
      * Use an alternative {@link ScheduledExecutorService}.
      *
      * <p>Defaults to {@link Executors#newSingleThreadScheduledExecutor()}.
+     *
+     * <p>This {@link ScheduledExecutorService} must be single threaded or sequential. Failure to do
+     * so will result in undefined behavior when using the SDK.
      */
     public Builder setScheduledExecutorService(ScheduledExecutorService scheduledExecutorService) {
       implBuilder.setScheduledExecutorService(scheduledExecutorService);
@@ -49,16 +52,6 @@
     }
 
     /**
-     * Specify an alternative {@link ConnectionBinder} for managing the connection.
-     *
-     * <p>Defaults to {@link DefaultProfileBinder}.
-     */
-    public Builder setBinder(ConnectionBinder binder) {
-      implBuilder.setBinder(binder);
-      return this;
-    }
-
-    /**
      * Specify which set of restrictions should be applied to checking availability.
      *
      * <p>Defaults to {@link AvailabilityRestrictions#DEFAULT}, which requires that a user be
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/DefaultUserBinder.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/DefaultUserBinder.java
new file mode 100644
index 0000000..09cf908
--- /dev/null
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/DefaultUserBinder.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps;
+
+import static android.Manifest.permission.INTERACT_ACROSS_PROFILES;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.content.pm.CrossProfileApps;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.os.UserHandle;
+import android.os.UserManager;
+import android.util.Log;
+import com.google.android.enterprise.connectedapps.annotations.AvailabilityRestrictions;
+import com.google.android.enterprise.connectedapps.exceptions.MissingApiException;
+
+/**
+ * A default {@link ConnectionBinder} for connecting to a specific user.
+ *
+ * <p>Methods expect that the app has INTERACT_ACROSS_USERS or INTERACT_ACROSS_PROFILES permission.
+ */
+public class DefaultUserBinder implements ConnectionBinder {
+
+  private static final String LOG_TAG = "DefaultUserBinder";
+
+  private static final String INTERACT_ACROSS_USERS_FULL =
+      "android.permission.INTERACT_ACROSS_USERS_FULL";
+
+  private boolean hasCachedPermissionRequests = false;
+  private boolean requestsInteractAcrossProfiles = false;
+  private boolean requestsInteractAcrossUsersFull = false;
+
+  private final UserHandle userHandle;
+
+  public DefaultUserBinder(UserHandle userHandle) {
+    this.userHandle = userHandle;
+  }
+
+  @Override
+  public boolean tryBind(
+      Context context,
+      ComponentName bindToService,
+      ServiceConnection connection,
+      AvailabilityRestrictions availabilityRestrictions)
+      throws MissingApiException {
+    Intent bindIntent = new Intent();
+    bindIntent.setComponent(bindToService);
+
+    boolean hasBound =
+        ReflectionUtilities.bindServiceAsUser(context, bindIntent, connection, userHandle);
+    if (!hasBound) {
+      context.unbindService(connection);
+    }
+    return hasBound;
+  }
+
+  @Override
+  public boolean bindingIsPossible(
+      Context context, AvailabilityRestrictions availabilityRestrictions) {
+    UserManager userManager = context.getSystemService(UserManager.class);
+    return userManager.isUserRunning(userHandle)
+        && userManager.isUserUnlocked(userHandle)
+        && !userManager.isQuietModeEnabled(userHandle);
+  }
+
+  @Override
+  public boolean hasPermissionToBind(Context context) {
+    cachePermissionRequests(context);
+
+    if (VERSION.SDK_INT >= VERSION_CODES.R
+        && requestsInteractAcrossProfiles
+        && context
+            .getSystemService(CrossProfileApps.class)
+            .getTargetUserProfiles()
+            .contains(userHandle)
+        && context.getSystemService(CrossProfileApps.class).canInteractAcrossProfiles()) {
+      return true;
+    }
+
+    if (requestsInteractAcrossUsersFull
+        && context.checkSelfPermission(INTERACT_ACROSS_USERS_FULL)
+            == PackageManager.PERMISSION_GRANTED) {
+      return true;
+    }
+
+    return false;
+  }
+
+  private void cachePermissionRequests(Context context) {
+    if (hasCachedPermissionRequests) {
+      return;
+    }
+
+    PackageManager packageManager = context.getPackageManager();
+    try {
+      PackageInfo packageInfo =
+          packageManager.getPackageInfo(context.getPackageName(), PackageManager.GET_PERMISSIONS);
+
+      if (packageInfo == null || packageInfo.requestedPermissions == null) {
+        hasCachedPermissionRequests = true;
+        return;
+      }
+
+      for (String permission : packageInfo.requestedPermissions) {
+        if (permission.equals(INTERACT_ACROSS_PROFILES)) {
+          requestsInteractAcrossProfiles = true;
+        } else if (permission.equals(INTERACT_ACROSS_USERS_FULL)) {
+          requestsInteractAcrossUsersFull = true;
+        }
+      }
+    } catch (NameNotFoundException e) {
+      Log.e(LOG_TAG, "Could not find package.", e);
+      requestsInteractAcrossProfiles = false;
+      requestsInteractAcrossUsersFull = false;
+    }
+
+    hasCachedPermissionRequests = true;
+  }
+}
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/DpcUserBinder.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/DpcUserBinder.java
new file mode 100644
index 0000000..60f378d
--- /dev/null
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/DpcUserBinder.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps;
+
+
+import android.app.admin.DevicePolicyManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.UserHandle;
+import com.google.android.enterprise.connectedapps.annotations.AvailabilityRestrictions;
+
+/** A {@link ConnectionBinder} used by Device Policy Controllers for binding across users. */
+public class DpcUserBinder implements ConnectionBinder {
+
+  private final UserHandle userHandle;
+
+  private final ComponentName deviceAdminReceiver;
+
+  public DpcUserBinder(ComponentName deviceAdminReceiver, UserHandle userHandle) {
+    if (userHandle == null) {
+      throw new NullPointerException();
+    }
+    if (deviceAdminReceiver == null) {
+      throw new NullPointerException();
+    }
+
+    this.userHandle = userHandle;
+    this.deviceAdminReceiver = deviceAdminReceiver;
+  }
+
+  @Override
+  public boolean tryBind(
+      Context context,
+      ComponentName bindToService,
+      ServiceConnection connection,
+      AvailabilityRestrictions availabilityRestrictions) {
+    DevicePolicyManager devicePolicyManager = context.getSystemService(DevicePolicyManager.class);
+    Intent bindIntent = new Intent();
+    bindIntent.setComponent(bindToService);
+    boolean hasBound =
+        devicePolicyManager.bindDeviceAdminServiceAsUser(
+            deviceAdminReceiver, bindIntent, connection, Context.BIND_AUTO_CREATE, userHandle);
+    if (!hasBound) {
+      context.unbindService(connection);
+    }
+    return hasBound;
+  }
+
+  @Override
+  public boolean bindingIsPossible(
+      Context context, AvailabilityRestrictions availabilityRestrictions) {
+    return true;
+  }
+
+  @Override
+  public boolean hasPermissionToBind(Context context) {
+    return true;
+  }
+}
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/FutureWrapper.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/FutureWrapper.java
index 96d0fd7..b797f0e 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/FutureWrapper.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/FutureWrapper.java
@@ -15,10 +15,10 @@
  */
 package com.google.android.enterprise.connectedapps;
 
-import android.os.Parcel;
+import android.os.Bundle;
+import com.google.android.enterprise.connectedapps.internal.BundleUtilities;
 import com.google.android.enterprise.connectedapps.internal.Bundler;
 import com.google.android.enterprise.connectedapps.internal.BundlerType;
-import com.google.android.enterprise.connectedapps.internal.ParcelUtilities;
 
 /** Wrapper for adding support for a future type to the Connected Apps SDK. */
 public abstract class FutureWrapper<E> implements LocalCallback {
@@ -34,9 +34,9 @@
   }
 
   @Override
-  public void onResult(int methodIdentifier, Parcel params) {
+  public void onResult(int methodIdentifier, Bundle params) {
     @SuppressWarnings("unchecked")
-    E result = (E) bundler.readFromParcel(params, bundlerType);
+    E result = (E) bundler.readFromBundle(params, "result", bundlerType);
 
     onResult(result);
   }
@@ -44,8 +44,8 @@
   public abstract void onResult(E result);
 
   @Override
-  public void onException(Parcel exception) {
-    Throwable throwable = ParcelUtilities.readThrowableFromParcel(exception);
+  public void onException(Bundle exception) {
+    Throwable throwable = BundleUtilities.readThrowableFromBundle(exception, "throwable");
     onException(throwable);
   }
 
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/LocalCallback.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/LocalCallback.java
index 6c27bb1..6abb0eb 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/LocalCallback.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/LocalCallback.java
@@ -15,11 +15,11 @@
  */
 package com.google.android.enterprise.connectedapps;
 
-import android.os.Parcel;
+import android.os.Bundle;
 
 /**
  * Interface used by callbacks used when calling {@link CrossProfileSender#callAsync(long, int,
- * Parcel, LocalCallback, long)}.
+ * Bundle, LocalCallback, Object, long)}.
  */
 public interface LocalCallback {
 
@@ -27,14 +27,14 @@
    * Pass a result into the callback.
    *
    * @param methodIdentifier The method being responded to.
-   * @param params The result encoded in a {@link Parcel}. This should not be recycled.
+   * @param params A Bundle containing the result under the key "result".
    */
-  void onResult(int methodIdentifier, Parcel params);
+  void onResult(int methodIdentifier, Bundle params);
 
   /**
    * Pass an exception into the callback.
    *
-   * @param exception The exception encoded in a {@link Parcel}. This should not be recycled.
+   * @param exception A Bundle containing the exception under the key "throwable"
    */
-  void onException(Parcel exception);
+  void onException(Bundle exception);
 }
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/ProfileConnectionHolder.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/ProfileConnectionHolder.java
new file mode 100644
index 0000000..df2b87e
--- /dev/null
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/ProfileConnectionHolder.java
@@ -0,0 +1,37 @@
+package com.google.android.enterprise.connectedapps;
+
+
+/**
+ * {@link AutoCloseable} wrapper around a connection holder.
+ *
+ * <p>This will automatically call {@link ProfileConnector#removeConnectionHolder(Object)} when
+ * closed.
+ */
+public final class ProfileConnectionHolder implements AutoCloseable {
+  private final ProfileConnector profileConnector;
+  private final Object connectionHolder;
+
+  public static ProfileConnectionHolder create(
+      ProfileConnector profileConnector, Object connectionHolder) {
+
+    ProfileConnectionHolder profileConnectionHolder =
+        new ProfileConnectionHolder(profileConnector, connectionHolder);
+
+    profileConnector.addConnectionHolderAlias(profileConnectionHolder, connectionHolder);
+
+    return profileConnectionHolder;
+  }
+
+  private ProfileConnectionHolder(ProfileConnector profileConnector, Object connectionHolder) {
+    if (profileConnector == null || connectionHolder == null) {
+      throw new NullPointerException();
+    }
+    this.profileConnector = profileConnector;
+    this.connectionHolder = connectionHolder;
+  }
+
+  @Override
+  public void close() {
+    profileConnector.removeConnectionHolder(connectionHolder);
+  }
+}
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/ProfileConnector.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/ProfileConnector.java
index f5335b3..58d036c 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/ProfileConnector.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/ProfileConnector.java
@@ -21,44 +21,31 @@
 /** A {@link ProfileConnector} is used to manage the connection between profiles. */
 public interface ProfileConnector {
   /**
-   * Start trying to connect to the other profile and start manually managing the connection.
+   * Execute {@link #connect(Object)} with a new connection holder.
    *
-   * <p>This will mean that the connection will not be dropped automatically to save resources.
-   *
-   * <p>Must be called before interacting with synchronous cross-profile methods.
-   *
-   * <p>If the connection can not be made, then no errors will be thrown and connections will
-   * re-attempted indefinitely.
-   *
-   * @see #connect()
-   * @see #stopManualConnectionManagement()
+   * <p>You must use {@link #removeConnectionHolder(Object)} with the returned {@link
+   * ProfileConnectionHolder} or call {@link ProfileConnectionHolder#close()} when you are finished
+   * with the connection.
    */
-  void startConnecting();
+  ProfileConnectionHolder connect() throws UnavailableProfileException;
 
   /**
-   * Attempt to connect to the other profile and start manually managing the connection.
+   * Attempt to connect to the other profile and add a connection holder.
    *
    * <p>This will mean that the connection will not be dropped automatically to save resources.
    *
-   * <p>Must be called before interacting with synchronous cross-profile methods.
-   *
    * <p>This must not be called from the main thread.
    *
-   * @see #startConnecting()
-   * @see #stopManualConnectionManagement()
+   * <p>You must remove the connection holder once you have finished with it. See {@link
+   * #removeConnectionHolder(Object)}.
+   *
+   * <p>Returns a {@link ProfileConnectionHolder} which can be used to automatically remove this
+   * connection holder using try-with-resources. Either the {@link ProfileConnectionHolder} or the
+   * passed in {@code connectionHolder} can be used with {@link #removeConnectionHolder(Object)}.
+   *
    * @throws UnavailableProfileException If the connection cannot be made.
    */
-  void connect() throws UnavailableProfileException;
-
-  /**
-   * Stop manual connection management.
-   *
-   * <p>This can be called after {@link #startConnecting()} to return connection management
-   * responsibilities to the SDK.
-   *
-   * <p>You should not make any synchronous cross-profile calls after calling this method.
-   */
-  void stopManualConnectionManagement();
+  ProfileConnectionHolder connect(Object connectionHolder) throws UnavailableProfileException;
 
   /**
    * Return the {@link CrossProfileSender} being used for this connection.
@@ -68,34 +55,28 @@
   CrossProfileSender crossProfileSender();
 
   /**
-   * Register a listener to be called when a profile is connected or disconnected.
+   * Add a listener to be called when a profile is connected or disconnected.
    *
    * <p>{@link #isConnected()} can be called to check if a connection is established.
    *
-   * @see #unregisterConnectionListener(ConnectionListener)
+   * @see #removeConnectionListener(ConnectionListener)
    */
-  void registerConnectionListener(ConnectionListener listener);
+  void addConnectionListener(ConnectionListener listener);
+
+  /** Remove a listener added using {@link #addConnectionListener(ConnectionListener)}. */
+  void removeConnectionListener(ConnectionListener listener);
 
   /**
-   * Unregister a listener registered using {@link #registerConnectionListener(
-   * ConnectionListener)}.
-   */
-  void unregisterConnectionListener(ConnectionListener listener);
-
-  /**
-   * Register a listener to be called when a profile becomes available or unavailable.
+   * Add a listener to be called when a profile becomes available or unavailable.
    *
    * <p>{@link #isAvailable()} can be called to check if a profile is available.
    *
-   * @see #unregisterAvailabilityListener(AvailabilityListener)
+   * @see #removeAvailabilityListener(AvailabilityListener)
    */
-  void registerAvailabilityListener(AvailabilityListener listener);
+  void addAvailabilityListener(AvailabilityListener listener);
 
-  /**
-   * Unregister a listener registered using {@link #registerAvailabilityListener(
-   * AvailabilityListener)}.
-   */
-  void unregisterAvailabilityListener(AvailabilityListener listener);
+  /** Remove a listener registered using {@link #addAvailabilityListener( AvailabilityListener)}. */
+  void removeAvailabilityListener(AvailabilityListener listener);
 
   /**
    * Return true if there is another profile which could be connected to.
@@ -122,10 +103,36 @@
   Context applicationContext();
 
   /**
-   * Returns true if this connection is being managed manually.
+   * Register an object as holding the connection open.
    *
-   * <p>Use {@link #startConnecting()} to begin manual connection management, and {@link
-   * #stopManualConnectionManagement()} to end it.
+   * <p>While there is at least one connection holder, the connected apps SDK will attempt to stay
+   * connected.
+   *
+   * <p>You must remove the connection holder once you have finished with it. See {@link
+   * #removeConnectionHolder(Object)}.
+   *
+   * <p>Returns a {@link ProfileConnectionHolder} which can be used to automatically remove this
+   * connection holder using try-with-resources. Either the {@link ProfileConnectionHolder} or the
+   * passed in {@code connectionHolder} can be used with {@link #removeConnectionHolder(Object)}.
    */
-  boolean isManuallyManagingConnection();
+  ProfileConnectionHolder addConnectionHolder(Object connectionHolder);
+
+  /**
+   * Registers a connection holder alias.
+   *
+   * <p>This means that if the key is removed, then the value will also be removed. If the value is
+   * removed, the key will not be removed.
+   */
+  void addConnectionHolderAlias(Object key, Object value);
+
+  /**
+   * Remove a connection holder.
+   *
+   * <p>Once there are no remaining connection holders, the connection will be able to be closed.
+   *
+   * <p>See {@link #addConnectionHolder(Object)}.
+   */
+  void removeConnectionHolder(Object connectionHolder);
+
+  void clearConnectionHolders();
 }
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestInterfaceProvider.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/UserBinderFactory.java
similarity index 65%
copy from tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestInterfaceProvider.java
copy to sdk/src/main/java/com/google/android/enterprise/connectedapps/UserBinderFactory.java
index 06c01ba..ea5866d 100644
--- a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestInterfaceProvider.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/UserBinderFactory.java
@@ -13,14 +13,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.android.enterprise.connectedapps.testapp.types;
+package com.google.android.enterprise.connectedapps;
 
-import com.google.android.enterprise.connectedapps.annotations.CrossProfileProvider;
+import android.os.UserHandle;
 
-public class TestInterfaceProvider {
+/**
+ * Used to provide custom {@link ConnectionBinder} implementations to {@link AbstractUserConnector}.
+ */
+public interface UserBinderFactory {
 
-  @CrossProfileProvider
-  public TestCrossProfileInterface provideCrossProfileInterface() {
-    return s -> s;
-  }
+  ConnectionBinder createBinder(UserHandle handle);
 }
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/UserConnectionHolder.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/UserConnectionHolder.java
new file mode 100644
index 0000000..fbec5a0
--- /dev/null
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/UserConnectionHolder.java
@@ -0,0 +1,41 @@
+package com.google.android.enterprise.connectedapps;
+
+import android.os.UserHandle;
+
+/**
+ * {@link AutoCloseable} wrapper around a connection holder.
+ *
+ * <p>This will automatically call {@link UserConnector#removeConnectionHolder(UserHandle, Object)}
+ * when closed.
+ */
+public final class UserConnectionHolder implements AutoCloseable {
+  private final UserConnector userConnector;
+  private final UserHandle userHandle;
+  private final Object connectionHolder;
+
+  public static UserConnectionHolder create(
+      UserConnector userConnector, UserHandle userHandle, Object connectionHolder) {
+
+    UserConnectionHolder userConnectionHolder =
+        new UserConnectionHolder(userConnector, userHandle, connectionHolder);
+
+    userConnector.addConnectionHolderAlias(userHandle, userConnectionHolder, connectionHolder);
+
+    return userConnectionHolder;
+  }
+
+  private UserConnectionHolder(
+      UserConnector userConnector, UserHandle userHandle, Object connectionHolder) {
+    if (userConnector == null || userHandle == null || connectionHolder == null) {
+      throw new NullPointerException();
+    }
+    this.userConnector = userConnector;
+    this.userHandle = userHandle;
+    this.connectionHolder = connectionHolder;
+  }
+
+  @Override
+  public void close() {
+    userConnector.removeConnectionHolder(userHandle, connectionHolder);
+  }
+}
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/UserConnector.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/UserConnector.java
index 29f888b..199a4f1 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/UserConnector.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/UserConnector.java
@@ -21,45 +21,35 @@
 
 /** A {@link UserConnector} is used to manage the connection between users. */
 public interface UserConnector {
-  /**
-   * Start trying to connect to another user and start manually managing the connection.
-   *
-   * <p>This will mean that the connection will not be dropped automatically to save resources.
-   *
-   * <p>Must be called before interacting with synchronous cross-user methods.
-   *
-   * <p>If the connection can not be made, then no errors will be thrown and connections will
-   * re-attempted indefinitely.
-   *
-   * @see #connect(UserHandle)
-   * @see #stopManualConnectionManagement(UserHandle)
-   */
-  void startConnecting(UserHandle userHandle);
 
   /**
-   * Attempt to connect to the user and start manually managing the connection.
+   * Execute {@link #connect(UserHandle, Object)} with a new connection holder.
+   *
+   * <p>You must use {@link #removeConnectionHolder(UserHandle, Object)} with the returned {@link
+   * UserConnectionHolder} or call {@link UserConnectionHolder#close()} when you are finished with
+   * the connection.
+   */
+  UserConnectionHolder connect(UserHandle userHandle) throws UnavailableProfileException;
+
+  /**
+   * Attempt to connect to the other user and add a connection holder.
    *
    * <p>This will mean that the connection will not be dropped automatically to save resources.
    *
-   * <p>Must be called before interacting with synchronous cross-profile methods.
-   *
    * <p>This must not be called from the main thread.
    *
-   * @see #startConnecting(UserHandle)
-   * @see #stopManualConnectionManagement(UserHandle)
+   * <p>You must remove the connection holder once you have finished with it. See {@link
+   * #removeConnectionHolder(UserHandle, Object)}.
+   *
+   * <p>Returns a {@link UserConnectionHolder} which can be used to automatically remove this
+   * connection holder using try-with-resources. Either the {@link UserConnectionHolder} or the
+   * passed in {@code connectionHolder} can be used with {@link #removeConnectionHolder(UserHandle,
+   * Object)}.
+   *
    * @throws UnavailableProfileException If the connection cannot be made.
    */
-  void connect(UserHandle userHandle) throws UnavailableProfileException;
-
-  /**
-   * Stop manual connection management.
-   *
-   * <p>This can be called after {@link #startConnecting(UserHandle)} to return connection
-   * management responsibilities to the SDK.
-   *
-   * <p>You should not make any synchronous cross-profile calls after calling this method.
-   */
-  void stopManualConnectionManagement(UserHandle userHandle);
+  UserConnectionHolder connect(UserHandle userHandle, Object connectionHolder)
+      throws UnavailableProfileException;
 
   /**
    * Return the {@link CrossProfileSender} being used for the connection to the user.
@@ -69,34 +59,34 @@
   CrossProfileSender crossProfileSender(UserHandle userHandle);
 
   /**
-   * Register a listener to be called when the user is connected or disconnected.
+   * Add a listener to be called when the user is connected or disconnected.
    *
    * <p>{@link #isConnected(UserHandle)} can be called to check if a connection is established.
    *
-   * @see #unregisterConnectionListener(UserHandle, ConnectionListener)
+   * @see #removeConnectionListener(UserHandle, ConnectionListener)
    */
-  void registerConnectionListener(UserHandle userHandle, ConnectionListener listener);
+  void addConnectionListener(UserHandle userHandle, ConnectionListener listener);
 
   /**
-   * Unregister a listener registered using {@link #registerConnectionListener(UserHandle,
+   * Remove a listener registered using {@link #addConnectionListener(UserHandle,
    * ConnectionListener)}.
    */
-  void unregisterConnectionListener(UserHandle userHandle, ConnectionListener listener);
+  void removeConnectionListener(UserHandle userHandle, ConnectionListener listener);
 
   /**
-   * Register a listener to be called when a user becomes available or unavailable.
+   * Add a listener to be called when a user becomes available or unavailable.
    *
    * <p>{@link #isAvailable(UserHandle)} can be called to check if a user is available.
    *
-   * @see #unregisterAvailabilityListener(UserHandle, AvailabilityListener)
+   * @see #removeAvailabilityListener(UserHandle, AvailabilityListener)
    */
-  void registerAvailabilityListener(UserHandle userHandle, AvailabilityListener listener);
+  void addAvailabilityListener(UserHandle userHandle, AvailabilityListener listener);
 
   /**
-   * Unregister a listener registered using {@link #registerAvailabilityListener(UserHandle,
+   * Remove a listener registered using {@link #addAvailabilityListener(UserHandle,
    * AvailabilityListener)}.
    */
-  void unregisterAvailabilityListener(UserHandle userHandle, AvailabilityListener listener);
+  void removeAvailabilityListener(UserHandle userHandle, AvailabilityListener listener);
 
   /**
    * Return true if the user can be connected to.
@@ -114,21 +104,43 @@
    */
   boolean isConnected(UserHandle userHandle);
 
-  /**
-   * Return an instance of {@link ConnectedAppsUtils} for dealing with the connection to the user.
-   */
-  ConnectedAppsUtils utils(UserHandle userHandle);
-
   Permissions permissions(UserHandle userHandle);
 
   /** Return the application context used by the user. */
   Context applicationContext(UserHandle userHandle);
 
   /**
-   * Returns true if the connection to the user is being managed manually.
+   * Register an object as holding the connection open.
    *
-   * <p>Use {@link #startConnecting(UserHandle)} to begin manual connection management, and {@link
-   * #stopManualConnectionManagement(UserHandle)} to end it.
+   * <p>While there is at least one connection holder, the Connected Apps SDK will attempt to stay
+   * connected.
+   *
+   * <p>You must remove the connection holder once you have finished with it. See {@link
+   * #removeConnectionHolder(UserHandle, Object)}.
+   *
+   * <p>Returns a {@link UserConnectionHolder} which can be used to automatically remove this
+   * connection holder using try-with-resources. Either the {@link UserConnectionHolder} or the
+   * passed in {@code connectionHolder} can be used with {@link #removeConnectionHolder(UserHandle,
+   * Object)}.
    */
-  boolean isManuallyManagingConnection(UserHandle userHandle);
+  UserConnectionHolder addConnectionHolder(UserHandle userHandle, Object connectionHolder);
+
+  /**
+   * Registers a connection holder alias.
+   *
+   * <p>This means that if the key is removed, then the value will also be removed. If the value is
+   * removed, the key will not be removed.
+   */
+  void addConnectionHolderAlias(UserHandle userHandle, Object key, Object value);
+
+  /**
+   * Remove a connection holder.
+   *
+   * <p>Once there are no remaining connection holders, the connection will be able to be closed.
+   *
+   * <p>See {@link #addConnectionHolder(UserHandle, Object)}.
+   */
+  void removeConnectionHolder(UserHandle userHandle, Object connectionHolder);
+
+  void clearConnectionHolders(UserHandle userHandle);
 }
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/UserConnectorWrapper.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/UserConnectorWrapper.java
new file mode 100644
index 0000000..0f49a15
--- /dev/null
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/UserConnectorWrapper.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps;
+
+import android.content.Context;
+import android.os.UserHandle;
+import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
+
+/**
+ * A compatibility wrapper which allows a {@link UserConnector} to be used in generated code where a
+ * {@link ProfileConnector} is expected.
+ */
+public class UserConnectorWrapper implements ProfileConnector {
+
+  private final UserConnector userConnector;
+
+  private final UserHandle userHandle;
+
+  public UserConnectorWrapper(UserConnector userConnector, UserHandle userHandle) {
+    this.userConnector = userConnector;
+    this.userHandle = userHandle;
+  }
+
+  @Override
+  public ProfileConnectionHolder connect() throws UnavailableProfileException {
+    return connect(CrossProfileSender.MANUAL_MANAGEMENT_CONNECTION_HOLDER);
+  }
+
+  @Override
+
+  public ProfileConnectionHolder connect(Object connectionHolder)
+      throws UnavailableProfileException {
+
+    userConnector.connect(userHandle, connectionHolder);
+
+
+    return ProfileConnectionHolder.create(this, connectionHolder);
+
+  }
+
+  @Override
+  public CrossProfileSender crossProfileSender() {
+    return userConnector.crossProfileSender(userHandle);
+  }
+
+  @Override
+  public void addConnectionListener(ConnectionListener listener) {
+    userConnector.addConnectionListener(userHandle, listener);
+  }
+
+  @Override
+  public void removeConnectionListener(ConnectionListener listener) {
+    userConnector.removeConnectionListener(userHandle, listener);
+  }
+
+  @Override
+  public void addAvailabilityListener(AvailabilityListener listener) {
+    userConnector.addAvailabilityListener(userHandle, listener);
+  }
+
+  @Override
+  public void removeAvailabilityListener(AvailabilityListener listener) {
+    userConnector.removeAvailabilityListener(userHandle, listener);
+  }
+
+  @Override
+  public boolean isAvailable() {
+    return userConnector.isAvailable(userHandle);
+  }
+
+  @Override
+  public boolean isConnected() {
+    return userConnector.isConnected(userHandle);
+  }
+
+  @Override
+  public ConnectedAppsUtils utils() {
+    throw new UnsupportedOperationException(
+        "Cannot get ConnectedAppsUtils for a cross-user connector.");
+  }
+
+  @Override
+  public Permissions permissions() {
+    return userConnector.permissions(userHandle);
+  }
+
+  @Override
+  public Context applicationContext() {
+    return userConnector.applicationContext(userHandle);
+  }
+
+  @Override
+  public ProfileConnectionHolder addConnectionHolder(Object connectionHolder) {
+    userConnector.addConnectionHolder(userHandle, connectionHolder);
+
+    return ProfileConnectionHolder.create(this, connectionHolder);
+  }
+
+  @Override
+  public void addConnectionHolderAlias(Object key, Object value) {
+    userConnector.addConnectionHolderAlias(userHandle, key, value);
+  }
+
+  @Override
+  public void removeConnectionHolder(Object connectionHolder) {
+    userConnector.removeConnectionHolder(userHandle, connectionHolder);
+  }
+
+  @Override
+  public void clearConnectionHolders() {
+    userConnector.clearConnectionHolders(userHandle);
+  }
+}
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/exceptions/ProfileRuntimeException.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/exceptions/ProfileRuntimeException.java
index 511a77a..17fc457 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/exceptions/ProfileRuntimeException.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/exceptions/ProfileRuntimeException.java
@@ -16,7 +16,7 @@
 package com.google.android.enterprise.connectedapps.exceptions;
 
 /**
- * Thrown when a {@link Throwable} is thrown during a cross-profile call.
+ * Thrown when a {@link RuntimeException} is thrown during a cross-profile call.
  *
  * <p>To get the original exception, call {@link #getCause()}.
  */
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/ParcelCallReceiver.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/BundleCallReceiver.java
similarity index 66%
rename from sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/ParcelCallReceiver.java
rename to sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/BundleCallReceiver.java
index e52386f..38ff3bc 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/ParcelCallReceiver.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/BundleCallReceiver.java
@@ -15,6 +15,7 @@
  */
 package com.google.android.enterprise.connectedapps.internal;
 
+import android.os.Bundle;
 import android.os.Parcel;
 import com.google.android.enterprise.connectedapps.CrossProfileSender;
 import java.nio.ByteBuffer;
@@ -25,13 +26,20 @@
 /**
  * Build up parcels over multiple calls and prepare responses.
  *
- * <p>This is the counterpart to {@link ParcelCallSender}. Calls by the {@link ParcelCallSender}
+ * <p>This is the counterpart to {@link BundleCallSender}. Calls by the {@link BundleCallSender}
  * should be relayed to an instance of this class.
  */
-public final class ParcelCallReceiver {
+public final class BundleCallReceiver {
+
+  static final byte STATUS_COMPLETE = 0;
+  static final byte STATUS_INCOMPLETE = 1;
+  static final byte STATUS_INCLUDES_BUNDLES = 2;
+
   private final Map<Long, byte[]> preparedCalls = new HashMap<>();
   private final Map<Long, Integer> preparedCallParts = new HashMap<>();
   private final Map<Long, byte[]> preparedResponses = new HashMap<>();
+  private final Map<Long, Bundle> preparedCallBundles = new HashMap<>();
+  private final Map<Long, Bundle> preparedResponseBundles = new HashMap<>();
 
   /**
    * Prepare a response to be returned by calls to {@link #getPreparedResponse(long, int)}.
@@ -41,20 +49,30 @@
    * is a 1, then the following 4 bytes will be an {@link Integer} representing the total number of
    * bytes in the response.
    *
-   * <p>The @{link Parcel} will not be recycled.</p>
+   * <p>The {@code byte[]} returned will consist only of a 2 if a bundle needs to be fetched using
+   * {@link #getPreparedResponseBundle(long, int)}.
    */
-  public byte[] prepareResponse(long callId, Parcel responseParcel) {
-    byte[] responseBytes = responseParcel.marshall();
+  public byte[] prepareResponse(long callId, Bundle responseBundle) {
+    Parcel responseParcel = Parcel.obtain();
+    responseBundle.writeToParcel(responseParcel, /* flags= */ 0);
+
+    byte[] responseBytes;
+    try {
+      responseBytes = responseParcel.marshall();
+    } catch (Exception | AssertionError e) {
+      prepareResponseBundle(callId, /* bundleId= */ 0, responseBundle);
+      return new byte[] {STATUS_INCLUDES_BUNDLES};
+    } finally {
+      responseParcel.recycle();
+    }
 
     if (responseBytes.length <= CrossProfileSender.MAX_BYTES_PER_BLOCK) {
-      // Prepend with 0 to indicate the bytes are complete
-      return ByteUtilities.joinByteArrays(new byte[] {0}, responseBytes);
+      return ByteUtilities.joinByteArrays(new byte[] {STATUS_COMPLETE}, responseBytes);
     }
     // Record the bytes to be sent and send the first block
     preparedResponses.put(callId, responseBytes);
     byte[] response = new byte[CrossProfileSender.MAX_BYTES_PER_BLOCK + 5];
-    // 1 = has additional content
-    response[0] = 1;
+    response[0] = STATUS_INCOMPLETE;
     byte[] sizeBytes = ByteBuffer.allocate(4).putInt(responseBytes.length).array();
     System.arraycopy(sizeBytes, /* srcPos= */ 0, response, /* destPos= */ 1, /* length= */ 4);
     System.arraycopy(
@@ -66,6 +84,26 @@
     return response;
   }
 
+  public void prepareBundle(long callId, int bundleId, Bundle bundle) {
+    if (preparedCallBundles.containsKey(callId)) {
+      throw new IllegalStateException("Already prepared bundle for call " + callId);
+    }
+
+    preparedCallBundles.put(callId, bundle);
+  }
+
+  private void prepareResponseBundle(long callId, int bundleId, Bundle bundle) {
+    if (preparedResponseBundles.containsKey(callId)) {
+      throw new IllegalStateException("Already prepared bundle for response " + callId);
+    }
+
+    preparedResponseBundles.put(callId, bundle);
+  }
+
+  public Bundle getPreparedResponseBundle(long callId, int bundleId) {
+    return preparedResponseBundles.remove(callId);
+  }
+
   /**
    * Prepare a call, storing one block of bytes for a call which will be completed with a call to
    * {@link #getPreparedCall(long, int, byte[])}.
@@ -89,18 +127,20 @@
   }
 
   /**
-   * Fetch the full {@link Parcel using bytes previously stored by calls to
-   * {@link #prepareCall(long, int, int, byte[])}.
+   * Fetch the full {@link Bundle} using bytes previously stored by calls to {@link
+   * #prepareCall(long, int, int, byte[])}.
    *
    * <p>If this is the only block, then the {@code paramBytes} will be unmarshalled directly into a
-   * {@link Parcel}.
-   *
-   * <p>The returned {@link Parcel} must be recycled after use.
+   * {@link Bundle}.
    *
    * @throws IllegalStateException If this is not the only block, and any previous blocks are
    *     missing.
    */
-  public Parcel getPreparedCall(long callId, int blockId, byte[] paramBytes) {
+  public Bundle getPreparedCall(long callId, int blockId, byte[] paramBytes) {
+    if (bytesRefersToBundle(paramBytes)) {
+      return preparedCallBundles.remove(callId);
+    }
+
     if (blockId > 0) {
       int expectedBlocks = 0;
       for (int i = 0; i < blockId; i++) {
@@ -122,14 +162,21 @@
       preparedCallParts.remove(callId);
     }
 
-    Parcel parcel = Parcel.obtain(); // Recycled by caller
+    Parcel parcel = Parcel.obtain();
     parcel.unmarshall(paramBytes, 0, paramBytes.length);
     parcel.setDataPosition(0);
-    return parcel;
+    Bundle bundle = new Bundle(Bundler.class.getClassLoader());
+    bundle.readFromParcel(parcel);
+    parcel.recycle();
+    return bundle;
+  }
+
+  static boolean bytesRefersToBundle(byte[] bytes) {
+    return bytes[0] == STATUS_INCLUDES_BUNDLES;
   }
 
   /**
-   * Get a block from a response previously prepared with {@link #prepareResponse(long, Parcel)}.
+   * Get a block from a response previously prepared with {@link #prepareResponse(long, Bundle)}.
    *
    * <p>If this is the final block, then the prepared blocks will be dropped, and future calls to
    * this method will fail.
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/ParcelCallSender.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/BundleCallSender.java
similarity index 60%
rename from sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/ParcelCallSender.java
rename to sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/BundleCallSender.java
index a5b729a..e7fa9fd 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/ParcelCallSender.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/BundleCallSender.java
@@ -16,7 +16,11 @@
 package com.google.android.enterprise.connectedapps.internal;
 
 import static com.google.android.enterprise.connectedapps.CrossProfileSender.MAX_BYTES_PER_BLOCK;
+import static com.google.android.enterprise.connectedapps.internal.BundleCallReceiver.STATUS_INCLUDES_BUNDLES;
+import static com.google.android.enterprise.connectedapps.internal.BundleCallReceiver.STATUS_INCOMPLETE;
+import static com.google.android.enterprise.connectedapps.internal.BundleCallReceiver.bytesRefersToBundle;
 
+import android.os.Bundle;
 import android.os.Parcel;
 import android.os.RemoteException;
 import android.os.TransactionTooLargeException;
@@ -30,20 +34,28 @@
  * This represents a single action of (sending a {@link Parcel} and possibly fetching a response,
  * which may be split up over many calls (if the payload is large).
  *
- * <p>The receiver should relay calls to a {@link ParcelCallReceiver}.
+ * <p>The receiver should relay calls to a {@link BundleCallReceiver}.
  */
-abstract class ParcelCallSender {
+abstract class BundleCallSender {
+
+  private static final String LOG_TAG = "BundleCallSender";
 
   private static final long RETRY_DELAY_MILLIS = 10;
   private static final int MAX_RETRIES = 10;
 
   /**
-   * The arguments passed to this should be passed to {@link ParcelCallReceiver#prepareCall(long,
+   * The arguments passed to this should be passed to {@link BundleCallReceiver#prepareCall(long,
    * int, int, byte[])}.
    */
   abstract void prepareCall(long callId, int blockId, int totalBytes, byte[] bytes)
       throws RemoteException;
 
+  /**
+   * The arguments passed to this should be passed to {@link BundleCallReceiver#prepareBundle(long,
+   * int, Bundle)}.
+   */
+  abstract void prepareBundle(long callId, int bundleId, Bundle bundle) throws RemoteException;
+
   private void prepareCallAndRetry(
       long callId, int blockId, int totalBytes, byte[] bytes, int retries) throws RemoteException {
     while (true) {
@@ -58,7 +70,28 @@
         try {
           Thread.sleep(RETRY_DELAY_MILLIS);
         } catch (InterruptedException ex) {
-          Log.w("ParcelCallSender", "Interrupted on prepare retry", ex);
+          Log.w(LOG_TAG, "Interrupted on prepare retry", ex);
+          // If we can't sleep we'll just try again immediately
+        }
+      }
+    }
+  }
+
+  private void prepareBundleAndRetry(long callId, int bundleId, Bundle bundle, int retries)
+      throws RemoteException {
+    while (true) {
+      try {
+        prepareBundle(callId, bundleId, bundle);
+        break;
+      } catch (TransactionTooLargeException e) {
+        if (retries-- <= 0) {
+          throw e;
+        }
+
+        try {
+          Thread.sleep(RETRY_DELAY_MILLIS);
+        } catch (InterruptedException ex) {
+          Log.w(LOG_TAG, "Interrupted on prepare retry", ex);
           // If we can't sleep we'll just try again immediately
         }
       }
@@ -67,7 +100,7 @@
 
   /**
    * The arguments passed to this should be passed to {@link
-   * ParcelCallReceiver#getPreparedCall(long, int, byte[])} and used to complete the call.
+   * BundleCallReceiver#getPreparedCall(long, int, byte[])} and used to complete the call.
    */
   abstract byte[] call(long callId, int blockId, byte[] bytes) throws RemoteException;
 
@@ -84,7 +117,7 @@
         try {
           Thread.sleep(RETRY_DELAY_MILLIS);
         } catch (InterruptedException ex) {
-          Log.w("ParcelCallSender", "Interrupted on prepare retry", ex);
+          Log.w(LOG_TAG, "Interrupted on prepare retry", ex);
           // If we can't sleep we'll just try again immediately
         }
       }
@@ -93,10 +126,16 @@
 
   /**
    * The arguments passed to this should be passed to {@link
-   * ParcelCallReceiver#getPreparedResponse(long, int)}.
+   * BundleCallReceiver#getPreparedResponse(long, int)}.
    */
   abstract byte[] fetchResponse(long callId, int blockId) throws RemoteException;
 
+  /**
+   * The arguments passed to this should be passed to {@link
+   * BundleCallReceiver#getPreparedResponseBundle(long, int)}.
+   */
+  abstract Bundle fetchResponseBundle(long callId, int bundleId) throws RemoteException;
+
   private byte[] fetchResponseAndRetry(long callId, int blockId, int retries)
       throws RemoteException {
     while (true) {
@@ -110,7 +149,29 @@
         try {
           Thread.sleep(RETRY_DELAY_MILLIS);
         } catch (InterruptedException ex) {
-          Log.w("ParcelCallSender", "Interrupted on prepare retry", ex);
+          Log.w(LOG_TAG, "Interrupted on prepare retry", ex);
+          // If we can't sleep we'll just try again immediately
+        }
+      }
+    }
+  }
+
+  private Bundle fetchResponseBundleAndRetry(long callId, int bundleId, int retries)
+      throws RemoteException {
+    while (true) {
+      try {
+        Bundle b = fetchResponseBundle(callId, bundleId);
+        b.setClassLoader(Bundler.class.getClassLoader());
+        return b;
+      } catch (TransactionTooLargeException e) {
+        if (retries-- <= 0) {
+          throw e;
+        }
+
+        try {
+          Thread.sleep(RETRY_DELAY_MILLIS);
+        } catch (InterruptedException ex) {
+          Log.w(LOG_TAG, "Interrupted on prepare retry", ex);
           // If we can't sleep we'll just try again immediately
         }
       }
@@ -121,15 +182,59 @@
    * Use the prepareCall(long, int, int, byte[])} and {@link #call(long, int, byte[])} methods to
    * make a call.
    *
-   * <p>The returned {@link Parcel} must be recycled after use.
-   *
-   * <p>Returns {@code null} if the call does not return anything
-   *
    * @throws UnavailableProfileException if any call fails
    */
-  public Parcel makeParcelCall(Parcel parcel) throws UnavailableProfileException {
+  public Bundle makeBundleCall(Bundle bundle) throws UnavailableProfileException {
     long callIdentifier = UUID.randomUUID().getMostSignificantBits();
-    byte[] bytes = parcel.marshall();
+
+    Parcel parcel = Parcel.obtain();
+    bundle.writeToParcel(parcel, /* flags= */ 0);
+    parcel.setDataPosition(0);
+
+    byte[] bytes;
+
+    try {
+      bytes = parcel.marshall();
+    } catch (RuntimeException | AssertionError e) {
+      // We can't marshall the parcel so we send the bundle directly
+      try {
+        prepareBundleAndRetry(callIdentifier, /* bundleId= */ 0, bundle, MAX_RETRIES);
+      } catch (RemoteException e1) {
+        throw new UnavailableProfileException("Error passing bundle for call", e1);
+      }
+      bytes = new byte[] {STATUS_INCLUDES_BUNDLES};
+    } finally {
+      parcel.recycle();
+    }
+
+    byte[] returnBytes = makeParcelCall(callIdentifier, bytes);
+
+    if (returnBytes.length == 0) {
+      return null;
+    }
+
+    if (bytesRefersToBundle(returnBytes)) {
+      try {
+        return fetchResponseBundleAndRetry(callIdentifier, /* bundleId= */ 0, MAX_RETRIES);
+      } catch (RemoteException e) {
+        throw new UnavailableProfileException("Error fetching bundle for response", e);
+      }
+    }
+
+    Parcel returnParcel = fetchResponseParcel(callIdentifier, returnBytes);
+    Bundle returnBundle = new Bundle(Bundler.class.getClassLoader());
+    returnBundle.readFromParcel(returnParcel);
+    returnParcel.recycle();
+
+    return returnBundle;
+  }
+
+  private boolean bytesAreIncomplete(byte[] bytes) {
+    return bytes[0] == STATUS_INCOMPLETE;
+  }
+
+  private byte[] makeParcelCall(long callIdentifier, byte[] bytes)
+      throws UnavailableProfileException {
     try {
       int numberOfBlocks = (int) Math.ceil(bytes.length * 1.0 / MAX_BYTES_PER_BLOCK);
       int blockIdentifier = 0;
@@ -152,32 +257,18 @@
       }
 
       // Since we know block size is below the limit any errors will be temporary so we should retry
-      byte[] returnBytes = callAndRetry(callIdentifier, blockIdentifier, bytes, MAX_RETRIES);
-
-      if (returnBytes.length == 0) {
-        return null;
-      }
-
-      return fetchResponseParcel(callIdentifier, returnBytes);
+      return callAndRetry(callIdentifier, blockIdentifier, bytes, MAX_RETRIES);
     } catch (RemoteException e) {
       throw new UnavailableProfileException("Could not access other profile", e);
     }
   }
 
-  /**
-   * Use the {@link ParcelCallSender#prepareCall(long, int, int, byte[])} and {@link
-   * ParcelCallSender#fetchResponse(long, int)} methods to fetch a prepared response.
-   *
-   * <p>The returned {@link Parcel} must be recycled after use.
-   *
-   * @throws UnavailableProfileException if any call fails
-   */
   private Parcel fetchResponseParcel(long callIdentifier, byte[] returnBytes)
       throws UnavailableProfileException {
 
     // returnBytes[0] is 0 if the bytes are complete, or 1 if we need to fetch more
     int byteOffset = 1;
-    if (returnBytes[0] == 1) {
+    if (bytesAreIncomplete(returnBytes)) {
       // returnBytes[1] - returnBytes[4] are an int representing the total size of the return
       // value
       int totalBytes = ByteBuffer.wrap(returnBytes).getInt(/* index= */ 1);
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/BundleUtilities.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/BundleUtilities.java
new file mode 100644
index 0000000..5e5d946
--- /dev/null
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/BundleUtilities.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.internal;
+
+import android.os.Bundle;
+
+/** This class is only for internal use by the SDK. */
+public final class BundleUtilities {
+  private BundleUtilities() {}
+
+  public static void writeThrowableToBundle(Bundle bundle, String key, Throwable throwable) {
+    bundle.putSerializable(key, throwable);
+  }
+
+  public static Throwable readThrowableFromBundle(Bundle bundle, String key) {
+    bundle.setClassLoader(Bundler.class.getClassLoader());
+    return (Throwable) bundle.getSerializable(key);
+  }
+}
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/Bundler.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/Bundler.java
index 830fc67..4837f4c 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/Bundler.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/Bundler.java
@@ -15,18 +15,18 @@
  */
 package com.google.android.enterprise.connectedapps.internal;
 
+import android.os.Bundle;
 import android.os.Parcel;
 import android.os.Parcelable;
 import com.google.android.enterprise.connectedapps.annotations.CrossProfileConfiguration;
 
 /**
- * A {@link Bundler} is used to read and write {@link Parcel} instances without needing to use the
- * specific methods for the type of object being read/written.
+ * A {@link Bundler} is used to read and write {@link Bundler} and {@link Parcel} instances without
+ * needing to use the specific methods for the type of object being read/written.
  *
  * <p>Each {@link CrossProfileConfiguration} will have a {@link Bundler} which can deal with all of
  * the types used by that {@link CrossProfileConfiguration}.
  */
-// TODO(158552516): Rename now this no longer concerns Bundles
 public interface Bundler extends Parcelable {
   /*
    * We make {@link Bundler} instances implement {@link Parcelable} so that they can be passed
@@ -35,6 +35,52 @@
    */
 
   /**
+   * Write a value to a {@link Bundle}.
+   *
+   * @throws IllegalArgumentException if the {@code value} type is unsupported.
+   */
+  void writeToBundle(Bundle bundle, String key, Object value, BundlerType valueType);
+
+  default void writeToBundle(Bundle bundle, String key, byte value, BundlerType valueType) {
+    bundle.putByte(key, value);
+  }
+
+  default void writeToBundle(Bundle bundle, String key, short value, BundlerType valueType) {
+    bundle.putShort(key, value);
+  }
+
+  default void writeToBundle(Bundle bundle, String key, int value, BundlerType valueType) {
+    bundle.putInt(key, value);
+  }
+
+  default void writeToBundle(Bundle bundle, String key, long value, BundlerType valueType) {
+    bundle.putLong(key, value);
+  }
+
+  default void writeToBundle(Bundle bundle, String key, char value, BundlerType valueType) {
+    bundle.putChar(key, value);
+  }
+
+  default void writeToBundle(Bundle bundle, String key, float value, BundlerType valueType) {
+    bundle.putFloat(key, value);
+  }
+
+  default void writeToBundle(Bundle bundle, String key, double value, BundlerType valueType) {
+    bundle.putDouble(key, value);
+  }
+
+  default void writeToBundle(Bundle bundle, String key, boolean value, BundlerType valueType) {
+    bundle.putBoolean(key, value);
+  }
+
+  /**
+   * Read a value from a {@link Bundle}.
+   *
+   * @throws IllegalArgumentException if the {@code valueClass} type is unsupported.
+   */
+  Object readFromBundle(Bundle bundle, String key, BundlerType valueClass);
+
+  /**
    * Write a value to a {@link Parcel}.
    *
    * @throws IllegalArgumentException if the {@code value} type is unsupported.
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileParcelCallSender.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileBundleCallSender.java
similarity index 80%
rename from sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileParcelCallSender.java
rename to sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileBundleCallSender.java
index 3d66792..6d327be 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileParcelCallSender.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileBundleCallSender.java
@@ -15,23 +15,24 @@
  */
 package com.google.android.enterprise.connectedapps.internal;
 
+import android.os.Bundle;
 import android.os.RemoteException;
 import com.google.android.enterprise.connectedapps.ICrossProfileCallback;
 import com.google.android.enterprise.connectedapps.ICrossProfileService;
 import org.checkerframework.checker.nullness.qual.Nullable;
 
 /**
- * Implementation of {@link ParcelCallSender} used when making synchronous or asynchronous
+ * Implementation of {@link BundleCallSender} used when making synchronous or asynchronous
  * cross-profile calls.
  */
-public final class CrossProfileParcelCallSender extends ParcelCallSender {
+public final class CrossProfileBundleCallSender extends BundleCallSender {
 
   private final ICrossProfileService wrappedService;
   private final long crossProfileTypeIdentifier;
   private final int methodIdentifier;
   private final @Nullable ICrossProfileCallback callback;
 
-  public CrossProfileParcelCallSender(
+  public CrossProfileBundleCallSender(
       ICrossProfileService service,
       long crossProfileTypeIdentifier,
       int methodIdentifier,
@@ -52,6 +53,11 @@
   }
 
   @Override
+  void prepareBundle(long callId, int bundleId, Bundle bundle) throws RemoteException {
+    wrappedService.prepareBundle(callId, bundleId, bundle);
+  }
+
+  @Override
   byte[] call(long callId, int blockId, byte[] params) throws RemoteException {
     return wrappedService.call(
         callId, blockId, crossProfileTypeIdentifier, methodIdentifier, params, callback);
@@ -61,4 +67,9 @@
   byte[] fetchResponse(long callId, int blockId) throws RemoteException {
     return wrappedService.fetchResponse(callId, blockId);
   }
+
+  @Override
+  Bundle fetchResponseBundle(long callId, int bundleId) throws RemoteException {
+    return wrappedService.fetchResponseBundle(callId, bundleId);
+  }
 }
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackParcelCallSender.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackBundleCallSender.java
similarity index 72%
rename from sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackParcelCallSender.java
rename to sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackBundleCallSender.java
index 5bd4b65..0c6dbb7 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackParcelCallSender.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackBundleCallSender.java
@@ -15,16 +15,17 @@
  */
 package com.google.android.enterprise.connectedapps.internal;
 
+import android.os.Bundle;
 import android.os.RemoteException;
 import com.google.android.enterprise.connectedapps.ICrossProfileCallback;
 
-/** Implementation of {@link ParcelCallSender} used when passing a callback return value. */
-public class CrossProfileCallbackParcelCallSender extends ParcelCallSender {
+/** Implementation of {@link BundleCallSender} used when passing a callback return value. */
+public class CrossProfileCallbackBundleCallSender extends BundleCallSender {
 
   private final ICrossProfileCallback callback;
   private final int methodIdentifier;
 
-  public CrossProfileCallbackParcelCallSender(
+  public CrossProfileCallbackBundleCallSender(
       ICrossProfileCallback callback, int methodIdentifier) {
     if (callback == null) {
       throw new NullPointerException("callback must not be null");
@@ -39,6 +40,11 @@
     callback.prepareResult(callId, blockId, totalBytes, bytes);
   }
 
+  @Override
+  void prepareBundle(long callId, int bundleId, Bundle bundle) throws RemoteException {
+    callback.prepareBundle(callId, bundleId, bundle);
+  }
+
   /**
    * Relays to {@link ICrossProfileCallback#onResult(long, int, int, byte[])}.
    *
@@ -51,11 +57,18 @@
   }
 
   /**
-   * Callbacks cannot themselves return values, so this method will always throw an {@link
-   * IllegalStateException}.
+   * Always throw an {@link IllegalStateException} as callbacks cannot themselves return values.
    */
   @Override
   byte[] fetchResponse(long callId, int blockId) throws RemoteException {
     throw new IllegalStateException();
   }
+
+  /**
+   * Always throw an {@link IllegalStateException} as callbacks cannot themselves return values.
+   */
+  @Override
+  Bundle fetchResponseBundle(long callId, int bundleId) throws RemoteException {
+    throw new IllegalStateException();
+  }
 }
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackExceptionParcelCallSender.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackExceptionBundleCallSender.java
similarity index 69%
rename from sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackExceptionParcelCallSender.java
rename to sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackExceptionBundleCallSender.java
index cf63fd7..d39e80c 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackExceptionParcelCallSender.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackExceptionBundleCallSender.java
@@ -15,15 +15,16 @@
  */
 package com.google.android.enterprise.connectedapps.internal;
 
+import android.os.Bundle;
 import android.os.RemoteException;
 import com.google.android.enterprise.connectedapps.ICrossProfileCallback;
 
-/** Implementation of {@link ParcelCallSender} used when passing a callback exception. */
-public class CrossProfileCallbackExceptionParcelCallSender extends ParcelCallSender {
+/** Implementation of {@link BundleCallSender} used when passing a callback exception. */
+public class CrossProfileCallbackExceptionBundleCallSender extends BundleCallSender {
 
   private final ICrossProfileCallback callback;
 
-  public CrossProfileCallbackExceptionParcelCallSender(ICrossProfileCallback callback) {
+  public CrossProfileCallbackExceptionBundleCallSender(ICrossProfileCallback callback) {
     if (callback == null) {
       throw new NullPointerException("callback must not be null");
     }
@@ -36,6 +37,11 @@
     callback.prepareResult(callId, blockId, totalBytes, bytes);
   }
 
+  @Override
+  void prepareBundle(long callId, int bundleId, Bundle bundle) throws RemoteException {
+    callback.prepareBundle(callId, bundleId, bundle);
+  }
+
   /**
    * Relays to {@link ICrossProfileCallback#onException(long, int, byte[])}}.
    *
@@ -48,11 +54,18 @@
   }
 
   /**
-   * Callbacks cannot themselves return values, so this method will always throw an {@link
-   * IllegalStateException}.
+   * Always throw an {@link IllegalStateException} as callbacks cannot themselves return values.
    */
   @Override
   byte[] fetchResponse(long callId, int blockId) throws RemoteException {
     throw new IllegalStateException();
   }
+
+  /**
+   * Always throw an {@link IllegalStateException} as callbacks cannot themselves return values.
+   */
+  @Override
+  Bundle fetchResponseBundle(long callId, int bundleId) throws RemoteException {
+    throw new IllegalStateException();
+  }
 }
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackMultiMerger.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackMultiMerger.java
index cb7fada..eff4e8e 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackMultiMerger.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackMultiMerger.java
@@ -16,6 +16,7 @@
 package com.google.android.enterprise.connectedapps.internal;
 
 import com.google.android.enterprise.connectedapps.Profile;
+import com.google.errorprone.annotations.concurrent.GuardedBy;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
@@ -35,10 +36,18 @@
     void onResult(Map<Profile, R> results);
   }
 
-  private boolean hasCompleted = false;
+  private final Object lock = new Object();
   private final int expectedResults;
+
+  @GuardedBy("lock")
+  private boolean hasCompleted = false;
+
+  @GuardedBy("lock")
   private final Map<Profile, R> results = new HashMap<>();
+
+  @GuardedBy("lock")
   private final Set<Profile> missedResults = new HashSet<>();
+
   private final CrossProfileCallbackMultiMergerCompleteListener<R> listener;
 
   public CrossProfileCallbackMultiMerger(
@@ -59,39 +68,52 @@
    * <p>This should be called for every missing result. For example, if a remote call fails.
    */
   public void missingResult(Profile profileId) {
-    if (hasCompleted) {
-      // Once a result has been posted we don't check any more
-      return;
-    }
+    synchronized (lock) {
+      if (hasCompleted) {
+        // Once a result has been posted we don't check any more
+        return;
+      }
 
-    if (results.containsKey(profileId) || missedResults.contains(profileId)) {
-      // Only one result per profile is accepted
-      return;
+      if (results.containsKey(profileId) || missedResults.contains(profileId)) {
+        // Only one result per profile is accepted
+        return;
+      }
+      missedResults.add(profileId);
     }
-    missedResults.add(profileId);
 
     checkIfCompleted();
   }
 
   public void onResult(Profile profileId, R value) {
-    if (hasCompleted) {
-      // Once a result has been posted we don't check any more
-      return;
-    }
-    if (results.containsKey(profileId) || missedResults.contains(profileId)) {
-      // Only one result per profile is accepted
-      return;
-    }
+    synchronized (lock) {
+      if (hasCompleted) {
+        // Once a result has been posted we don't check any more
+        return;
+      }
+      if (results.containsKey(profileId) || missedResults.contains(profileId)) {
+        // Only one result per profile is accepted
+        return;
+      }
 
-    results.put(profileId, value);
+      results.put(profileId, value);
+    }
 
     checkIfCompleted();
   }
 
   private void checkIfCompleted() {
-    if (results.size() + missedResults.size() >= expectedResults) {
-      hasCompleted = true;
-      listener.onResult(results);
+    Map<Profile, R> resultsCopy = null;
+    synchronized (lock) {
+      if (results.size() + missedResults.size() >= expectedResults) {
+        hasCompleted = true;
+        // Some tests rely on values in the map potentially being null, so using HashMap instead of
+        // ImmutableMap here as some production code may depend on this behavior.
+        resultsCopy = new HashMap<>(results);
+      }
+    }
+    // Listener notified outside the lock to avoid potential deadlocks.
+    if (resultsCopy != null) {
+      listener.onResult(resultsCopy);
     }
   }
 }
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileFutureResultWriter.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileFutureResultWriter.java
index 7b58047..c884e36 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileFutureResultWriter.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/CrossProfileFutureResultWriter.java
@@ -15,7 +15,7 @@
  */
 package com.google.android.enterprise.connectedapps.internal;
 
-import android.os.Parcel;
+import android.os.Bundle;
 import android.util.Log;
 import com.google.android.enterprise.connectedapps.ICrossProfileCallback;
 import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
@@ -42,35 +42,31 @@
 
   @Override
   public void onSuccess(E result) {
-    Parcel parcel = Parcel.obtain(); // Recycled in this method
-    bundler.writeToParcel(parcel, result, bundlerType, /* flags= */ 0);
+    Bundle bundle = new Bundle(Bundler.class.getClassLoader());
+    bundler.writeToBundle(bundle, "result", result, bundlerType);
 
     try {
-      CrossProfileCallbackParcelCallSender parcelCallSender =
-          new CrossProfileCallbackParcelCallSender(callback, /* methodIdentifier= */ 0);
-      parcelCallSender.makeParcelCall(parcel);
+      CrossProfileCallbackBundleCallSender bundleCallSender =
+          new CrossProfileCallbackBundleCallSender(callback, /* methodIdentifier= */ 0);
+      bundleCallSender.makeBundleCall(bundle);
     } catch (UnavailableProfileException e) {
       Log.e("FutureResult", "Connection was dropped before response");
     } catch (RuntimeException e) {
       onFailure(new UnavailableProfileException("Error when writing result of future", e));
-    } finally {
-      parcel.recycle();
     }
   }
 
   @Override
   public void onFailure(Throwable throwable) {
-    Parcel parcel = Parcel.obtain(); // Recycled in this method
-    ParcelUtilities.writeThrowableToParcel(parcel, throwable);
+    Bundle bundle = new Bundle(Bundler.class.getClassLoader());
+    BundleUtilities.writeThrowableToBundle(bundle, "throwable", throwable);
 
     try {
-      CrossProfileCallbackExceptionParcelCallSender parcelCallSender =
-          new CrossProfileCallbackExceptionParcelCallSender(callback);
-      parcelCallSender.makeParcelCall(parcel);
+      CrossProfileCallbackExceptionBundleCallSender bundleCallSender =
+          new CrossProfileCallbackExceptionBundleCallSender(callback);
+      bundleCallSender.makeBundleCall(bundle);
     } catch (UnavailableProfileException e) {
       Log.e("FutureResult", "Connection was dropped before response");
-    } finally {
-      parcel.recycle();
     }
   }
 }
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/BackgroundExceptionThrower.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/ExceptionThrower.java
similarity index 75%
rename from sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/BackgroundExceptionThrower.java
rename to sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/ExceptionThrower.java
index 9511e19..64338c6 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/BackgroundExceptionThrower.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/ExceptionThrower.java
@@ -18,10 +18,10 @@
 import android.os.Handler;
 import android.os.Looper;
 
-/** Utility class for throwing an exception in the background after a delay. */
-public final class BackgroundExceptionThrower {
+/** Utility class for throwing an exception on the main thread after a delay. */
+public final class ExceptionThrower {
 
-  private BackgroundExceptionThrower() {}
+  private ExceptionThrower() {}
 
   private static class ThrowingRunnable implements Runnable {
     RuntimeException runtimeException;
@@ -35,24 +35,22 @@
       this.error = error;
     }
 
+
     @Override
     public void run() {
-      if (error != null) {
-        throw error;
-      }
       throw runtimeException;
     }
   }
 
-  /** Throw the given {@link Throwable} after a delay on the main looper. */
-  public static void throwInBackground(RuntimeException throwable) {
+  /** Throw the given {@link RuntimeException} after a delay on the main looper. */
+  public static void delayThrow(RuntimeException runtimeException) {
     // We add a small delay to ensure that the return can be completed before crashing
-    new Handler(Looper.getMainLooper()).postDelayed(new ThrowingRunnable(throwable), 1000);
+    new Handler(Looper.getMainLooper()).postDelayed(new ThrowingRunnable(runtimeException), 1000);
   }
 
   /** Throw the given {@link Error} after a delay on the main looper. */
-  public static void throwInBackground(Error throwable) {
+  public static void delayThrow(Error error) {
     // We add a small delay to ensure that the return can be completed before crashing
-    new Handler(Looper.getMainLooper()).postDelayed(new ThrowingRunnable(throwable), 1000);
+    new Handler(Looper.getMainLooper()).postDelayed(new ThrowingRunnable(error), 1000);
   }
 }
diff --git a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/MethodRunner.java b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/MethodRunner.java
index c9ee3dd..d6c54de 100644
--- a/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/MethodRunner.java
+++ b/sdk/src/main/java/com/google/android/enterprise/connectedapps/internal/MethodRunner.java
@@ -16,10 +16,10 @@
 package com.google.android.enterprise.connectedapps.internal;
 
 import android.content.Context;
-import android.os.Parcel;
+import android.os.Bundle;
 import com.google.android.enterprise.connectedapps.ICrossProfileCallback;
 
 /** Interface used internally by the SDK */
 public interface MethodRunner {
-  Parcel call(Context context, Parcel params, ICrossProfileCallback callback);
+  Bundle call(Context context, Bundle params, ICrossProfileCallback callback);
 }
diff --git a/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/AbstractFakeProfileConnector.java b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/AbstractFakeProfileConnector.java
index 1fdb5f3..deb4c8d 100644
--- a/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/AbstractFakeProfileConnector.java
+++ b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/AbstractFakeProfileConnector.java
@@ -21,11 +21,16 @@
 import com.google.android.enterprise.connectedapps.ConnectionListener;
 import com.google.android.enterprise.connectedapps.CrossProfileSender;
 import com.google.android.enterprise.connectedapps.Permissions;
+import com.google.android.enterprise.connectedapps.ProfileConnectionHolder;
 import com.google.android.enterprise.connectedapps.ProfileConnector;
 import com.google.android.enterprise.connectedapps.annotations.CustomProfileConnector.ProfileType;
 import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
+import java.util.Collections;
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
+import java.util.WeakHashMap;
+import java.util.concurrent.Executor;
 
 /**
  * A fake {@link ProfileConnector} for use in tests.
@@ -33,7 +38,7 @@
  * <p>This should be extended to make it compatible with a specific {@link ProfileConnector}
  * interface.
  */
-public abstract class AbstractFakeProfileConnector implements ProfileConnector {
+public abstract class AbstractFakeProfileConnector implements FakeProfileConnector {
 
   enum WorkProfileState {
     DOES_NOT_EXIST,
@@ -47,8 +52,11 @@
   private WorkProfileState workProfileState = WorkProfileState.DOES_NOT_EXIST;
   private boolean isConnected = false;
   private boolean hasPermissionToMakeCrossProfileCalls = true;
-  private boolean isManuallyManagingConnection = false;
+  private ConnectionHandler connectionHandler = () -> true;
+  private Executor executor = Runnable::run;
 
+  private final Set<Object> connectionHolders = Collections.newSetFromMap(new WeakHashMap<>());
+  private final Map<Object, Set<Object>> connectionHolderAliases = new WeakHashMap<>();
   private final Set<ConnectionListener> connectionListeners = new HashSet<>();
   private final Set<AvailabilityListener> availabilityListeners = new HashSet<>();
 
@@ -151,32 +159,30 @@
     notifyAvailabilityChanged();
   }
 
-  /**
-   * Force the connector to be "automatically" connected.
-   *
-   * <p>This call should only be used by the SDK and should not be called in tests. If you want to
-   * connect manually, use {@link #startConnecting()}, or for automatic management just make the
-   * asynchronous call directly.
-   *
-   * @hide
-   */
+  @Override
+  public void setConnectionHandler(ConnectionHandler connectionHandler) {
+    this.connectionHandler = connectionHandler;
+  }
+
+  @Override
+  public void setExecutor(Executor executor) {
+    this.executor = executor;
+  }
+
+  @Override
   public void automaticallyConnect() {
-    if (isAvailable() && !isConnected) {
+    if (hasPermissionToMakeCrossProfileCalls
+        && isAvailable()
+        && !isConnected
+        && connectionHandler.tryConnect()) {
       isConnected = true;
       notifyConnectionChanged();
     }
   }
 
-  /**
-   * Disconnect after an automatic connection.
-   *
-   * <p>In reality, this timeout happens some arbitrary time of no interaction with the other
-   * profile.
-   *
-   * <p>If {@link #isManuallyManagingConnection()} is true, then this will do nothing.
-   */
+  @Override
   public void timeoutConnection() {
-    if (isManuallyManagingConnection) {
+    if (!connectionHolders.isEmpty()) {
       return;
     }
 
@@ -186,37 +192,32 @@
     }
   }
 
-  @Override
-  public void startConnecting() {
-    isManuallyManagingConnection = true;
-    automaticallyConnect();
-  }
-
   /**
    * This fake does not enforce the requirement that calls to {@link #connect()} do not occur on the
    * UI Thread.
    */
   @Override
-  public void connect() throws UnavailableProfileException {
-    if (!isAvailable()) {
-      throw new UnavailableProfileException("No profile available");
-    }
-
-    isManuallyManagingConnection = true;
-    automaticallyConnect();
-  }
-
-  /**
-   * Stop manually managing the connection and ensure that the connector is disconnected.
-   */
-  public void disconnect() {
-    stopManualConnectionManagement();
-    timeoutConnection();
+  public ProfileConnectionHolder connect() throws UnavailableProfileException {
+    return connect(CrossProfileSender.MANUAL_MANAGEMENT_CONNECTION_HOLDER);
   }
 
   @Override
-  public void stopManualConnectionManagement() {
-    isManuallyManagingConnection = false;
+  public ProfileConnectionHolder connect(Object connectionHolder)
+      throws UnavailableProfileException {
+    if (!hasPermissionToMakeCrossProfileCalls || !isAvailable()) {
+      throw new UnavailableProfileException("No profile available");
+    }
+
+    connectionHolders.add(connectionHolder);
+    automaticallyConnect();
+
+    return ProfileConnectionHolder.create(this, connectionHolder);
+  }
+
+  /** Stop manually managing the connection and ensure that the connector is disconnected. */
+  public void disconnect() {
+    connectionHolders.clear();
+    timeoutConnection();
   }
 
   /** Unsupported by the fake so always returns {@code null}. */
@@ -226,12 +227,12 @@
   }
 
   @Override
-  public void registerConnectionListener(ConnectionListener listener) {
+  public void addConnectionListener(ConnectionListener listener) {
     connectionListeners.add(listener);
   }
 
   @Override
-  public void unregisterConnectionListener(ConnectionListener listener) {
+  public void removeConnectionListener(ConnectionListener listener) {
     connectionListeners.remove(listener);
   }
 
@@ -242,12 +243,12 @@
   }
 
   @Override
-  public void registerAvailabilityListener(AvailabilityListener listener) {
+  public void addAvailabilityListener(AvailabilityListener listener) {
     availabilityListeners.add(listener);
   }
 
   @Override
-  public void unregisterAvailabilityListener(AvailabilityListener listener) {
+  public void removeAvailabilityListener(AvailabilityListener listener) {
     availabilityListeners.remove(listener);
   }
 
@@ -274,20 +275,14 @@
 
   @Override
   public Permissions permissions() {
-    return new FakePermissions(this);
+    return new FakeCrossProfilePermissions(this);
   }
 
-  /** Not supported by the fake so returns null. */
   @Override
   public Context applicationContext() {
     return applicationContext;
   }
 
-  @Override
-  public boolean isManuallyManagingConnection() {
-    return isManuallyManagingConnection;
-  }
-
   /**
    * Set whether or not the app has been given the appropriate permission to make cross-profile
    * calls.
@@ -300,4 +295,45 @@
   boolean hasPermissionToMakeCrossProfileCalls() {
     return hasPermissionToMakeCrossProfileCalls;
   }
+
+  @Override
+  public ProfileConnectionHolder addConnectionHolder(Object connectionHolder) {
+    connectionHolders.add(connectionHolder);
+    executor.execute(this::automaticallyConnect);
+
+    return ProfileConnectionHolder.create(this, connectionHolder);
+  }
+
+  @Override
+  public void addConnectionHolderAlias(Object key, Object value) {
+    if (!connectionHolderAliases.containsKey(key)) {
+      connectionHolderAliases.put(key, Collections.newSetFromMap(new WeakHashMap<>()));
+    }
+
+    connectionHolderAliases.get(key).add(value);
+  }
+
+  @Override
+  public void removeConnectionHolder(Object connectionHolder) {
+    if (connectionHolderAliases.containsKey(connectionHolder)) {
+      Set<Object> aliases = connectionHolderAliases.get(connectionHolder);
+      connectionHolderAliases.remove(connectionHolder);
+      for (Object alias : aliases) {
+        removeConnectionHolder(alias);
+      }
+    }
+
+    connectionHolders.remove(connectionHolder);
+  }
+
+  @Override
+  public void clearConnectionHolders() {
+    connectionHolderAliases.clear();
+    connectionHolders.clear();
+  }
+
+  @Override
+  public boolean hasExplicitConnectionHolders() {
+    return !connectionHolders.isEmpty();
+  }
 }
diff --git a/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/AbstractFakeUserConnector.java b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/AbstractFakeUserConnector.java
new file mode 100644
index 0000000..530baa3
--- /dev/null
+++ b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/AbstractFakeUserConnector.java
@@ -0,0 +1,415 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.testing;
+
+import android.content.Context;
+import android.os.UserHandle;
+import com.google.android.enterprise.connectedapps.AvailabilityListener;
+import com.google.android.enterprise.connectedapps.ConnectionListener;
+import com.google.android.enterprise.connectedapps.CrossProfileSender;
+import com.google.android.enterprise.connectedapps.Permissions;
+import com.google.android.enterprise.connectedapps.UserConnectionHolder;
+import com.google.android.enterprise.connectedapps.UserConnector;
+import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.WeakHashMap;
+
+/**
+ * A fake {@link UserConnector} for use in tests.
+ *
+ * <p>This should be extended to make it compatible with a specific {@link UserConnector} interface.
+ */
+public abstract class AbstractFakeUserConnector implements FakeUserConnector {
+
+  private static final class SpecificUserConnector {
+
+    enum UserState {
+      DOES_NOT_EXIST,
+      TURNED_OFF,
+      TURNED_ON
+    }
+
+    private UserState userState = UserState.DOES_NOT_EXIST;
+    private boolean isConnected = false;
+    private boolean hasPermissionToMakeCrossUserCalls = true;
+
+    private final Set<Object> connectionHolders = Collections.newSetFromMap(new WeakHashMap<>());
+    private final Map<Object, Set<Object>> connectionHolderAliases = new WeakHashMap<>();
+    private final Set<ConnectionListener> connectionListeners = new HashSet<>();
+    private final Set<AvailabilityListener> availabilityListeners = new HashSet<>();
+
+    private void createUser() {
+      if (userState == UserState.DOES_NOT_EXIST) {
+        userState = UserState.TURNED_OFF;
+      }
+    }
+
+    private void removeUser() {
+      turnOffUser();
+      userState = UserState.DOES_NOT_EXIST;
+    }
+
+    private void turnOnUser() {
+      if (userState == UserState.TURNED_ON) {
+        return;
+      }
+
+      if (userState == UserState.DOES_NOT_EXIST) {
+        createUser();
+      }
+
+      userState = UserState.TURNED_ON;
+      notifyAvailabilityChanged();
+    }
+
+    private void turnOffUser() {
+      if (userState == UserState.TURNED_OFF) {
+        return;
+      }
+
+      if (userState == UserState.DOES_NOT_EXIST) {
+        createUser();
+      }
+
+      if (isConnected) {
+        isConnected = false;
+        notifyConnectionChanged();
+      }
+
+      userState = UserState.TURNED_OFF;
+      notifyAvailabilityChanged();
+    }
+
+    private boolean isConnected() {
+      return isConnected;
+    }
+
+    private void connect(Object connectionHolder) {
+      addConnectionHolder(connectionHolder);
+    }
+
+    private void timeoutConnection() {
+      if (!connectionHolders.isEmpty()) {
+        return;
+      }
+
+      if (isConnected) {
+        isConnected = false;
+        notifyConnectionChanged();
+      }
+    }
+
+    private void automaticallyConnect() {
+      if (isAvailable() && !isConnected) {
+        isConnected = true;
+        notifyConnectionChanged();
+      }
+    }
+
+    private void disconnect() {
+      connectionHolders.clear();
+      timeoutConnection();
+    }
+
+    private boolean isAvailable() {
+      return userState == UserState.TURNED_ON;
+    }
+
+    private void addConnectionListener(ConnectionListener listener) {
+      connectionListeners.add(listener);
+    }
+
+    private void removeConnectionListener(ConnectionListener listener) {
+      connectionListeners.remove(listener);
+    }
+
+    private void notifyConnectionChanged() {
+      for (ConnectionListener listener : connectionListeners) {
+        listener.connectionChanged();
+      }
+    }
+
+    private void addAvailabilityListener(AvailabilityListener listener) {
+      availabilityListeners.add(listener);
+    }
+
+    private void removeAvailabilityListener(AvailabilityListener listener) {
+      availabilityListeners.remove(listener);
+    }
+
+    private void notifyAvailabilityChanged() {
+      for (AvailabilityListener listener : availabilityListeners) {
+        listener.availabilityChanged();
+      }
+    }
+
+    private void addConnectionHolder(Object connectionHolder) {
+      connectionHolders.add(connectionHolder);
+      automaticallyConnect();
+    }
+
+    private void addConnectionHolderAlias(Object key, Object value) {
+      if (!connectionHolderAliases.containsKey(key)) {
+        connectionHolderAliases.put(key, Collections.newSetFromMap(new WeakHashMap<>()));
+      }
+
+      connectionHolderAliases.get(key).add(value);
+    }
+
+    private void removeConnectionHolder(Object connectionHolder) {
+      if (connectionHolderAliases.containsKey(connectionHolder)) {
+        Set<Object> aliases = connectionHolderAliases.get(connectionHolder);
+        connectionHolderAliases.remove(connectionHolder);
+        for (Object alias : aliases) {
+          removeConnectionHolder(alias);
+        }
+      }
+
+      connectionHolders.remove(connectionHolder);
+    }
+
+    private void clearConnectionHolders() {
+      connectionHolderAliases.clear();
+      ;
+      connectionHolders.clear();
+    }
+
+    private boolean hasExplicitConnectionHolders() {
+      return !connectionHolders.isEmpty();
+    }
+  }
+
+  private final Context applicationContext;
+
+  private final Map<UserHandle, SpecificUserConnector> specificUsers = new HashMap<>();
+
+  private UserHandle currentUser;
+
+  public AbstractFakeUserConnector(Context context) {
+    if (context == null) {
+      throw new NullPointerException();
+    }
+
+    this.applicationContext = context.getApplicationContext();
+  }
+
+  /**
+   * Simulate running on a particular user.
+   *
+   * <p>If {@code currentUser} does not exist or is not turned on, then it will be created and
+   * turned on.
+   *
+   * @see #runningOnUser
+   */
+  public void setRunningOnUser(UserHandle currentUser) {
+    this.currentUser = currentUser;
+    connectorFor(currentUser).turnOnUser();
+  }
+
+  /**
+   * Get the current user being simulated.
+   *
+   * @see #setRunningOnUser(UserHandle)
+   */
+  public UserHandle runningOnUser() {
+    return currentUser;
+  }
+
+  /**
+   * Simulate the creation of a user.
+   *
+   * <p>The new user will be turned off by default.
+   */
+  public void createUser(UserHandle userHandle) {
+    connectorFor(userHandle).createUser();
+  }
+
+  /**
+   * Remove a simulated user.
+   *
+   * <p>The simulated user will be turned off first.
+   */
+  public void removeUser(UserHandle userHandle) {
+    connectorFor(userHandle).removeUser();
+  }
+
+  /**
+   * Simulate a user being turned on.
+   *
+   * <p>If no such user exists, then it will be created.
+   */
+  public void turnOnUser(UserHandle userHandle) {
+    connectorFor(userHandle).turnOnUser();
+  }
+
+  /**
+   * Simulate a user being turned off.
+   *
+   * <p>If no such user exists, then it will be created.
+   */
+  public void turnOffUser(UserHandle userHandle) {
+    connectorFor(userHandle).turnOffUser();
+  }
+
+  /**
+   * Force the connector to be "automatically" connected.
+   *
+   * <p>This call should only be used by the SDK and should not be called in tests. If you want to
+   * connect manually, use {@link #addConnectionHolder(UserHandle, Object)}, or for automatic
+   * management just make the asynchronous call directly.
+   *
+   * @hide
+   */
+  public void automaticallyConnect(UserHandle userHandle) {
+    connectorFor(userHandle).automaticallyConnect();
+  }
+
+  /**
+   * Disconnect after an automatic connection.
+   *
+   * <p>In reality, this timeout happens some arbitrary time of no interaction with the other
+   * profile.
+   *
+   * <p>If there are connection holders, then this will do nothing.
+   */
+  public void timeoutConnection(UserHandle userHandle) {
+    connectorFor(userHandle).timeoutConnection();
+  }
+
+  /**
+   * This fake does not enforce the requirement that calls to {@link #connect(UserHandle)} do not
+   * occur on the UI Thread.
+   */
+  @Override
+  public UserConnectionHolder connect(UserHandle userHandle) throws UnavailableProfileException {
+    return connect(userHandle, new Object());
+  }
+
+  @Override
+  public UserConnectionHolder connect(UserHandle userHandle, Object connectionHolder)
+
+      throws UnavailableProfileException {
+    if (!isAvailable(userHandle)) {
+      throw new UnavailableProfileException("User not available");
+    }
+
+    connectorFor(userHandle).connect(connectionHolder);
+
+    return UserConnectionHolder.create(this, userHandle, connectionHolder);
+  }
+
+  /** Stop manually managing the connection and ensure that the connector is disconnected. */
+  public void disconnect(UserHandle userHandle) {
+    connectorFor(userHandle).disconnect();
+  }
+
+  /** Unsupported by the fake so always returns {@code null}. */
+  @Override
+  public CrossProfileSender crossProfileSender(UserHandle userHandle) {
+    return null;
+  }
+
+  @Override
+  public void addConnectionListener(UserHandle userHandle, ConnectionListener listener) {
+    connectorFor(userHandle).addConnectionListener(listener);
+  }
+
+  @Override
+  public void removeConnectionListener(UserHandle userHandle, ConnectionListener listener) {
+    connectorFor(userHandle).removeConnectionListener(listener);
+  }
+
+  @Override
+  public void addAvailabilityListener(UserHandle userHandle, AvailabilityListener listener) {
+    connectorFor(userHandle).addAvailabilityListener(listener);
+  }
+
+  @Override
+  public void removeAvailabilityListener(UserHandle userHandle, AvailabilityListener listener) {
+    connectorFor(userHandle).removeAvailabilityListener(listener);
+  }
+
+  @Override
+  public boolean isAvailable(UserHandle userHandle) {
+    return connectorFor(userHandle).isAvailable();
+  }
+
+  @Override
+  public boolean isConnected(UserHandle userHandle) {
+    return connectorFor(userHandle).isConnected();
+  }
+
+  @Override
+  public Permissions permissions(UserHandle userHandle) {
+    return new FakeCrossUserPermissions(this, userHandle);
+  }
+
+  @Override
+  public Context applicationContext(UserHandle userHandle) {
+    return applicationContext;
+  }
+
+  /**
+   * Set whether or not the app has been given the appropriate permission to make cross-user calls.
+   */
+  public void setHasPermissionToMakeCrossProfileCalls(
+      UserHandle userHandle, boolean hasPermissionToMakeCrossUserCalls) {
+    connectorFor(userHandle).hasPermissionToMakeCrossUserCalls = hasPermissionToMakeCrossUserCalls;
+  }
+
+  boolean hasPermissionToMakeCrossProfileCalls(UserHandle userHandle) {
+    return connectorFor(userHandle).hasPermissionToMakeCrossUserCalls;
+  }
+
+  private SpecificUserConnector connectorFor(UserHandle userHandle) {
+    if (!specificUsers.containsKey(userHandle)) {
+      specificUsers.put(userHandle, new SpecificUserConnector());
+    }
+
+    return specificUsers.get(userHandle);
+  }
+
+  @Override
+  public UserConnectionHolder addConnectionHolder(UserHandle userHandle, Object connectionHolder) {
+    connectorFor(userHandle).addConnectionHolder(connectionHolder);
+
+    return UserConnectionHolder.create(this, userHandle, connectionHolder);
+  }
+
+  @Override
+  public void addConnectionHolderAlias(UserHandle userHandle, Object key, Object value) {
+    connectorFor(userHandle).addConnectionHolderAlias(key, value);
+  }
+
+  @Override
+  public void removeConnectionHolder(UserHandle userHandle, Object connectionHolder) {
+    connectorFor(userHandle).removeConnectionHolder(connectionHolder);
+  }
+
+  @Override
+  public void clearConnectionHolders(UserHandle userHandle) {
+    connectorFor(userHandle).clearConnectionHolders();
+  }
+
+  @Override
+  public boolean hasExplicitConnectionHolders(UserHandle userHandle) {
+    return connectorFor(userHandle).hasExplicitConnectionHolders();
+  }
+}
diff --git a/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakePermissions.java b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakeCrossProfilePermissions.java
similarity index 87%
rename from testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakePermissions.java
rename to testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakeCrossProfilePermissions.java
index 763b67c..4da4e09 100644
--- a/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakePermissions.java
+++ b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakeCrossProfilePermissions.java
@@ -17,11 +17,11 @@
 
 import com.google.android.enterprise.connectedapps.Permissions;
 
-class FakePermissions implements Permissions {
+class FakeCrossProfilePermissions implements Permissions {
 
   private final AbstractFakeProfileConnector fakeProfileConnector;
 
-  FakePermissions(AbstractFakeProfileConnector fakeProfileConnector) {
+  FakeCrossProfilePermissions(AbstractFakeProfileConnector fakeProfileConnector) {
     this.fakeProfileConnector = fakeProfileConnector;
   }
 
diff --git a/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakePermissions.java b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakeCrossUserPermissions.java
similarity index 64%
copy from testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakePermissions.java
copy to testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakeCrossUserPermissions.java
index 763b67c..83597a1 100644
--- a/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakePermissions.java
+++ b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakeCrossUserPermissions.java
@@ -15,18 +15,22 @@
  */
 package com.google.android.enterprise.connectedapps.testing;
 
+import android.os.UserHandle;
 import com.google.android.enterprise.connectedapps.Permissions;
 
-class FakePermissions implements Permissions {
+class FakeCrossUserPermissions implements Permissions {
 
-  private final AbstractFakeProfileConnector fakeProfileConnector;
+  private final AbstractFakeUserConnector fakeUserConnector;
 
-  FakePermissions(AbstractFakeProfileConnector fakeProfileConnector) {
-    this.fakeProfileConnector = fakeProfileConnector;
+  private final UserHandle userHandle;
+
+  FakeCrossUserPermissions(AbstractFakeUserConnector fakeUserConnector, UserHandle userHandle) {
+    this.fakeUserConnector = fakeUserConnector;
+    this.userHandle = userHandle;
   }
 
   @Override
   public boolean canMakeCrossProfileCalls() {
-    return fakeProfileConnector.hasPermissionToMakeCrossProfileCalls();
+    return fakeUserConnector.hasPermissionToMakeCrossProfileCalls(userHandle);
   }
 }
diff --git a/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakeProfileConnector.java b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakeProfileConnector.java
new file mode 100644
index 0000000..98dd36a
--- /dev/null
+++ b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakeProfileConnector.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.testing;
+
+import com.google.android.enterprise.connectedapps.ProfileConnector;
+import java.util.concurrent.Executor;
+
+/** Implemented by generated {@link ProfileConnector} fakes. */
+public interface FakeProfileConnector extends ProfileConnector {
+
+  /**
+   * Disconnect after an automatic connection.
+   *
+   * <p>In reality, this timeout happens some arbitrary time of no interaction with the other
+   * profile.
+   *
+   * <p>If there are any connection holders, then this will do nothing.
+   */
+  void timeoutConnection();
+
+  /**
+   * Set the connection handler.
+   *
+   * <p>Upon attempting to connect, the connectionHandler is invoked. A return value of true
+   * simulates that the connection was successful, whereas a return value of false simulates that
+   * the connection failed.
+   *
+   * <p>If not set, the default connection handler always returns true (success).
+   */
+  void setConnectionHandler(ConnectionHandler connectionHandler);
+
+  /**
+   * Set the executor used for asynchronous operations.
+   *
+   * <p>If not set, all asynchronous operations will run synchronously.
+   *
+   * <p>Currently this only applies to calls to {@link #addConnectionHolder}.
+   */
+  void setExecutor(Executor executor);
+
+  /**
+   * Force the connector to be "automatically" connected.
+   *
+   * <p>This call should only be used by the SDK and should not be called in tests. If you want to
+   * connect manually, use {@link #addConnectionHolder(Object)}, or for automatic management just
+   * make the asynchronous call directly.
+   *
+   * @hide
+   */
+  void automaticallyConnect();
+
+  /**
+   * Returns true if explicit connection holders have been added.
+   *
+   * <p>This call should only be used by the SDK and should not be called in tests.
+   *
+   * @hide
+   */
+  boolean hasExplicitConnectionHolders();
+
+  /** A connection handler which gets invoked when attempting to connect to the other profile. */
+  public interface ConnectionHandler {
+
+    /** Return whether the connection was successful. */
+    boolean tryConnect();
+  }
+}
diff --git a/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakeUserConnector.java b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakeUserConnector.java
new file mode 100644
index 0000000..ccf5776
--- /dev/null
+++ b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakeUserConnector.java
@@ -0,0 +1,8 @@
+package com.google.android.enterprise.connectedapps.testing;
+
+import android.os.UserHandle;
+import com.google.android.enterprise.connectedapps.UserConnector;
+
+public interface FakeUserConnector extends UserConnector {
+  boolean hasExplicitConnectionHolders(UserHandle userHandle);
+}
diff --git a/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakeUserConnectorWrapper.java b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakeUserConnectorWrapper.java
new file mode 100644
index 0000000..faa826b
--- /dev/null
+++ b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/FakeUserConnectorWrapper.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.testing;
+
+import android.content.Context;
+import android.os.UserHandle;
+import com.google.android.enterprise.connectedapps.AvailabilityListener;
+import com.google.android.enterprise.connectedapps.ConnectedAppsUtils;
+import com.google.android.enterprise.connectedapps.ConnectionListener;
+import com.google.android.enterprise.connectedapps.CrossProfileSender;
+import com.google.android.enterprise.connectedapps.CrossUserConnector;
+import com.google.android.enterprise.connectedapps.Permissions;
+import com.google.android.enterprise.connectedapps.ProfileConnectionHolder;
+import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
+import java.util.concurrent.Executor;
+
+/**
+ * A compatibility wrapper which allows an {@link AbstractFakeUserConnector} to be used in generated
+ * code where a {@link FakeProfileConnector} is expected.
+ */
+public class FakeUserConnectorWrapper implements FakeProfileConnector {
+
+  private final AbstractFakeUserConnector connector;
+
+  private final UserHandle handle;
+
+  public FakeUserConnectorWrapper(AbstractFakeUserConnector connector, UserHandle handle) {
+    this.connector = connector;
+    this.handle = handle;
+  }
+
+  @Override
+  public void timeoutConnection() {
+    connector.timeoutConnection(handle);
+  }
+
+  @Override
+  public void setConnectionHandler(ConnectionHandler connectionHandler) {
+    // TODO(b/214511168): Implement it
+    throw new UnsupportedOperationException("Cannot set ConnectionHandler for UserConnector");
+  }
+
+  @Override
+  public void setExecutor(Executor executor) {
+    // TODO(b/214511168): Implement it
+    throw new UnsupportedOperationException("Cannot set Executor for UserConnector");
+  }
+
+  @Override
+  public void automaticallyConnect() {
+    connector.automaticallyConnect(handle);
+  }
+
+  @Override
+  public ProfileConnectionHolder connect(Object connectionHolder)
+      throws UnavailableProfileException {
+    return addConnectionHolder(connectionHolder);
+  }
+
+  @Override
+  public ProfileConnectionHolder connect() throws UnavailableProfileException {
+    return connect(CrossProfileSender.MANUAL_MANAGEMENT_CONNECTION_HOLDER);
+  }
+
+  @Override
+  public CrossProfileSender crossProfileSender() {
+    return connector.crossProfileSender(handle);
+  }
+
+  @Override
+  public void addConnectionListener(ConnectionListener listener) {
+    connector.addConnectionListener(handle, listener);
+  }
+
+  @Override
+  public void removeConnectionListener(ConnectionListener listener) {
+    connector.removeConnectionHolder(handle, listener);
+  }
+
+  @Override
+  public void addAvailabilityListener(AvailabilityListener listener) {
+    connector.addAvailabilityListener(handle, listener);
+  }
+
+  @Override
+  public void removeAvailabilityListener(AvailabilityListener listener) {
+    connector.removeAvailabilityListener(handle, listener);
+  }
+
+  @Override
+  public boolean isAvailable() {
+    return connector.isAvailable(handle);
+  }
+
+  @Override
+  public boolean isConnected() {
+    return connector.isConnected(handle);
+  }
+
+  /**
+   * Unsupported for {@link FakeUserConnectorWrapper} as unsupported by all {@link
+   * CrossUserConnector}s.
+   */
+  @Override
+  public ConnectedAppsUtils utils() {
+    throw new UnsupportedOperationException("Cannot get ConnectedAppsUtils for UserConnector");
+  }
+
+  @Override
+  public Permissions permissions() {
+    return connector.permissions(handle);
+  }
+
+  @Override
+  public Context applicationContext() {
+    return connector.applicationContext(handle);
+  }
+
+  @Override
+  public ProfileConnectionHolder addConnectionHolder(Object connectionHolder) {
+    connector.addConnectionHolder(handle, connectionHolder);
+
+    return ProfileConnectionHolder.create(this, connectionHolder);
+  }
+
+  @Override
+  public void addConnectionHolderAlias(Object key, Object value) {
+    connector.addConnectionHolderAlias(handle, key, value);
+  }
+
+  @Override
+  public void removeConnectionHolder(Object connectionHolder) {
+    connector.removeConnectionHolder(handle, connectionHolder);
+  }
+
+  @Override
+  public void clearConnectionHolders() {
+    connector.clearConnectionHolders(handle);
+  }
+
+  @Override
+  public boolean hasExplicitConnectionHolders() {
+    return connector.hasExplicitConnectionHolders(handle);
+  }
+}
diff --git a/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/InstrumentedTestUtilities.java b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/InstrumentedTestUtilities.java
index 2d2ee60..2951176 100644
--- a/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/InstrumentedTestUtilities.java
+++ b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/InstrumentedTestUtilities.java
@@ -76,8 +76,7 @@
 
     grantInteractAcrossUsers(packageName);
 
-    ProfileAvailabilityPoll.blockUntilProfileRunningAndUnlocked(
-        context, getWorkProfileUserHandle());
+    ProfileAvailabilityPoll.blockUntilUserRunningAndUnlocked(context, getWorkProfileUserHandle());
   }
 
   private UserHandle getWorkProfileUserHandle() {
@@ -222,7 +221,7 @@
           }
         };
 
-    connector.registerConnectionListener(connectionListener);
+    connector.addConnectionListener(connectionListener);
     connectionListener.connectionChanged();
 
     try {
@@ -231,7 +230,7 @@
       throw new AssertionError("Error waiting to disconnect", e);
     }
 
-    connector.unregisterConnectionListener(connectionListener);
+    connector.removeConnectionListener(connectionListener);
   }
 
   /**
@@ -249,7 +248,7 @@
           }
         };
 
-    connector.registerConnectionListener(connectionListener);
+    connector.addConnectionListener(connectionListener);
     connectionListener.connectionChanged();
 
     try {
@@ -258,7 +257,7 @@
       throw new AssertionError("Error waiting to connect", e);
     }
 
-    connector.unregisterConnectionListener(connectionListener);
+    connector.removeConnectionListener(connectionListener);
   }
 
   private static String runCommandWithOutput(String command) {
diff --git a/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/ProfileAvailabilityPoll.java b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/ProfileAvailabilityPoll.java
index 42ff10c..5c98d60 100644
--- a/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/ProfileAvailabilityPoll.java
+++ b/testing/sdk/src/main/java/com/google/android/enterprise/connectedapps/testing/ProfileAvailabilityPoll.java
@@ -25,7 +25,7 @@
   private static final int POLL_FREQUENCY_MS = 1000;
   private static final int POLL_TIMEOUT_MS = 30000;
 
-  public static void blockUntilProfileRunningAndUnlocked(Context context, UserHandle userHandle) {
+  public static void blockUntilUserRunningAndUnlocked(Context context, UserHandle userHandle) {
     UserManager userManager = context.getSystemService(UserManager.class);
     BlockingPoll.poll(
         () -> userManager.isUserRunning(userHandle) && userManager.isUserUnlocked(userHandle),
@@ -33,7 +33,7 @@
         POLL_TIMEOUT_MS);
   }
 
-  public static void blockUntilProfileNotAvailable(Context context, UserHandle userHandle) {
+  public static void blockUntilUserNotAvailable(Context context, UserHandle userHandle) {
     UserManager userManager = context.getSystemService(UserManager.class);
     BlockingPoll.poll(
         () -> !userManager.isUserRunning(userHandle) || userManager.isQuietModeEnabled(userHandle),
diff --git a/tests/instrumented/src/AndroidManifest.xml b/tests/instrumented/src/AndroidManifest.xml
index 381a313..50515ff 100644
--- a/tests/instrumented/src/AndroidManifest.xml
+++ b/tests/instrumented/src/AndroidManifest.xml
@@ -24,14 +24,16 @@
       android:targetSdkVersion="28"/>
 
   <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS" />
+  <uses-permission android:name="android.permission.INTERACT_ACROSS_USERS_FULL" />
 
   <application>
     <uses-library android:name="android.test.runner" />
+    <service android:name="com.google.android.enterprise.connectedapps.testapp.connector.ExceptionsSuppressingConnector_Service" android:exported="false" />
     <service android:name="com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector_Service" android:exported="false" />
+    <service android:name="com.google.android.enterprise.connectedapps.testapp.crossuser.AppCrossUserConnector_Service" android:exported="false" />
   </application>
 
-  <!-- TODO(179354604): this needs to be changed upstream -->
-  <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+  <instrumentation android:name="com.google.android.apps.common.testing.testrunner.Google3InstrumentationTestRunner"
       android:targetPackage="com.google.android.enterprise.connectedapps"
       android:label="Connected Apps SDK test"/>
 </manifest>
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/AvailabilityListenerTest.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/AvailabilityListenerTest.java
index f35f3b5..8f9de63 100644
--- a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/AvailabilityListenerTest.java
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/AvailabilityListenerTest.java
@@ -63,7 +63,7 @@
 
     utilities.ensureWorkProfileTurnedOn();
     TestAvailabilityListener availabilityListener = new TestAvailabilityListener();
-    connector.registerAvailabilityListener(availabilityListener);
+    connector.addAvailabilityListener(availabilityListener);
 
     utilities.turnOffWorkProfileAndWait();
 
@@ -78,7 +78,7 @@
 
     utilities.ensureWorkProfileTurnedOff();
     TestAvailabilityListener availabilityListener = new TestAvailabilityListener();
-    connector.registerAvailabilityListener(availabilityListener);
+    connector.addAvailabilityListener(availabilityListener);
 
     utilities.turnOnWorkProfileAndWait();
 
@@ -91,7 +91,7 @@
       throws InterruptedException {
     utilities.ensureWorkProfileTurnedOn();
     TestAvailabilityListener availabilityListener = new TestAvailabilityListener();
-    connector.registerAvailabilityListener(availabilityListener);
+    connector.addAvailabilityListener(availabilityListener);
 
     ListenableFuture<Void> unusedFuture = type.other().killApp();
 
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/BinderParcelableTest.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/BinderParcelableTest.java
new file mode 100644
index 0000000..0a2ad44
--- /dev/null
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/BinderParcelableTest.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.instrumented.tests;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Application;
+import android.os.Parcelable;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
+import com.google.android.enterprise.connectedapps.instrumented.utils.BlockingExceptionCallbackListener;
+import com.google.android.enterprise.connectedapps.instrumented.utils.BlockingParcelableCallbackListener;
+import com.google.android.enterprise.connectedapps.instrumented.utils.InstrumentedTestUtilities;
+import com.google.android.enterprise.connectedapps.testapp.ParcelableContainingBinder;
+import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
+import com.google.android.enterprise.connectedapps.testapp.types.ProfileTestCrossProfileType;
+import com.google.common.util.concurrent.ListenableFuture;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests regarding parcelable types which contain a binder. */
+@RunWith(JUnit4.class)
+public class BinderParcelableTest {
+  private static final Application context = ApplicationProvider.getApplicationContext();
+
+  private static final TestProfileConnector connector = TestProfileConnector.create(context);
+  private static final InstrumentedTestUtilities utilities =
+      new InstrumentedTestUtilities(context, connector);
+
+  private final ProfileTestCrossProfileType type = ProfileTestCrossProfileType.create(connector);
+  private final BlockingExceptionCallbackListener exceptionCallbackListener =
+      new BlockingExceptionCallbackListener();
+
+  private final ParcelableContainingBinder parcelableContainingBinder =
+      new ParcelableContainingBinder();
+
+  @Before
+  public void setup() {
+    utilities.ensureReadyForCrossProfileCalls();
+  }
+
+  @AfterClass
+  public static void teardownClass() {
+    utilities.ensureNoWorkProfile();
+  }
+
+  @Test
+  public void parcelableContainingBinderArgumentAndReturnType_bothWork()
+      throws UnavailableProfileException {
+    utilities.addConnectionHolderAndWait(this);
+
+    // Binders won't be identical
+    assertThat(type.other().identityParcelableMethod(parcelableContainingBinder)).isNotNull();
+  }
+
+  @Test
+  public void parcelableContainingBinderAsyncMethod_works() throws Exception {
+    BlockingParcelableCallbackListener callbackListener = new BlockingParcelableCallbackListener();
+
+    type.other()
+        .asyncIdentityParcelableMethod(
+            parcelableContainingBinder, callbackListener, exceptionCallbackListener);
+
+    // Binders won't be identical
+    assertThat(callbackListener.await()).isNotNull();
+  }
+
+  @Test
+  public void futureParcelableContainingBinder_works() throws Exception {
+    ListenableFuture<Parcelable> future =
+        type.other().futureIdentityParcelableMethod(parcelableContainingBinder);
+
+    // Binders won't be identical
+    assertThat(future.get()).isNotNull();
+  }
+}
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/BothProfilesTest.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/BothProfilesTest.java
deleted file mode 100644
index 8b15d35..0000000
--- a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/BothProfilesTest.java
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * Copyright 2021 Google LLC
- *
- * 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
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.google.android.enterprise.connectedapps.instrumented.tests;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import android.app.Application;
-import androidx.test.core.app.ApplicationProvider;
-import com.google.android.enterprise.connectedapps.Profile;
-import com.google.android.enterprise.connectedapps.instrumented.utils.BlockingStringCallbackListenerMulti;
-import com.google.android.enterprise.connectedapps.instrumented.utils.InstrumentedTestUtilities;
-import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
-import com.google.android.enterprise.connectedapps.testapp.types.ProfileTestCrossProfileTypeWhichNeedsContext;
-import java.util.Map;
-import java.util.concurrent.ExecutionException;
-
-import org.junit.After;
-import org.junit.AfterClass;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.junit.runners.JUnit4;
-
-/** Tests regarding calling a method on both profiles. */
-@RunWith(JUnit4.class)
-public class BothProfilesTest {
-  private static final int FIVE_SECONDS = 5000;
-  private static final String STRING = "String";
-
-  private static final Application context = ApplicationProvider.getApplicationContext();
-
-  private static final TestProfileConnector connector = TestProfileConnector.create(context);
-  private static final InstrumentedTestUtilities utilities =
-      new InstrumentedTestUtilities(context, connector);
-
-  private final ProfileTestCrossProfileTypeWhichNeedsContext type =
-      ProfileTestCrossProfileTypeWhichNeedsContext.create(connector);
-
-  @Before
-  public void setup() {
-    utilities.ensureReadyForCrossProfileCalls();
-  }
-
-  @AfterClass
-  public static void teardown() {
-    utilities.ensureNoWorkProfile();
-  }
-
-  /** This test could not be covered by Robolectric. */
-  @Test
-  public void both_synchronous_timesOutOnWorkProfile_timeoutNotEnforcedOnSynchronousCalls() {
-    utilities.manuallyConnectAndWait();
-
-    Map<Profile, String> result =
-        type.both()
-            .timeout(FIVE_SECONDS)
-            .identityStringMethodWhichDelays10SecondsOnWorkProfile(STRING);
-
-    assertThat(result).containsKey(connector.utils().getPersonalProfile());
-    assertThat(result).containsKey(connector.utils().getWorkProfile());
-  }
-
-  /** This test could not be covered by Robolectric. */
-  @Test
-  public void both_async_timesOutOnWorkProfile_onlyIncludesPersonalProfile()
-      throws InterruptedException {
-
-    BlockingStringCallbackListenerMulti callbackListener =
-        new BlockingStringCallbackListenerMulti();
-
-    type.both()
-        .timeout(FIVE_SECONDS)
-        .asyncIdentityStringMethodWhichDelays10SecondsOnWorkProfile(STRING, callbackListener);
-    Map<Profile, String> result = callbackListener.await();
-
-    assertThat(result).containsKey(connector.utils().getPersonalProfile());
-    assertThat(result).doesNotContainKey(connector.utils().getWorkProfile());
-  }
-
-  /** This test could not be covered by Robolectric. */
-  @Test
-  public void both_future_timesOutOnWorkProfile_onlyIncludesPersonalProfile()
-      throws InterruptedException, ExecutionException {
-    Map<Profile, String> result =
-        type.both()
-            .timeout(FIVE_SECONDS)
-            .futureIdentityStringMethodWhichDelays10SecondsOnWorkProfile(STRING)
-            .get();
-
-    assertThat(result).containsKey(connector.utils().getPersonalProfile());
-    assertThat(result).doesNotContainKey(connector.utils().getWorkProfile());
-  }
-}
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/ConnectTest.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/ConnectTest.java
index 84069a7..9d3d5da 100644
--- a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/ConnectTest.java
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/ConnectTest.java
@@ -20,10 +20,11 @@
 
 import android.app.Application;
 import androidx.test.core.app.ApplicationProvider;
+import com.google.android.enterprise.connectedapps.ProfileConnectionHolder;
 import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
 import com.google.android.enterprise.connectedapps.instrumented.utils.InstrumentedTestUtilities;
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
-
+import com.google.android.enterprise.connectedapps.testapp.types.ProfileTestCrossProfileType;
 import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.Before;
@@ -43,7 +44,10 @@
 public class ConnectTest {
   private static final Application context = ApplicationProvider.getApplicationContext();
 
+  private static final String STRING = "String";
+
   private static final TestProfileConnector connector = TestProfileConnector.create(context);
+  private final ProfileTestCrossProfileType type = ProfileTestCrossProfileType.create(connector);
   private static final InstrumentedTestUtilities utilities =
       new InstrumentedTestUtilities(context, connector);
 
@@ -54,7 +58,7 @@
 
   @After
   public void teardown() {
-    connector.stopManualConnectionManagement();
+    connector.clearConnectionHolders();
     utilities.waitForDisconnected();
   }
 
@@ -73,15 +77,6 @@
   }
 
   @Test
-  public void connect_startsManuallyManagingConnection() throws Exception {
-    utilities.ensureReadyForCrossProfileCalls();
-
-    connector.connect();
-
-    assertThat(connector.isManuallyManagingConnection()).isTrue();
-  }
-
-  @Test
   public void connect_otherProfileNotAvailable_throwsUnavailableProfileException() {
     utilities.ensureNoWorkProfile();
 
@@ -98,15 +93,6 @@
   }
 
   @Test
-  public void connect_otherProfileNotAvailable_doesNotStartManuallyManagingConnection() {
-    utilities.ensureNoWorkProfile();
-
-    connectIgnoreExceptions();
-
-    assertThat(connector.isManuallyManagingConnection()).isFalse();
-  }
-
-  @Test
   public void connect_alreadyConnected_returns() throws UnavailableProfileException {
     utilities.ensureReadyForCrossProfileCalls();
     connector.connect();
@@ -116,6 +102,17 @@
     assertThat(connector.isConnected()).isTrue();
   }
 
+  // This is testing a race condition so it tries the same thing repeatedly. If this test becomes
+  // flaky it indicates the race condition is back
+  @Test
+  public void connect_immediatelyUsesConnection_connectionHolderIsSet() throws Exception {
+    for (int i = 0; i < 1000; i++) {
+      try (ProfileConnectionHolder ignored = connector.connect()) {
+        assertThat(type.other().identityStringMethod(STRING)).isEqualTo(STRING);
+      }
+    }
+  }
+
   private void connectIgnoreExceptions() {
     try {
       connector.connect();
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/CrossUserEndToEndTest.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/CrossUserEndToEndTest.java
new file mode 100644
index 0000000..6118ad9
--- /dev/null
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/CrossUserEndToEndTest.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.instrumented.tests;
+
+import static com.google.android.enterprise.connectedapps.SharedTestUtilities.getUserHandleForUserId;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Application;
+import android.os.UserHandle;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
+import com.google.android.enterprise.connectedapps.instrumented.utils.BlockingExceptionCallbackListener;
+import com.google.android.enterprise.connectedapps.instrumented.utils.BlockingStringCallbackListener;
+import com.google.android.enterprise.connectedapps.instrumented.utils.UserConnectorTestUtilities;
+import com.google.android.enterprise.connectedapps.instrumented.utils.UserManagementTestUtilities;
+import com.google.android.enterprise.connectedapps.testapp.crossuser.AppCrossUserConnector;
+import com.google.android.enterprise.connectedapps.testapp.crossuser.UserTestCrossUserType;
+import java.util.concurrent.ExecutionException;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for high level behaviour running on a correctly configured device (with a managed profile
+ * with the app installed in both sides, granted INTERACT_ACROSS_USERS).
+ *
+ * <p>This tests that each type of call works in both directions.
+ */
+@RunWith(JUnit4.class)
+public class CrossUserEndToEndTest {
+
+  private static final Application context = ApplicationProvider.getApplicationContext();
+
+  private static final String STRING = "String";
+
+  private final UserManagementTestUtilities userUtilities =
+      new UserManagementTestUtilities(context);
+
+  private int otherUserId;
+  private UserHandle other;
+  private UserHandle current;
+  private AppCrossUserConnector connector;
+  private UserConnectorTestUtilities connectorUtilities;
+  private UserTestCrossUserType testCrossUserType;
+
+  @Before
+  public void setup() {
+    otherUserId = userUtilities.ensureUserReadyForCrossUserCalls();
+    other = getUserHandleForUserId(otherUserId);
+    current = getUserHandleForUserId(0);
+    connector = AppCrossUserConnector.create(context);
+    connectorUtilities = new UserConnectorTestUtilities(connector);
+    testCrossUserType = UserTestCrossUserType.create(connector);
+  }
+
+  @After
+  public void teardown() {
+    connector.clearConnectionHolders(other);
+    connectorUtilities.waitForDisconnected(other);
+  }
+
+  @Test
+  public void isAvailable_isTrue() {
+    assertThat(connector.isAvailable(other)).isTrue();
+  }
+
+  @Test
+  public void isConnected_isFalse() {
+    connector.removeConnectionHolder(other, this);
+    connectorUtilities.waitForDisconnected(other);
+
+    assertThat(connector.isConnected(other)).isFalse();
+  }
+
+  @Test
+  public void hasConnected_isConnectedIsTrue() {
+    connectorUtilities.addConnectionHolderAndWait(other, this);
+
+    assertThat(connector.isConnected(other)).isTrue();
+  }
+
+  @Test
+  public void hasConnected_userStopped_isConnectedIsFalse() throws UnavailableProfileException {
+    connector.connect(other);
+
+    userUtilities.stopUser(otherUserId);
+
+    assertThat(connector.isConnected(other)).isFalse();
+  }
+
+  @Test
+  public void hasConnected_synchronousConnection_isConnectedIsTrue()
+      throws UnavailableProfileException {
+    connector.connect(other);
+
+    assertThat(connector.isConnected(other)).isTrue();
+  }
+
+  @Test
+  public void callOnCurrent_resultIsCorrect() {
+    assertThat(testCrossUserType.current().identityStringMethod(STRING)).isEqualTo(STRING);
+  }
+
+  @Test
+  public void callsUser_givesCurrentUserHandle_resultIsCorrect()
+      throws UnavailableProfileException {
+    assertThat(testCrossUserType.user(current).identityStringMethod(STRING)).isEqualTo(STRING);
+  }
+
+  @Test
+  public void synchronousMethod_resultIsCorrect() throws UnavailableProfileException {
+    connector.connect(other);
+
+    assertThat(testCrossUserType.user(other).identityStringMethod(STRING)).isEqualTo(STRING);
+  }
+
+  @Test
+  public void futureMethod_resultIsCorrect() throws InterruptedException, ExecutionException {
+    assertThat(testCrossUserType.user(other).listenableFutureIdentityStringMethod(STRING).get())
+        .isEqualTo(STRING);
+  }
+
+  @Test
+  public void asyncMethod_resultIsCorrect() throws InterruptedException {
+    BlockingStringCallbackListener stringCallbackListener = new BlockingStringCallbackListener();
+
+    testCrossUserType
+        .user(other)
+        .asyncIdentityStringMethod(
+            STRING, stringCallbackListener, new BlockingExceptionCallbackListener());
+
+    assertThat(stringCallbackListener.await()).isEqualTo(STRING);
+  }
+
+  // No round-trip tests which call into the other user and the other user calls back into the
+  // current user, as there is currently no way to grant INTERACT_ACROSS_USERS_FULL on a
+  // non-instrumented user in Android (and it is unlikely that this will never be added).
+}
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/ExceptionsTest.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/ExceptionsTest.java
new file mode 100644
index 0000000..289b4fb
--- /dev/null
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/ExceptionsTest.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.instrumented.tests;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.app.ActivityManager;
+import android.app.Application;
+import android.os.ParcelFileDescriptor;
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.enterprise.connectedapps.exceptions.ProfileRuntimeException;
+import com.google.android.enterprise.connectedapps.instrumented.utils.InstrumentedTestUtilities;
+import com.google.android.enterprise.connectedapps.testapp.CustomRuntimeException;
+import com.google.android.enterprise.connectedapps.testapp.connector.ExceptionsSuppressingConnector;
+import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
+import com.google.android.enterprise.connectedapps.testapp.types.ProfileExceptionsSuppressingCrossProfileType;
+import com.google.android.enterprise.connectedapps.testapp.types.ProfileTestCrossProfileType;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.concurrent.TimeUnit;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for exception handling in cross-profile methods. */
+@RunWith(JUnit4.class)
+public class ExceptionsTest {
+  private static final Application context = ApplicationProvider.getApplicationContext();
+
+  @Test
+  public void remoteException_throwsLocallyAndCrashes() throws IOException, InterruptedException {
+    TestProfileConnector connector = TestProfileConnector.create(context);
+    InstrumentedTestUtilities utilities = new InstrumentedTestUtilities(context, connector);
+    ProfileTestCrossProfileType type = ProfileTestCrossProfileType.create(connector);
+    utilities.ensureReadyForCrossProfileCalls();
+    utilities.addConnectionHolderAndWait(this);
+    String pidsBefore = getProcessIdsOfThisPackage();
+
+    ProfileRuntimeException exception = assertThrows(
+        ProfileRuntimeException.class,
+        () -> type.other().methodWhichThrowsRuntimeException());
+    assertThat(exception).hasCauseThat().isInstanceOf(CustomRuntimeException.class);
+
+    TimeUnit.SECONDS.sleep(5);
+
+    assertThat(getProcessIdsOfThisPackage()).isNotEqualTo(pidsBefore);
+  }
+
+  @Test
+  public void remoteException_throwsLocallyAndSuppresses() throws IOException, InterruptedException {
+    ExceptionsSuppressingConnector connector = ExceptionsSuppressingConnector.create(context);
+    InstrumentedTestUtilities utilities = new InstrumentedTestUtilities(context, connector);
+    ProfileExceptionsSuppressingCrossProfileType type =
+        ProfileExceptionsSuppressingCrossProfileType.create(connector);
+    utilities.ensureReadyForCrossProfileCalls();
+    utilities.addConnectionHolderAndWait(this);
+    String pidsBefore = getProcessIdsOfThisPackage();
+
+    ProfileRuntimeException exception = assertThrows(
+        ProfileRuntimeException.class,
+        () -> type.other().methodWhichThrowsRuntimeException());
+    assertThat(exception).hasCauseThat().isInstanceOf(CustomRuntimeException.class);
+
+    TimeUnit.SECONDS.sleep(5);
+
+    assertThat(getProcessIdsOfThisPackage()).isEqualTo(pidsBefore);
+  }
+
+  private static String getProcessIdsOfThisPackage() throws IOException {
+    ParcelFileDescriptor fd = InstrumentationRegistry.getInstrumentation()
+        .getUiAutomation()
+        .executeShellCommand("pidof " + context.getApplicationInfo().processName);
+    InputStream inputStream = new FileInputStream(fd.getFileDescriptor());
+    StringBuilder sb = new StringBuilder();
+    for (int ch; (ch = inputStream.read()) != -1; ) {
+      sb.append((char) ch);
+    }
+    return sb.toString();
+  }
+}
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/HappyPathEndToEndTest.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/HappyPathEndToEndTest.java
index 3b9ea57..719ca9a 100644
--- a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/HappyPathEndToEndTest.java
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/HappyPathEndToEndTest.java
@@ -19,7 +19,6 @@
 
 import android.app.Application;
 import androidx.test.core.app.ApplicationProvider;
-
 import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
 import com.google.android.enterprise.connectedapps.instrumented.utils.BlockingExceptionCallbackListener;
 import com.google.android.enterprise.connectedapps.instrumented.utils.BlockingStringCallbackListener;
@@ -32,11 +31,9 @@
 import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.Before;
-import org.junit.Ignore;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
-import android.util.Log;
 
 /**
  * Tests for high level behaviour running on a correctly configured device (with a managed profile
@@ -64,7 +61,7 @@
 
   @After
   public void teardown() {
-    connector.stopManualConnectionManagement();
+    connector.clearConnectionHolders();
     utilities.waitForDisconnected();
   }
 
@@ -80,7 +77,7 @@
 
   @Test
   public void isConnected_isFalse() {
-    connector.stopManualConnectionManagement();
+    connector.clearConnectionHolders();
     utilities.waitForDisconnected();
 
     assertThat(connector.isConnected()).isFalse();
@@ -88,14 +85,14 @@
 
   @Test
   public void isConnected_hasConnected_isTrue() {
-    utilities.manuallyConnectAndWait();
+    utilities.addConnectionHolderAndWait(this);
 
     assertThat(connector.isConnected()).isTrue();
   }
 
   @Test
   public void synchronousMethod_resultIsCorrect() throws UnavailableProfileException {
-    utilities.manuallyConnectAndWait();
+    utilities.addConnectionHolderAndWait(this);
 
     assertThat(type.other().identityStringMethod(STRING)).isEqualTo(STRING);
   }
@@ -119,7 +116,7 @@
   @Test
   public void synchronousMethod_fromOtherProfile_resultIsCorrect()
       throws UnavailableProfileException {
-    utilities.manuallyConnectAndWait();
+    utilities.addConnectionHolderAndWait(this);
     typeWithContext.other().connectToOtherProfile();
     BlockingPoll.poll(
         () -> {
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/InstrumentedTestUtilitiesTest.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/InstrumentedTestUtilitiesTest.java
index 81c9ce8..cc3d325 100644
--- a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/InstrumentedTestUtilitiesTest.java
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/InstrumentedTestUtilitiesTest.java
@@ -22,7 +22,7 @@
 import com.google.android.enterprise.connectedapps.AvailabilityListener;
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
 import com.google.android.enterprise.connectedapps.testing.InstrumentedTestUtilities;
-
+import org.junit.After;
 import org.junit.AfterClass;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -34,15 +34,20 @@
 
   private static final Application context = ApplicationProvider.getApplicationContext();
 
-  private static final TestProfileConnector connector = TestProfileConnector.create(context);
-  private static final InstrumentedTestUtilities utilities =
+  private final static TestProfileConnector connector = TestProfileConnector.create(context);
+  private final static InstrumentedTestUtilities utilities =
       new InstrumentedTestUtilities(context, connector);
 
   @AfterClass
-  public static void teardown() {
+  public static void teardownClass() {
     utilities.ensureNoWorkProfile();
   }
 
+  @After
+  public void teardown() {
+    connector.clearConnectionHolders();
+  }
+
   @Test
   public void isAvailable_ensureReadyForCrossProfileCalls_isTrue() {
     utilities.ensureReadyForCrossProfileCalls();
@@ -84,7 +89,7 @@
   public void isConnected_waitForConnected_isTrue() {
     utilities.ensureReadyForCrossProfileCalls();
 
-    connector.startConnecting();
+    connector.addConnectionHolder(this);
     utilities.waitForConnected();
 
     assertThat(connector.isConnected()).isTrue();
@@ -93,10 +98,10 @@
   @Test
   public void isConnected_waitForDisconnected_isFalse() {
     utilities.ensureReadyForCrossProfileCalls();
-    connector.startConnecting();
+    connector.addConnectionHolder(this);
     utilities.waitForConnected();
 
-    connector.stopManualConnectionManagement();
+    connector.clearConnectionHolders();
     utilities.waitForDisconnected();
 
     assertThat(connector.isConnected()).isFalse();
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/MessageSizeTest.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/MessageSizeTest.java
index 363b271..e0cc1e9 100644
--- a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/MessageSizeTest.java
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/MessageSizeTest.java
@@ -27,7 +27,6 @@
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
 import com.google.android.enterprise.connectedapps.testapp.types.ProfileTestCrossProfileType;
 import java.util.concurrent.ExecutionException;
-
 import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.Test;
@@ -58,23 +57,22 @@
   }
 
   @AfterClass
-  public static void teardown() {
+  public static void teardownClass() {
     utilities.ensureNoWorkProfile();
   }
 
   @Test
   public void synchronous_smallMessage_sends() throws UnavailableProfileException {
-    utilities.manuallyConnectAndWait();
+    utilities.addConnectionHolderAndWait(this);
 
     assertThat(type.other().identityStringMethod(SMALL_STRING)).isEqualTo(SMALL_STRING);
   }
 
   @Test
   public void synchronous_largeMessage_sends() throws UnavailableProfileException {
-    utilities.manuallyConnectAndWait();
+    utilities.addConnectionHolderAndWait(this);
 
-    // We can't use the asserts which compare Strings because of b/158998985
-    assertThat(type.other().identityStringMethod(LARGE_STRING).equals(LARGE_STRING)).isTrue();
+    assertThat(type.other().identityStringMethod(LARGE_STRING)).isEqualTo(LARGE_STRING);
   }
 
   @Test
@@ -90,8 +88,7 @@
     type.other()
         .asyncIdentityStringMethod(LARGE_STRING, stringCallbackListener, exceptionCallbackListener);
 
-    // We can't use the asserts which compare Strings because of b/158998985
-    assertThat(stringCallbackListener.await().equals(LARGE_STRING)).isTrue();
+    assertThat(stringCallbackListener.await()).isEqualTo(LARGE_STRING);
   }
 
   @Test
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/NotInstalledInOtherUserTest.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/NotInstalledInOtherUserTest.java
index 9b6de41..24ccd87 100644
--- a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/NotInstalledInOtherUserTest.java
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/NotInstalledInOtherUserTest.java
@@ -25,7 +25,6 @@
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
 import com.google.android.enterprise.connectedapps.testapp.types.ProfileTestCrossProfileType;
 import com.google.common.util.concurrent.ListenableFuture;
-
 import org.junit.AfterClass;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -48,7 +47,7 @@
       new InstrumentedTestUtilities(context, connector);
 
   @AfterClass
-  public static void teardown() {
+  public static void teardownClass() {
     utilities.ensureNoWorkProfile();
   }
 
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/NotReallySerializableTest.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/NotReallySerializableTest.java
index 912138b..589d3eb 100644
--- a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/NotReallySerializableTest.java
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/NotReallySerializableTest.java
@@ -29,7 +29,6 @@
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
 import com.google.android.enterprise.connectedapps.testapp.types.ProfileTestCrossProfileType;
 import com.google.common.util.concurrent.ListenableFuture;
-
 import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.Test;
@@ -60,14 +59,14 @@
   }
 
   @AfterClass
-  public static void teardown() {
+  public static void teardownClass() {
     utilities.ensureNoWorkProfile();
   }
 
   @Test
   public void
       synchronous_serializableObjectIsNotReallySerializable_throwsProfileRuntimeException() {
-    utilities.manuallyConnectAndWait();
+    utilities.addConnectionHolderAndWait(this);
 
     assertThrows(
         ProfileRuntimeException.class,
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/SecondUserTest.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/SecondUserTest.java
index b882aa6..15a5026 100644
--- a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/SecondUserTest.java
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/SecondUserTest.java
@@ -65,7 +65,7 @@
   public void call_hasWorkProfile_hasSecondUser_executesOnWorkProfile()
       throws UnavailableProfileException {
     utilities.ensureReadyForCrossProfileCalls();
-    utilities.manuallyConnectAndWait();
+    utilities.addConnectionHolderAndWait(this);
     int secondUserId = utilities.createUser("SecondUser");
 
     try {
@@ -83,7 +83,7 @@
   public void call_hasWorkProfile_hasSecondUser_fromWorkProfile_executesOnThisUser()
       throws UnavailableProfileException {
     utilities.ensureReadyForCrossProfileCalls();
-    utilities.manuallyConnectAndWait();
+    utilities.addConnectionHolderAndWait(this);
     int secondUserId = utilities.createUser("SecondUser");
 
     try {
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/TimeoutTest.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/TimeoutTest.java
new file mode 100644
index 0000000..c1d36d9
--- /dev/null
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/tests/TimeoutTest.java
@@ -0,0 +1,66 @@
+package com.google.android.enterprise.connectedapps.instrumented.tests;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Application;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.enterprise.connectedapps.ProfileConnectionHolder;
+import com.google.android.enterprise.connectedapps.instrumented.utils.BlockingExceptionCallbackListener;
+import com.google.android.enterprise.connectedapps.instrumented.utils.BlockingStringCallbackListener;
+import com.google.android.enterprise.connectedapps.instrumented.utils.InstrumentedTestUtilities;
+import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
+import com.google.android.enterprise.connectedapps.testapp.types.ProfileTestCrossProfileType;
+import java.util.concurrent.TimeUnit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Tests for timeout behaviour.
+ *
+ * <p>This is required as robolectric does not have callbacks behave correctly after the binding is
+ * closed.
+ */
+@RunWith(JUnit4.class)
+public class TimeoutTest {
+  private static final Application context = ApplicationProvider.getApplicationContext();
+
+  private static final String STRING = "String";
+
+  private final TestProfileConnector connector = TestProfileConnector.create(context);
+  private final InstrumentedTestUtilities utilities =
+      new InstrumentedTestUtilities(context, connector);
+  private final ProfileTestCrossProfileType type = ProfileTestCrossProfileType.create(connector);
+
+  @Before
+  public void setup() {
+    utilities.ensureReadyForCrossProfileCalls();
+  }
+
+  @After
+  public void teardown() {
+    utilities.ensureNoWorkProfile();
+  }
+
+  @Test
+  public void
+      other_async_callbackTriggeredMultipleTimes_connectionHeldOpen_isReceivedMultipleTimes()
+          throws Exception {
+    BlockingStringCallbackListener stringCallbackListener = new BlockingStringCallbackListener();
+
+    try (ProfileConnectionHolder connectionHolder =
+        connector.addConnectionHolder(stringCallbackListener)) {
+      type.other()
+          .asyncIdentityStringMethodWhichCallsBackTwiceWithNonBlockingDelay(
+              STRING,
+              stringCallbackListener,
+              /* secondsDelay= */ 60,
+              new BlockingExceptionCallbackListener());
+      stringCallbackListener.await();
+
+      assertThat(stringCallbackListener.await(200, TimeUnit.SECONDS)).isEqualTo(STRING);
+    }
+  }
+}
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/BlockingCallbackListener.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/BlockingCallbackListener.java
index 6963fdd..d4787f0 100644
--- a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/BlockingCallbackListener.java
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/BlockingCallbackListener.java
@@ -15,7 +15,9 @@
  */
 package com.google.android.enterprise.connectedapps.instrumented.utils;
 
-import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
 
 /**
  * Base class for callback listeners which can block until a result is received.
@@ -24,16 +26,17 @@
  * {@link #receive(Object)} when the callback completes.
  */
 public abstract class BlockingCallbackListener<E> {
-  private E callbackValue;
-  private final CountDownLatch latch = new CountDownLatch(1);
+  private BlockingQueue<E> results = new LinkedBlockingQueue<>();
+
+  public E await(long timeout, TimeUnit unit) throws InterruptedException {
+    return results.poll(timeout, unit);
+  }
 
   public E await() throws InterruptedException {
-    latch.await();
-    return callbackValue;
+    return await(10, TimeUnit.MINUTES);
   }
 
   protected void receive(E value) {
-    callbackValue = value;
-    latch.countDown();
+    results.offer(value);
   }
 }
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/BlockingParcelableCallbackListener.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/BlockingParcelableCallbackListener.java
new file mode 100644
index 0000000..1c42229
--- /dev/null
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/BlockingParcelableCallbackListener.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.instrumented.utils;
+
+import android.os.Parcelable;
+import com.google.android.enterprise.connectedapps.testapp.TestParcelableCallbackListener;
+
+/** A {@link TestParcelableCallbackListener} which can block for a result. */
+public class BlockingParcelableCallbackListener extends BlockingCallbackListener<Parcelable>
+    implements TestParcelableCallbackListener {
+  @Override
+  public void parcelableCallback(Parcelable s) {
+    receive(s);
+  }
+}
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/InstrumentedTestUtilities.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/InstrumentedTestUtilities.java
index be1db8e..3c05431 100644
--- a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/InstrumentedTestUtilities.java
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/InstrumentedTestUtilities.java
@@ -15,24 +15,14 @@
  */
 package com.google.android.enterprise.connectedapps.instrumented.utils;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static com.google.android.enterprise.connectedapps.SharedTestUtilities.getUserHandleForUserId;
+import static com.google.android.enterprise.connectedapps.instrumented.utils.UserAndProfileTestUtilities.runCommandWithOutput;
 
 import android.content.Context;
-import android.os.Build.VERSION;
-import android.os.Build.VERSION_CODES;
-import android.os.ParcelFileDescriptor;
 import android.os.UserHandle;
-import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.enterprise.connectedapps.ProfileConnectionHolder;
 import com.google.android.enterprise.connectedapps.ProfileConnector;
-import com.google.android.enterprise.connectedapps.SharedTestUtilities;
-import com.google.android.enterprise.connectedapps.instrumented.utils.ServiceCall.Parameter;
 import com.google.android.enterprise.connectedapps.testing.ProfileAvailabilityPoll;
-import java.io.FileInputStream;
-import java.io.InputStream;
-import java.util.NoSuchElementException;
-import java.util.Scanner;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
 
 /**
  * Wrapper around {@link
@@ -44,73 +34,14 @@
   private final ProfileConnector connector;
   private final Context context;
   private final com.google.android.enterprise.connectedapps.testing.InstrumentedTestUtilities
-          instrumentedTestUtilities;
-
-  private static final int S_REQUEST_QUIET_MODE_ENABLED_ID = 73;
-  private static final int R_REQUEST_QUIET_MODE_ENABLED_ID = 72;
-  private static final int REQUEST_QUIET_MODE_ENABLED_ID = 58;
-
-  private static final String USER_ID_KEY = "USER_ID";
-  private static final Parameter USER_ID_PARAMETER = new Parameter(USER_ID_KEY);
-
-  private static final ServiceCall S_TURN_OFF_WORK_PROFILE_COMMAND =
-          new ServiceCall("user", S_REQUEST_QUIET_MODE_ENABLED_ID)
-                  .setUser(1000) // user 1000 has packageName "android"
-                  .addStringParam("android") // callingPackage
-                  .addBooleanParam(true) // enableQuietMode
-                  .addIntParam(USER_ID_PARAMETER) // userId
-                  .addIntParam(0) // target
-                  .addIntParam(0); // flags
-
-  private static final ServiceCall R_TURN_OFF_WORK_PROFILE_COMMAND =
-          new ServiceCall("user", R_REQUEST_QUIET_MODE_ENABLED_ID)
-                  .setUser(1000) // user 1000 has packageName "android"
-                  .addStringParam("android") // callingPackage
-                  .addBooleanParam(true) // enableQuietMode
-                  .addIntParam(USER_ID_PARAMETER) // userId
-                  .addIntParam(0) // target
-                  .addIntParam(0); // flags
-
-  private static final ServiceCall TURN_OFF_WORK_PROFILE_COMMAND =
-          new ServiceCall("user", REQUEST_QUIET_MODE_ENABLED_ID)
-                  .setUser(1000) // user 1000 has packageName "android"
-                  .addStringParam("android") // callingPackage
-                  .addBooleanParam(true) // enableQuietMode
-                  .addIntParam(USER_ID_PARAMETER) // userId
-                  .addIntParam(0); // target
-
-  private static final ServiceCall S_TURN_ON_WORK_PROFILE_COMMAND =
-          new ServiceCall("user", S_REQUEST_QUIET_MODE_ENABLED_ID)
-                  .setUser(1000) // user 1000 has packageName "android"
-                  .addStringParam("android") // callingPackage
-                  .addBooleanParam(false) // enableQuietMode
-                  .addIntParam(USER_ID_PARAMETER) // userId
-                  .addIntParam(0) // target
-                  .addIntParam(0); // flags
-
-  private static final ServiceCall R_TURN_ON_WORK_PROFILE_COMMAND =
-          new ServiceCall("user", R_REQUEST_QUIET_MODE_ENABLED_ID)
-                  .setUser(1000) // user 1000 has packageName "android"
-                  .addStringParam("android") // callingPackage
-                  .addBooleanParam(false) // enableQuietMode
-                  .addIntParam(USER_ID_PARAMETER) // userId
-                  .addIntParam(0) // target
-                  .addIntParam(0); // flags
-
-  private static final ServiceCall TURN_ON_WORK_PROFILE_COMMAND =
-          new ServiceCall("user", REQUEST_QUIET_MODE_ENABLED_ID)
-                  .setUser(1000) // user 1000 has packageName "android"
-                  .addStringParam("android") // callingPackage
-                  .addBooleanParam(false) // enableQuietMode
-                  .addIntParam(USER_ID_PARAMETER) // userId
-                  .addIntParam(0); // target
+      instrumentedTestUtilities;
 
   public InstrumentedTestUtilities(Context context, ProfileConnector connector) {
     this.context = context;
     this.connector = connector;
     this.instrumentedTestUtilities =
-            new com.google.android.enterprise.connectedapps.testing.InstrumentedTestUtilities(
-                    context, connector);
+        new com.google.android.enterprise.connectedapps.testing.InstrumentedTestUtilities(
+            context, connector);
   }
 
   /**
@@ -130,7 +61,7 @@
   }
 
   private UserHandle getWorkProfileUserHandle() {
-    return SharedTestUtilities.getUserHandleForUserId(getWorkProfileUserId());
+    return getUserHandleForUserId(getWorkProfileUserId());
   }
 
   /**
@@ -156,7 +87,7 @@
 
   public void installInUser(int userId) {
     runCommandWithOutput(
-            "cmd package install-existing --user " + userId + " " + context.getPackageName());
+        "cmd package install-existing --user " + userId + " " + context.getPackageName());
   }
 
   /**
@@ -167,7 +98,7 @@
   public void grantInteractAcrossUsers() {
     // TODO(scottjonathan): Support INTERACT_ACROSS_PROFILES in these tests.
     runCommandWithOutput(
-            "pm grant " + context.getPackageName() + " android.permission.INTERACT_ACROSS_USERS");
+        "pm grant " + context.getPackageName() + " android.permission.INTERACT_ACROSS_USERS");
   }
 
   /**
@@ -190,15 +121,12 @@
         return;
       }
 
-
-      runCommandWithOutput("pm remove-user " + getWorkProfileUserId());
-
       // TODO(162219825): Try to remove the package
 
-//      throw new IllegalStateException(
-//              "There is already a work profile on the device with user id "
-//                      + getWorkProfileUserId()
-//                      + ".");
+      throw new IllegalStateException(
+          "There is already a work profile on the device with user id "
+              + getWorkProfileUserId()
+              + ".");
     }
     runCommandWithOutput("pm create-user --profileOf 0 --managed TestProfile123");
     int workProfileUserId = getWorkProfileUserId();
@@ -208,7 +136,7 @@
   private static boolean userHasPackageInstalled(int userId, String packageName) {
     String expectedPackageLine = "package:" + packageName;
     String[] installedPackages =
-            runCommandWithOutput("pm list packages --user " + userId).split("\n");
+        runCommandWithOutput("pm list packages --user " + userId).split("\n");
     for (String installedPackage : installedPackages) {
       if (installedPackage.equals(expectedPackageLine)) {
         return true;
@@ -228,51 +156,6 @@
   }
 
   /**
-   * Turn off the work profile and block until it has been turned off.
-   *
-   * <p>This uses {@link ServiceCall} and so is only guaranteed to work correctly on AOSP.
-   *
-   * @see #turnOffWorkProfile()
-   */
-  public void turnOffWorkProfileAndWait() {
-    turnOffWorkProfile();
-
-    ProfileAvailabilityPoll.blockUntilProfileNotAvailable(context, getWorkProfileUserHandle());
-  }
-
-  // TODO(160147511): Remove use of service calls for versions after R
-  /**
-   * Turn off the work profile
-   *
-   * <p>This uses {@link ServiceCall} and so is only guaranteed to work correctly on AOSP.
-   *
-   * @see #turnOffWorkProfileAndWait()
-   */
-  public void turnOffWorkProfile() {
-    if (VERSION.CODENAME.equals("S")) {
-      runCommandWithOutput(
-              S_TURN_OFF_WORK_PROFILE_COMMAND
-                      .prepare()
-                      .setInt(USER_ID_KEY, getWorkProfileUserId())
-                      .getCommand());
-    } else if (VERSION.SDK_INT == VERSION_CODES.R) {
-      runCommandWithOutput(
-              R_TURN_OFF_WORK_PROFILE_COMMAND
-                      .prepare()
-                      .setInt(USER_ID_KEY, getWorkProfileUserId())
-                      .getCommand());
-    } else if (VERSION.SDK_INT == VERSION_CODES.Q || VERSION.SDK_INT == VERSION_CODES.P) {
-      runCommandWithOutput(
-              TURN_OFF_WORK_PROFILE_COMMAND
-                      .prepare()
-                      .setInt(USER_ID_KEY, getWorkProfileUserId())
-                      .getCommand());
-    } else {
-      throw new IllegalStateException("Cannot turn off work on this version of android");
-    }
-  }
-
-  /**
    * Turn on the work profile and block until it has been turned on.
    *
    * <p>This uses {@link ServiceCall} and so is only guaranteed to work correctly on AOSP.
@@ -286,40 +169,42 @@
 
     turnOnWorkProfile();
 
-    ProfileAvailabilityPoll.blockUntilProfileRunningAndUnlocked(
-            context, getWorkProfileUserHandle());
+    ProfileAvailabilityPoll.blockUntilUserRunningAndUnlocked(context, getWorkProfileUserHandle());
   }
 
-  // TODO(160147511): Remove use of service calls for versions after R
   /**
-   * Turn on the work profile and block until it has been turned on.
+   * Turn on the work profile.
    *
    * <p>This uses {@link ServiceCall} and so is only guaranteed to work correctly on AOSP.
    *
    * @see #turnOnWorkProfileAndWait()
    */
   public void turnOnWorkProfile() {
-    if (VERSION.CODENAME.equals("S")) {
-      runCommandWithOutput(
-              S_TURN_ON_WORK_PROFILE_COMMAND
-                      .prepare()
-                      .setInt(USER_ID_KEY, getWorkProfileUserId())
-                      .getCommand());
-    } else if (VERSION.SDK_INT == VERSION_CODES.R) {
-      runCommandWithOutput(
-              R_TURN_ON_WORK_PROFILE_COMMAND
-                      .prepare()
-                      .setInt(USER_ID_KEY, getWorkProfileUserId())
-                      .getCommand());
-    } else if (VERSION.SDK_INT == VERSION_CODES.Q || VERSION.SDK_INT == VERSION_CODES.P) {
-      runCommandWithOutput(
-              TURN_ON_WORK_PROFILE_COMMAND
-                      .prepare()
-                      .setInt(USER_ID_KEY, getWorkProfileUserId())
-                      .getCommand());
-    } else {
-      throw new IllegalStateException("Cannot turn on work on this version of android");
-    }
+    UserAndProfileTestUtilities.turnOnUser(getWorkProfileUserId());
+  }
+
+  /**
+   * Turn off the work profile and block until it has been turned off.
+   *
+   * <p>This uses {@link ServiceCall} and so is only guaranteed to work correctly on AOSP.
+   *
+   * @see #turnOffWorkProfile()
+   */
+  public void turnOffWorkProfileAndWait() {
+    turnOffWorkProfile();
+
+    ProfileAvailabilityPoll.blockUntilUserNotAvailable(context, getWorkProfileUserHandle());
+  }
+
+  /**
+   * Turn off the work profile.
+   *
+   * <p>This uses {@link ServiceCall} and so is only guaranteed to work correctly on AOSP.
+   *
+   * @see #turnOffWorkProfileAndWait()
+   */
+  public void turnOffWorkProfile() {
+    UserAndProfileTestUtilities.turnOffUser(getWorkProfileUserId());
   }
 
   /**
@@ -339,50 +224,20 @@
   }
 
   /**
-   * Manually call {@link ProfileConnector#startConnecting()} and wait for connection to be
+   * Call {@link ProfileConnector#addConnectionHolder(Object)} ()} and wait for connection to be
    * complete.
    */
-  public void manuallyConnectAndWait() {
-    connector.startConnecting();
+  public ProfileConnectionHolder addConnectionHolderAndWait(Object connectionHolder) {
+    ProfileConnectionHolder p = connector.addConnectionHolder(connectionHolder);
     waitForConnected();
+    return p;
   }
 
-  private static final Pattern CREATE_USER_PATTERN =
-          Pattern.compile("Success: created user id (\\d+)");
-
   public int createUser(String username) {
-    String output = runCommandWithOutput("pm create-user " + username);
-
-    Matcher userMatcher = CREATE_USER_PATTERN.matcher(output);
-    if (userMatcher.find()) {
-      return Integer.parseInt(userMatcher.group(1));
-    }
-
-    throw new IllegalStateException("Could not create user. Output: " + output);
+    return UserAndProfileTestUtilities.createUser(username);
   }
 
   public void startUser(int userId) {
-    UserHandle userHandle = SharedTestUtilities.getUserHandleForUserId(userId);
-    InstrumentedTestUtilities.runCommandWithOutput("am start-user " + userId);
-    ProfileAvailabilityPoll.blockUntilProfileRunningAndUnlocked(context, userHandle);
-  }
-
-  private static String runCommandWithOutput(String command) {
-    // TODO: Log output so we can see why it's failing
-    ParcelFileDescriptor p = runCommand(command);
-
-    InputStream inputStream = new FileInputStream(p.getFileDescriptor());
-
-    try (Scanner scanner = new Scanner(inputStream, UTF_8.name())) {
-      return scanner.useDelimiter("\\A").next();
-    } catch (NoSuchElementException e) {
-      return "";
-    }
-  }
-
-  private static ParcelFileDescriptor runCommand(String command) {
-    return InstrumentationRegistry.getInstrumentation()
-            .getUiAutomation()
-            .executeShellCommand(command);
+    UserAndProfileTestUtilities.startUserAndBlock(context, userId);
   }
 }
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/UserAndProfileTestUtilities.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/UserAndProfileTestUtilities.java
new file mode 100644
index 0000000..3bf283f
--- /dev/null
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/UserAndProfileTestUtilities.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.instrumented.utils;
+
+import static com.google.android.enterprise.connectedapps.SharedTestUtilities.getUserHandleForUserId;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.content.Context;
+import android.os.Build.VERSION;
+import android.os.Build.VERSION_CODES;
+import android.os.ParcelFileDescriptor;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.enterprise.connectedapps.instrumented.utils.ServiceCall.Parameter;
+import com.google.android.enterprise.connectedapps.testing.ProfileAvailabilityPoll;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.util.NoSuchElementException;
+import java.util.Scanner;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+// TODO(b/160147511): Remove use of service calls for versions after R
+final class UserAndProfileTestUtilities {
+
+  private static final int R_REQUEST_QUIET_MODE_ENABLED_ID = 72;
+  private static final int REQUEST_QUIET_MODE_ENABLED_ID = 58;
+
+  private static final String USER_ID_KEY = "USER_ID";
+  private static final Parameter USER_ID_PARAMETER = new Parameter(USER_ID_KEY);
+
+  private static final ServiceCall R_TURN_OFF_USER_COMMAND =
+      new ServiceCall("user", R_REQUEST_QUIET_MODE_ENABLED_ID)
+          .setUser(1000) // user 1000 has packageName "android"
+          .addStringParam("android") // callingPackage
+          .addBooleanParam(true) // enableQuietMode
+          .addIntParam(USER_ID_PARAMETER) // userId
+          .addIntParam(0) // target
+          .addIntParam(0); // flags
+
+  private static final ServiceCall TURN_OFF_USER_COMMAND =
+      new ServiceCall("user", REQUEST_QUIET_MODE_ENABLED_ID)
+          .setUser(1000) // user 1000 has packageName "android"
+          .addStringParam("android") // callingPackage
+          .addBooleanParam(true) // enableQuietMode
+          .addIntParam(USER_ID_PARAMETER) // userId
+          .addIntParam(0); // target
+
+  private static final ServiceCall R_TURN_ON_USER_COMMAND =
+      new ServiceCall("user", R_REQUEST_QUIET_MODE_ENABLED_ID)
+          .setUser(1000) // user 1000 has packageName "android"
+          .addStringParam("android") // callingPackage
+          .addBooleanParam(false) // enableQuietMode
+          .addIntParam(USER_ID_PARAMETER) // userId
+          .addIntParam(0) // target
+          .addIntParam(0); // flags
+
+  private static final ServiceCall TURN_ON_USER_COMMAND =
+      new ServiceCall("user", REQUEST_QUIET_MODE_ENABLED_ID)
+          .setUser(1000) // user 1000 has packageName "android"
+          .addStringParam("android") // callingPackage
+          .addBooleanParam(false) // enableQuietMode
+          .addIntParam(USER_ID_PARAMETER) // userId
+          .addIntParam(0); // target
+
+  static void turnOnUser(int userId) {
+    if (VERSION.SDK_INT == VERSION_CODES.R) {
+      runServiceCall(R_TURN_ON_USER_COMMAND, userId);
+    } else if (VERSION.SDK_INT == VERSION_CODES.Q || VERSION.SDK_INT == VERSION_CODES.P) {
+      runServiceCall(TURN_ON_USER_COMMAND, userId);
+    } else {
+      throw new IllegalStateException("Cannot turn on user on this version of android");
+    }
+  }
+
+  static void turnOffUser(int userId) {
+    if (VERSION.SDK_INT == VERSION_CODES.R) {
+      runServiceCall(R_TURN_OFF_USER_COMMAND, userId);
+    } else if (VERSION.SDK_INT == VERSION_CODES.Q || VERSION.SDK_INT == VERSION_CODES.P) {
+      runServiceCall(TURN_OFF_USER_COMMAND, userId);
+    } else {
+      throw new IllegalStateException("Cannot turn off user on this version of android");
+    }
+  }
+
+  private static void runServiceCall(ServiceCall serviceCall, int userId) {
+    runCommandWithOutput(serviceCall.prepare().setInt(USER_ID_KEY, userId).getCommand());
+  }
+
+  private static final Pattern CREATE_USER_PATTERN =
+      Pattern.compile("Success: created user id (\\d+)");
+
+  static int createUser(String username) {
+    String output = runCommandWithOutput("pm create-user " + username);
+
+    Matcher userMatcher = CREATE_USER_PATTERN.matcher(output);
+    if (userMatcher.find()) {
+      return Integer.parseInt(userMatcher.group(1));
+    }
+
+    throw new IllegalStateException("Could not create user. Output: " + output);
+  }
+
+  static void startUserAndBlock(Context context, int userId) {
+    runCommandWithOutput("am start-user " + userId);
+    ProfileAvailabilityPoll.blockUntilUserRunningAndUnlocked(
+        context, getUserHandleForUserId(userId));
+  }
+
+  static String runCommandWithOutput(String command) {
+    ParcelFileDescriptor p = runCommand(command);
+    InputStream inputStream = new FileInputStream(p.getFileDescriptor());
+
+    try (Scanner scanner = new Scanner(inputStream, UTF_8.name())) {
+      return scanner.useDelimiter("\\A").next();
+    } catch (NoSuchElementException ignored) {
+      return "";
+    }
+  }
+
+  private static ParcelFileDescriptor runCommand(String command) {
+    return InstrumentationRegistry.getInstrumentation()
+        .getUiAutomation()
+        .executeShellCommand(command);
+  }
+
+  private UserAndProfileTestUtilities() {}
+}
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/UserConnectorTestUtilities.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/UserConnectorTestUtilities.java
new file mode 100644
index 0000000..71f4af6
--- /dev/null
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/UserConnectorTestUtilities.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.instrumented.utils;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+
+import android.os.UserHandle;
+import com.google.android.enterprise.connectedapps.ConnectionListener;
+import com.google.android.enterprise.connectedapps.UserConnector;
+import java.util.concurrent.CountDownLatch;
+
+public class UserConnectorTestUtilities {
+
+  private static final int TIMEOUT_MS = 10_000;
+
+  private final UserConnector connector;
+
+  public UserConnectorTestUtilities(UserConnector connector) {
+    this.connector = connector;
+  }
+
+  public void waitForConnected(UserHandle userHandle) {
+    CountDownLatch connectionLatch = new CountDownLatch(1);
+
+    ConnectionListener connectionListener =
+        () -> {
+          if (connector.isConnected(userHandle)) {
+            connectionLatch.countDown();
+          }
+        };
+
+    connector.addConnectionListener(userHandle, connectionListener);
+    connectionListener.connectionChanged();
+
+    try {
+      connectionLatch.await(TIMEOUT_MS, MILLISECONDS);
+    } catch (InterruptedException e) {
+      throw new AssertionError("Error waiting to connect", e);
+    } finally {
+      connector.removeConnectionListener(userHandle, connectionListener);
+    }
+  }
+
+  public void waitForDisconnected(UserHandle userHandle) {
+    CountDownLatch connectionLatch = new CountDownLatch(1);
+
+    ConnectionListener connectionListener =
+        () -> {
+          if (!connector.isConnected(userHandle)) {
+            connectionLatch.countDown();
+          }
+        };
+
+    connector.addConnectionListener(userHandle, connectionListener);
+    connectionListener.connectionChanged();
+
+    try {
+      connectionLatch.await(TIMEOUT_MS, MILLISECONDS);
+    } catch (InterruptedException e) {
+      throw new AssertionError("Error waiting to disconnect", e);
+    } finally {
+      connector.removeConnectionListener(userHandle, connectionListener);
+    }
+  }
+
+  public void addConnectionHolderAndWait(UserHandle userHandle, Object connectionHolder) {
+    connector.addConnectionHolder(userHandle, connectionHolder);
+    waitForConnected(userHandle);
+  }
+}
diff --git a/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/UserManagementTestUtilities.java b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/UserManagementTestUtilities.java
new file mode 100644
index 0000000..4188395
--- /dev/null
+++ b/tests/instrumented/src/main/java/com/google/android/enterprise/connectedapps/instrumented/utils/UserManagementTestUtilities.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.instrumented.utils;
+
+import static android.os.UserHandle.getUserHandleForUid;
+import static com.google.android.enterprise.connectedapps.instrumented.utils.UserAndProfileTestUtilities.runCommandWithOutput;
+
+import android.content.Context;
+import android.os.UserHandle;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.google.android.enterprise.connectedapps.testing.BlockingPoll;
+import com.google.android.enterprise.connectedapps.testing.ProfileAvailabilityPoll;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class UserManagementTestUtilities {
+
+  private final Context context;
+
+  public UserManagementTestUtilities(Context context) {
+    this.context = context;
+  }
+
+  public void stopUser(int userId) {
+    runCommandWithOutput("am stop-user -w -f " + userId);
+  }
+
+  /** Create a user, perform all necessary user setup, and return their ID. */
+  public int ensureUserReadyForCrossUserCalls() {
+    return ensureUserReadyForCrossUserCalls(context.getPackageName());
+  }
+
+  public int ensureUserReadyForCrossUserCalls(String packageName) {
+    int userId = createOrFindOtherUser();
+    UserAndProfileTestUtilities.startUserAndBlock(context, userId);
+
+    ensurePackageInstalled(userId, packageName);
+    grantInteractAcrossUsersFull();
+
+    waitForChangesToTakeEffect(getUserHandleForUid(userId));
+
+    return userId;
+  }
+
+  private int createOrFindOtherUser() {
+    if (hasOtherUser()) {
+      return getOtherUserId();
+    }
+
+    return createOtherUser();
+  }
+
+  private int createOtherUser() {
+    int userId = UserAndProfileTestUtilities.createUser("TestOtherUser");
+    BlockingPoll.poll(this::hasOtherUser, 100, 10000);
+    installPackage(userId, context.getPackageName());
+    return userId;
+  }
+
+  private boolean hasOtherUser() {
+    try {
+      getOtherUserId();
+      return true;
+    } catch (IllegalStateException e) {
+      return false;
+    }
+  }
+
+  private int getOtherUserId() {
+    String userList = runCommandWithOutput("pm list users");
+
+    Matcher matcher = Pattern.compile("UserInfo\\{(.*):.*:.*\\}").matcher(userList);
+
+    while (matcher.find()) {
+      int userId = Integer.parseInt(matcher.group(1));
+      if (userId != 0) {
+        // Skip system user
+        return userId;
+      }
+    }
+
+    throw new IllegalStateException("No non-system user found: " + userList);
+  }
+
+  private void ensurePackageInstalled(int userId, String packageName) {
+    if (!packageName.equals(context.getPackageName())) {
+      installPackage(userId, packageName);
+    }
+  }
+
+  private static void installPackage(int userId, String packageName) {
+    runCommandWithOutput("cmd package install-existing --user " + userId + " " + packageName);
+  }
+
+  private void grantInteractAcrossUsersFull() {
+    InstrumentationRegistry.getInstrumentation()
+        .getUiAutomation()
+        .adoptShellPermissionIdentity("android.permission.INTERACT_ACROSS_USERS_FULL");
+  }
+
+  private void waitForChangesToTakeEffect(UserHandle userHandle) {
+    ProfileAvailabilityPoll.blockUntilUserRunningAndUnlocked(context, userHandle);
+  }
+}
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/AlwaysThrowsTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/AlwaysThrowsTest.java
index 08e4e2a..b7ff2dd 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/AlwaysThrowsTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/AlwaysThrowsTest.java
@@ -53,7 +53,7 @@
                 annotatedNotesProvider(annotationPrinter),
                 annotatedNotesCrossProfileType(annotationPrinter));
 
-    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_AlwaysThrows");
+    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".NotesType_AlwaysThrows");
   }
 
   @Test
@@ -66,10 +66,9 @@
                 annotatedNotesCrossProfileType(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_AlwaysThrows")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_AlwaysThrows")
         .contentsAsUtf8String()
-        .contains(
-            "class ProfileNotesType_AlwaysThrows implements" + " ProfileNotesType_SingleSender");
+        .contains("class NotesType_AlwaysThrows implements NotesType_SingleSender");
   }
 
   @Test
@@ -82,8 +81,8 @@
                 annotatedNotesCrossProfileType(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_AlwaysThrows")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_AlwaysThrows")
         .contentsAsUtf8String()
-        .contains("public ProfileNotesType_AlwaysThrows(String errorMessage)");
+        .contains("public NotesType_AlwaysThrows(String errorMessage)");
   }
 }
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/BundlerTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/BundlerTest.java
index ca62a9d..88d3d96 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/BundlerTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/BundlerTest.java
@@ -53,7 +53,7 @@
                 annotatedNotesProvider(annotationPrinter),
                 annotatedNotesCrossProfileType(annotationPrinter));
 
-    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_Bundler");
+    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".NotesType_Bundler");
   }
 
   @Test
@@ -66,8 +66,8 @@
                 annotatedNotesCrossProfileType(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_Bundler")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_Bundler")
         .contentsAsUtf8String()
-        .contains("ProfileNotesType_Bundler implements Bundler");
+        .contains("NotesType_Bundler implements Bundler");
   }
 }
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CacheableTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CacheableTest.java
new file mode 100644
index 0000000..f0c1bef
--- /dev/null
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CacheableTest.java
@@ -0,0 +1,319 @@
+package com.google.android.enterprise.connectedapps.processor;
+
+import static com.google.android.enterprise.connectedapps.processor.TestUtilities.INSTALLATION_LISTENER_NAME;
+import static com.google.android.enterprise.connectedapps.processor.TestUtilities.NOTES_PACKAGE;
+import static com.google.android.enterprise.connectedapps.processor.TestUtilities.SERIALIZABLE_OBJECT;
+import static com.google.android.enterprise.connectedapps.processor.TestUtilities.installationListener;
+import static com.google.android.enterprise.connectedapps.processor.TestUtilities.installationListenerSimple;
+import static com.google.android.enterprise.connectedapps.processor.TestUtilities.installationListenerSimpleWithIntentParam;
+import static com.google.android.enterprise.connectedapps.processor.TestUtilities.installationListenerSimpleWithStringParam;
+import static com.google.testing.compile.CompilationSubject.assertThat;
+import static com.google.testing.compile.Compiler.javac;
+
+import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationFinder;
+import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationStrings;
+import com.google.testing.compile.Compilation;
+import com.google.testing.compile.JavaFileObjects;
+import javax.tools.JavaFileObject;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class CacheableTest {
+  private static final String CACHEABLE_METHOD_RETURNS_VOID_ERROR =
+      "Methods annotated with @Cacheable must return a non-void type";
+  private static final String CACHEABLE_ANNOTATION_ON_NON_METHOD_ERROR =
+      "annotation type not applicable to this kind of declaration";
+  private static final String CACHEABLE_METHOD_RETURNS_NON_SERIALIZABLE_ERROR =
+      "Methods annotated with @Cacheable must return a type which implements Serializable, return"
+          + " a future with a Serializable result or return void with a simple callback parameter.";
+  private static final String CACHEABLE_METHOD_NON_SIMPLE_CALLBACK_ERROR =
+      "Methods annotated with @Cacheable may only have a callback parameter which is simple.";
+  private static final String CACHEABLE_METHOD_USES_INVALID_PARAMETERS_ERROR =
+      "Methods annotated with @Cacheable may only use callbacks that take a single Serializable"
+          + " parameter.";
+
+  private final AnnotationStrings annotationStrings;
+
+  public CacheableTest(AnnotationStrings annotationStrings) {
+    this.annotationStrings = annotationStrings;
+  }
+
+  @Parameters(name = "{0}")
+  public static Iterable<AnnotationStrings> getAnnotationPrinters() {
+    return AnnotationFinder.annotationStrings();
+  }
+
+  @Test
+  public void validCacheableAnnotation_compiles() {
+    JavaFileObject validCacheableMethod =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesType",
+            "package " + NOTES_PACKAGE + ";",
+            "import com.google.android.enterprise.connectedapps.annotations.Cacheable;",
+            "import " + annotationStrings.crossProfileQualifiedName() + ";",
+            "public final class NotesType {",
+            annotationStrings.crossProfileAsAnnotation(),
+            "@Cacheable",
+            "public int countNotes() {",
+            "return 1;",
+            "  }",
+            "}");
+
+    Compilation compilation = javac().withProcessors(new Processor()).compile(validCacheableMethod);
+
+    assertThat(compilation).succeededWithoutWarnings();
+  }
+
+  @Test
+  public void cacheableAnnotationOnClass_hasError() {
+    JavaFileObject validCacheableMethod =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesType",
+            "package " + NOTES_PACKAGE + ";",
+            "import com.google.android.enterprise.connectedapps.annotations.Cacheable;",
+            "import " + annotationStrings.crossProfileQualifiedName() + ";",
+            annotationStrings.crossProfileAsAnnotation(),
+            "@Cacheable",
+            "public final class NotesType {",
+            "public void refreshNotes() {",
+            "  }",
+            "}");
+
+    Compilation compilation = javac().withProcessors(new Processor()).compile(validCacheableMethod);
+
+    assertThat(compilation)
+        .hadErrorContaining(CACHEABLE_ANNOTATION_ON_NON_METHOD_ERROR)
+        .inFile(validCacheableMethod);
+  }
+
+  @Test
+  public void cacheableMethodReturnsVoid_hasError() {
+    JavaFileObject voidMethod =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesType",
+            "package " + NOTES_PACKAGE + ";",
+            "import com.google.android.enterprise.connectedapps.annotations.Cacheable;",
+            "import " + annotationStrings.crossProfileQualifiedName() + ";",
+            "public final class NotesType {",
+            annotationStrings.crossProfileAsAnnotation(),
+            "@Cacheable",
+            "public void refreshNotes() {",
+            "  }",
+            "}");
+
+    Compilation compilation = javac().withProcessors(new Processor()).compile(voidMethod);
+
+    assertThat(compilation)
+        .hadErrorContaining(CACHEABLE_METHOD_RETURNS_VOID_ERROR)
+        .inFile(voidMethod);
+  }
+
+  @Test
+  public void cacheableMethodReturnsNonSerializable_hasError() {
+    JavaFileObject nonSerializableMethod =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesType",
+            "package " + NOTES_PACKAGE + ";",
+            "import android.content.Intent;",
+            "import com.google.android.enterprise.connectedapps.annotations.Cacheable;",
+            "import " + annotationStrings.crossProfileQualifiedName() + ";",
+            "public final class NotesType {",
+            annotationStrings.crossProfileAsAnnotation(),
+            "@Cacheable",
+            "public Intent refreshNotes() {",
+            "return new Intent();",
+            "  }",
+            "}");
+
+    Compilation compilation =
+        javac().withProcessors(new Processor()).compile(nonSerializableMethod);
+
+    assertThat(compilation)
+        .hadErrorContaining(CACHEABLE_METHOD_RETURNS_NON_SERIALIZABLE_ERROR)
+        .inFile(nonSerializableMethod);
+  }
+
+  @Test
+  public void cacheableMethodReturnSerializable_compiles() {
+    JavaFileObject serializableReturnMethod =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesType",
+            "package " + NOTES_PACKAGE + ";",
+            "import android.content.Intent;",
+            "import com.google.android.enterprise.connectedapps.annotations.Cacheable;",
+            "import " + annotationStrings.crossProfileQualifiedName() + ";",
+            "public final class NotesType {",
+            annotationStrings.crossProfileAsAnnotation(),
+            "@Cacheable",
+            "public SerializableObject refreshNotes() {",
+            "return null;",
+            "  }",
+            "}");
+
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(serializableReturnMethod, SERIALIZABLE_OBJECT);
+
+    assertThat(compilation).succeededWithoutWarnings();
+  }
+
+  @Test
+  public void cacheableMethodReturnsFutureWithSerializableResult_compiles() {
+    JavaFileObject futureWithSerializableReturnMethod =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesType",
+            "package " + NOTES_PACKAGE + ";",
+            "import com.google.android.enterprise.connectedapps.annotations.Cacheable;",
+            "import com.google.common.util.concurrent.ListenableFuture;",
+            "import " + annotationStrings.crossProfileQualifiedName() + ";",
+            "public final class NotesType {",
+            annotationStrings.crossProfileAsAnnotation(),
+            "@Cacheable",
+            "public ListenableFuture<SerializableObject> refreshNotes() {",
+            "return null;",
+            "  }",
+            "}");
+
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(futureWithSerializableReturnMethod, SERIALIZABLE_OBJECT);
+
+    assertThat(compilation).succeededWithoutWarnings();
+  }
+
+  @Test
+  public void cacheableMethodReturnsFutureWithNonSerializableResult_hasError() {
+    JavaFileObject futureWithNonSerializableReturnMethod =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesType",
+            "package " + NOTES_PACKAGE + ";",
+            "import android.content.Intent;",
+            "import com.google.android.enterprise.connectedapps.annotations.Cacheable;",
+            "import com.google.common.util.concurrent.ListenableFuture;",
+            "import " + annotationStrings.crossProfileQualifiedName() + ";",
+            "public final class NotesType {",
+            annotationStrings.crossProfileAsAnnotation(),
+            "@Cacheable",
+            "public ListenableFuture<Intent> refreshNotes() {",
+            "return null;",
+            "  }",
+            "}");
+
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(futureWithNonSerializableReturnMethod, SERIALIZABLE_OBJECT);
+
+    assertThat(compilation)
+        .hadErrorContaining(CACHEABLE_METHOD_RETURNS_NON_SERIALIZABLE_ERROR)
+        .inFile(futureWithNonSerializableReturnMethod);
+  }
+
+  @Test
+  public void cacheableMethodHasSimpleCallbackParameter_compiles() {
+    JavaFileObject simpleCallbackParameterMethod =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesType",
+            "package " + NOTES_PACKAGE + ";",
+            "import android.content.Intent;",
+            "import com.google.android.enterprise.connectedapps.annotations.Cacheable;",
+            "import " + annotationStrings.crossProfileQualifiedName() + ";",
+            "public final class NotesType {",
+            annotationStrings.crossProfileAsAnnotation(),
+            "@Cacheable",
+            "public void refreshNotes(" + INSTALLATION_LISTENER_NAME + " a) {",
+            "  }",
+            "}");
+
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(
+                simpleCallbackParameterMethod,
+                installationListenerSimpleWithStringParam(annotationStrings));
+
+    assertThat(compilation).succeededWithoutWarnings();
+  }
+
+  @Test
+  public void cacheableMethodHasNonSimpleCallbackParameter_hasError() {
+    JavaFileObject nonSimpleCallbackParameterMethod =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesType",
+            "package " + NOTES_PACKAGE + ";",
+            "import com.google.android.enterprise.connectedapps.annotations.Cacheable;",
+            "import " + annotationStrings.crossProfileQualifiedName() + ";",
+            "public final class NotesType {",
+            annotationStrings.crossProfileAsAnnotation(),
+            "@Cacheable",
+            "public void refreshNotes(" + INSTALLATION_LISTENER_NAME + " a) {",
+            "  }",
+            "}");
+
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(nonSimpleCallbackParameterMethod, installationListener(annotationStrings));
+
+    assertThat(compilation)
+        .hadErrorContaining(CACHEABLE_METHOD_NON_SIMPLE_CALLBACK_ERROR)
+        .inFile(nonSimpleCallbackParameterMethod);
+  }
+
+  @Test
+  public void cacheableMethodHasCallbackWithNonSerializableParameter_hasError() {
+    JavaFileObject simpleCallbackParameterMethod =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesType",
+            "package " + NOTES_PACKAGE + ";",
+            "import com.google.android.enterprise.connectedapps.annotations.Cacheable;",
+            "import " + annotationStrings.crossProfileQualifiedName() + ";",
+            "public final class NotesType {",
+            annotationStrings.crossProfileAsAnnotation(),
+            "@Cacheable",
+            "public void refreshNotes(" + INSTALLATION_LISTENER_NAME + " a) {",
+            "  }",
+            "}");
+
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(
+                simpleCallbackParameterMethod,
+                installationListenerSimpleWithIntentParam(annotationStrings));
+
+    assertThat(compilation)
+        .hadErrorContaining(CACHEABLE_METHOD_USES_INVALID_PARAMETERS_ERROR)
+        .inFile(simpleCallbackParameterMethod);
+  }
+
+  @Test
+  public void cacheableMethodHasCallbackWithNoParameters_hasError() {
+    JavaFileObject simpleCallbackParameterMethod =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesType",
+            "package " + NOTES_PACKAGE + ";",
+            "import com.google.android.enterprise.connectedapps.annotations.Cacheable;",
+            "import " + annotationStrings.crossProfileQualifiedName() + ";",
+            "public final class NotesType {",
+            annotationStrings.crossProfileAsAnnotation(),
+            "@Cacheable",
+            "public void refreshNotes(" + INSTALLATION_LISTENER_NAME + " a) {",
+            "  }",
+            "}");
+
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(simpleCallbackParameterMethod, installationListenerSimple(annotationStrings));
+
+    assertThat(compilation)
+        .hadErrorContaining(CACHEABLE_METHOD_USES_INVALID_PARAMETERS_ERROR)
+        .inFile(simpleCallbackParameterMethod);
+  }
+}
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileCallbackSupportedParameterTypeTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileCallbackSupportedParameterTypeTest.java
index 136fa36..b5aa559 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileCallbackSupportedParameterTypeTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileCallbackSupportedParameterTypeTest.java
@@ -83,7 +83,8 @@
       "java.util.List<com.google.protos.connectedappssdk.TestProtoOuterClass.TestProto>",
       "com.google.common.collect.ImmutableMap<String, String>",
       "android.util.Pair<String, Integer>",
-      "com.google.common.base.Optional<ParcelableObject>"
+      "com.google.common.base.Optional<ParcelableObject>",
+      "android.graphics.drawable.Drawable"
     };
     return combineParameters(AnnotationFinder.annotationStrings(), Arrays.asList(types));
   }
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileCallbackTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileCallbackTest.java
index adc1c3d..c733045 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileCallbackTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileCallbackTest.java
@@ -693,7 +693,7 @@
                 installationListenerWithStringParam(annotationStrings));
 
     assertThat(compilation)
-        .generatedSourceFile("com.google.android.enterprise.notes.ProfileNotesType_Bundler")
+        .generatedSourceFile("com.google.android.enterprise.notes.NotesType_Bundler")
         .contentsAsUtf8String()
         .contains(".writeString");
   }
@@ -729,22 +729,22 @@
             .compile(notesType, annotatedNotesProvider(annotationStrings), installationListener);
 
     assertThat(compilation)
-        .generatedSourceFile("com.google.android.enterprise.notes.ProfileNotesType_Bundler")
+        .generatedSourceFile("com.google.android.enterprise.notes.NotesType_Bundler")
         .contentsAsUtf8String()
         .contains(".writeString");
 
     assertThat(compilation)
-        .generatedSourceFile("com.google.android.enterprise.notes.ProfileNotesType_Bundler")
+        .generatedSourceFile("com.google.android.enterprise.notes.NotesType_Bundler")
         .contentsAsUtf8String()
         .contains(".writeFloat");
 
     assertThat(compilation)
-        .generatedSourceFile("com.google.android.enterprise.notes.ProfileNotesType_Bundler")
+        .generatedSourceFile("com.google.android.enterprise.notes.NotesType_Bundler")
         .contentsAsUtf8String()
         .contains(".writeInt"); // used for Boolean
 
     assertThat(compilation)
-        .generatedSourceFile("com.google.android.enterprise.notes.ProfileNotesType_Bundler")
+        .generatedSourceFile("com.google.android.enterprise.notes.NotesType_Bundler")
         .contentsAsUtf8String()
         .contains(".writeByte");
   }
@@ -771,7 +771,7 @@
                 installationListenerWithListStringParam(annotationStrings));
 
     assertThat(compilation)
-        .generatedSourceFile("com.google.android.enterprise.notes.ProfileNotesType_Bundler")
+        .generatedSourceFile("com.google.android.enterprise.notes.NotesType_Bundler")
         .contentsAsUtf8String()
         .contains("ParcelableList");
   }
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileProviderClassTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileProviderClassTest.java
index 08c19a2..fc6a106 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileProviderClassTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileProviderClassTest.java
@@ -314,7 +314,7 @@
         javac().withProcessors(new Processor()).compile(notesProvider, staticType);
 
     assertThat(compilation)
-        .generatedSourceFile("com.google.android.enterprise.notes.ProfileStaticType_Bundler")
+        .generatedSourceFile("com.google.android.enterprise.notes.StaticType_Bundler")
         .contentsAsUtf8String()
         .contains("parcel.writeString(");
   }
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileSupportedParameterTypeTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileSupportedParameterTypeTest.java
index 0b4f7f1..325165f 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileSupportedParameterTypeTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileSupportedParameterTypeTest.java
@@ -49,22 +49,39 @@
   public static Iterable<Object[]> data() {
     String[] types = {
       "String",
+      "CharSequence",
       "String[]",
       "byte",
+      "byte[]",
+      "byte[][][]",
       "Byte",
       "short",
+      "short[]",
+      "short[][][]",
       "Short",
       "int",
+      "int[]",
+      "int[][][]",
       "Integer",
       "long",
+      "long[]",
+      "long[][][]",
       "Long",
       "float",
+      "float[]",
+      "float[][][]",
       "Float",
       "double",
+      "double[]",
+      "double[][][]",
       "Double",
       "char",
+      "char[]",
+      "char[][][]",
       "Character",
       "boolean",
+      "boolean[]",
+      "boolean[][][]",
       "Boolean",
       "ParcelableObject",
       "ParcelableObject[]",
@@ -89,7 +106,9 @@
       "com.google.common.collect.ImmutableMap<String, String>",
       "android.util.Pair<String, Integer>",
       "android.graphics.Bitmap",
-      "android.content.Context"
+      "android.content.Context",
+      "android.os.Parcelable",
+      "android.graphics.drawable.Drawable"
     };
     return combineParameters(AnnotationFinder.annotationStrings(), Arrays.asList(types));
   }
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileSupportedReturnTypeTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileSupportedReturnTypeTest.java
index 9335557..7c5cebb 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileSupportedReturnTypeTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileSupportedReturnTypeTest.java
@@ -50,22 +50,39 @@
         new TypeWithReturnValue[] {
           TypeWithReturnValue.referenceType("Void"),
           TypeWithReturnValue.referenceType("String"),
+          TypeWithReturnValue.referenceType("CharSequence"),
           TypeWithReturnValue.referenceType("String[]"),
           TypeWithReturnValue.primitiveType("byte", "0"),
+          TypeWithReturnValue.referenceType("byte[]"),
+          TypeWithReturnValue.referenceType("byte[][][]"),
           TypeWithReturnValue.referenceType("Byte"),
           TypeWithReturnValue.primitiveType("short", "0"),
+          TypeWithReturnValue.referenceType("short[]"),
+          TypeWithReturnValue.referenceType("short[][][]"),
           TypeWithReturnValue.referenceType("Short"),
           TypeWithReturnValue.primitiveType("int", "0"),
+          TypeWithReturnValue.referenceType("int[]"),
+          TypeWithReturnValue.referenceType("int[][][]"),
           TypeWithReturnValue.referenceType("Integer"),
           TypeWithReturnValue.primitiveType("long", "0"),
+          TypeWithReturnValue.referenceType("long[]"),
+          TypeWithReturnValue.referenceType("long[][][]"),
           TypeWithReturnValue.referenceType("Long"),
           TypeWithReturnValue.primitiveType("float", "0"),
+          TypeWithReturnValue.referenceType("float[]"),
+          TypeWithReturnValue.referenceType("float[][][]"),
           TypeWithReturnValue.referenceType("Float"),
           TypeWithReturnValue.primitiveType("double", "0"),
+          TypeWithReturnValue.referenceType("double[]"),
+          TypeWithReturnValue.referenceType("double[][][]"),
           TypeWithReturnValue.referenceType("Double"),
           TypeWithReturnValue.primitiveType("char", "'a'"),
+          TypeWithReturnValue.referenceType("char[]"),
+          TypeWithReturnValue.referenceType("char[][][]"),
           TypeWithReturnValue.referenceType("Character"),
           TypeWithReturnValue.primitiveType("boolean", "false"),
+          TypeWithReturnValue.referenceType("boolean[]"),
+          TypeWithReturnValue.referenceType("boolean[][][]"),
           TypeWithReturnValue.referenceType("Boolean"),
           TypeWithReturnValue.referenceType("ParcelableObject"),
           TypeWithReturnValue.referenceType("ParcelableObject[]"),
@@ -102,6 +119,8 @@
           TypeWithReturnValue.referenceType("android.util.Pair<String, Integer>"),
           TypeWithReturnValue.referenceType("com.google.common.base.Optional<ParcelableObject>"),
           TypeWithReturnValue.referenceType("android.graphics.Bitmap"),
+          TypeWithReturnValue.referenceType("android.os.Parcelable"),
+          TypeWithReturnValue.referenceType("android.graphics.drawable.Drawable")
         };
     return combineParameters(
         AnnotationFinder.annotationStrings(), Arrays.asList(typesWithReturnValues));
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileTest.java
index eecccac..ef36682 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileTest.java
@@ -64,9 +64,6 @@
       "@CROSS_PROFILE_ANNOTATION annotations on methods can not specify a connector";
   private static final String METHOD_PARCELABLE_WRAPPERS_ERROR =
       "@CROSS_PROFILE_ANNOTATION annotations on methods can not specify parcelable wrappers";
-  private static final String METHOD_CLASSNAME_ERROR =
-      "@CROSS_PROFILE_ANNOTATION annotations on methods can not specify a profile class name";
-  private static final String INVALID_TIMEOUT_MILLIS = "timeoutMillis must be positive";
   private static final String ASYNC_DECLARED_EXCEPTION_ERROR =
       "Asynchronous methods annotated @CROSS_PROFILE_ANNOTATION cannot declare exceptions";
   private static final String PARCELABLE_WRAPPER_ANNOTATION_ERROR =
@@ -894,53 +891,6 @@
   }
 
   @Test
-  public void crossProfileMethodWithPrimitiveArrayParameterType_hasError() {
-    JavaFileObject notesType =
-        JavaFileObjects.forSourceLines(
-            NOTES_PACKAGE + ".NotesType",
-            "package " + NOTES_PACKAGE + ";",
-            "import " + annotationStrings.crossProfileQualifiedName() + ";",
-            "public final class NotesType {",
-            annotationStrings.crossProfileAsAnnotation(),
-            "  public void refreshNotes(int[] i) {",
-            "  }",
-            "}");
-
-    Compilation compilation =
-        javac()
-            .withProcessors(new Processor())
-            .compile(notesType, annotatedNotesProvider(annotationStrings));
-
-    assertThat(compilation)
-        .hadErrorContaining(formatErrorMessage(UNSUPPORTED_PARAMETER_TYPE_ERROR, annotationStrings))
-        .inFile(notesType);
-  }
-
-  @Test
-  public void crossProfileMethodWithPrimitiveArrayReturnType_hasError() {
-    JavaFileObject notesType =
-        JavaFileObjects.forSourceLines(
-            NOTES_PACKAGE + ".NotesType",
-            "package " + NOTES_PACKAGE + ";",
-            "import " + annotationStrings.crossProfileQualifiedName() + ";",
-            "public final class NotesType {",
-            annotationStrings.crossProfileAsAnnotation(),
-            "  public int[] refreshNotes() {",
-            "    return null;",
-            "  }",
-            "}");
-
-    Compilation compilation =
-        javac()
-            .withProcessors(new Processor())
-            .compile(notesType, annotatedNotesProvider(annotationStrings));
-
-    assertThat(compilation)
-        .hadErrorContaining(formatErrorMessage(UNSUPPORTED_RETURN_TYPE_ERROR, annotationStrings))
-        .inFile(notesType);
-  }
-
-  @Test
   public void crossProfileMethodWithMultiDimensionalArrayParameterType_hasError() {
     JavaFileObject notesType =
         JavaFileObjects.forSourceLines(
@@ -1040,30 +990,6 @@
   }
 
   @Test
-  public void specifyProfileClassNameOnMethodAnnotation_hasError() {
-    JavaFileObject notesType =
-        JavaFileObjects.forSourceLines(
-            NOTES_PACKAGE + ".NotesType",
-            "package " + NOTES_PACKAGE + ";",
-            "import " + annotationStrings.crossProfileQualifiedName() + ";",
-            "public final class NotesType {",
-            annotationStrings.crossProfileAsAnnotation(
-                "profileClassName=\"" + NOTES_PACKAGE + ".ProfileNotes\""),
-            "  public void refreshNotes() {",
-            "  }",
-            "}");
-
-    Compilation compilation =
-        javac()
-            .withProcessors(new Processor())
-            .compile(notesType, annotatedNotesProvider(annotationStrings));
-
-    assertThat(compilation)
-        .hadErrorContaining(formatErrorMessage(METHOD_CLASSNAME_ERROR, annotationStrings))
-        .inFile(notesType);
-  }
-
-  @Test
   public void crossProfileInterface_works() {
     JavaFileObject notesType =
         JavaFileObjects.forSourceLines(
@@ -1093,83 +1019,6 @@
   }
 
   @Test
-  public void crossProfile_specifiesValidTimeoutMillisAndAlsoOnType_compiles() {
-    JavaFileObject crossProfileType =
-        JavaFileObjects.forSourceLines(
-            NOTES_PACKAGE + ".NotesType",
-            "package " + NOTES_PACKAGE + ";",
-            "import " + annotationStrings.crossProfileQualifiedName() + ";",
-            annotationStrings.crossProfileAsAnnotation("timeoutMillis=30"),
-            "public final class NotesType {",
-            annotationStrings.crossProfileAsAnnotation("timeoutMillis=10"),
-            "  public void refreshNotes() {",
-            "  }",
-            "}");
-
-    Compilation compilation = javac().withProcessors(new Processor()).compile(crossProfileType);
-
-    assertThat(compilation).succeededWithoutWarnings();
-  }
-
-  @Test
-  public void crossProfile_specifiesValidTimeoutMillis_compiles() {
-    JavaFileObject crossProfileType =
-        JavaFileObjects.forSourceLines(
-            NOTES_PACKAGE + ".NotesType",
-            "package " + NOTES_PACKAGE + ";",
-            "import " + annotationStrings.crossProfileQualifiedName() + ";",
-            "public final class NotesType {",
-            annotationStrings.crossProfileAsAnnotation("timeoutMillis=10"),
-            "  public void refreshNotes() {",
-            "  }",
-            "}");
-
-    Compilation compilation = javac().withProcessors(new Processor()).compile(crossProfileType);
-
-    assertThat(compilation).succeededWithoutWarnings();
-  }
-
-  @Test
-  public void crossProfile_specifiesNegativeTimeoutMillis_hasError() {
-    JavaFileObject crossProfileType =
-        JavaFileObjects.forSourceLines(
-            NOTES_PACKAGE + ".NotesType",
-            "package " + NOTES_PACKAGE + ";",
-            "import " + annotationStrings.crossProfileQualifiedName() + ";",
-            "public final class NotesType {",
-            annotationStrings.crossProfileAsAnnotation("timeoutMillis=-10"),
-            "  public void refreshNotes() {",
-            "  }",
-            "}");
-
-    Compilation compilation = javac().withProcessors(new Processor()).compile(crossProfileType);
-
-    assertThat(compilation)
-        .hadErrorContaining(formatErrorMessage(INVALID_TIMEOUT_MILLIS, annotationStrings))
-        .inFile(crossProfileType);
-  }
-
-  @Test
-  public void crossProfileType_specifiesZeroTimeoutMillis_hasError() {
-    JavaFileObject crossProfileType =
-        JavaFileObjects.forSourceLines(
-            NOTES_PACKAGE + ".NotesType",
-            "package " + NOTES_PACKAGE + ";",
-            "import " + annotationStrings.crossProfileQualifiedName() + ";",
-            "public final class NotesType {",
-            annotationStrings.crossProfileAsAnnotation("timeoutMillis=0"),
-            "  public void refreshNotes() {",
-            "  }",
-            "}");
-
-    Compilation compilation = javac().withProcessors(new Processor()).compile(crossProfileType);
-
-    assertThat(compilation)
-        .hadErrorContaining(formatErrorMessage(INVALID_TIMEOUT_MILLIS, annotationStrings))
-        .inFile(crossProfileType);
-  }
-
-  @Test
   public void crossProfileMethod_synchronous_declaresException_compiles() {
     JavaFileObject crossProfileType =
         JavaFileObjects.forSourceLines(
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileTypeTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileTypeTest.java
index cb692ff..4b30a11 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileTypeTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossProfileTypeTest.java
@@ -57,8 +57,7 @@
   private static final String NON_PUBLIC_CLASS_ERROR =
       "@CROSS_PROFILE_ANNOTATION types must be public";
   private static final String CONNECTOR_MUST_EXTEND_CONNECTOR =
-      "Interfaces specified as a connector must extend ProfileConnector";
-  private static final String INVALID_TIMEOUT_MILLIS = "timeoutMillis must be positive";
+      "Interfaces specified as a connector must extend ProfileConnector or UserConnector";
   private static final String CONNECTOR_MUST_BE_INTERFACE = "Connectors must be interfaces";
   private static final String NOT_STATIC_ERROR =
       "Types annotated @CROSS_PROFILE_ANNOTATION(isStatic=true) must not contain any non-static"
@@ -159,63 +158,6 @@
   }
 
   @Test
-  public void crossProfileType_specifiesValidTimeoutMillis_compiles() {
-    JavaFileObject crossProfileType =
-        JavaFileObjects.forSourceLines(
-            NOTES_PACKAGE + ".NotesType",
-            "package " + NOTES_PACKAGE + ";",
-            "import " + annotationStrings.crossProfileQualifiedName() + ";",
-            annotationStrings.crossProfileAsAnnotation("timeoutMillis=10"),
-            "public final class NotesType {",
-            annotationStrings.crossProfileAsAnnotation(),
-            "  public void refreshNotes() {",
-            "  }",
-            "}");
-
-    Compilation compilation = javac().withProcessors(new Processor()).compile(crossProfileType);
-
-    assertThat(compilation).succeededWithoutWarnings();
-  }
-
-  @Test
-  public void crossProfileType_specifiesNegativeTimeoutMillis_hasError() {
-    JavaFileObject crossProfileType =
-        JavaFileObjects.forSourceLines(
-            NOTES_PACKAGE + ".NotesType",
-            "package " + NOTES_PACKAGE + ";",
-            "import " + annotationStrings.crossProfileQualifiedName() + ";",
-            annotationStrings.crossProfileAsAnnotation("timeoutMillis=-10"),
-            "public final class NotesType {",
-            annotationStrings.crossProfileAsAnnotation(),
-            "  public void refreshNotes() {",
-            "  }",
-            "}");
-
-    Compilation compilation = javac().withProcessors(new Processor()).compile(crossProfileType);
-
-    assertThat(compilation).hadErrorContaining(INVALID_TIMEOUT_MILLIS).inFile(crossProfileType);
-  }
-
-  @Test
-  public void crossProfileType_specifiesZeroTimeoutMillis_hasError() {
-    JavaFileObject crossProfileType =
-        JavaFileObjects.forSourceLines(
-            NOTES_PACKAGE + ".NotesType",
-            "package " + NOTES_PACKAGE + ";",
-            "import " + annotationStrings.crossProfileQualifiedName() + ";",
-            annotationStrings.crossProfileAsAnnotation("timeoutMillis=0"),
-            "public final class NotesType {",
-            annotationStrings.crossProfileAsAnnotation(),
-            "  public void refreshNotes() {",
-            "  }",
-            "}");
-
-    Compilation compilation = javac().withProcessors(new Processor()).compile(crossProfileType);
-
-    assertThat(compilation).hadErrorContaining(INVALID_TIMEOUT_MILLIS).inFile(crossProfileType);
-  }
-
-  @Test
   public void crossProfileType_specifiesNotInterfaceConnector_hasError() {
     JavaFileObject crossProfileType =
         JavaFileObjects.forSourceLines(
@@ -244,7 +186,7 @@
   }
 
   @Test
-  public void crossProfileType_specifiesConnectorNotExtendingProfileConnector_hasError() {
+  public void crossProfileType_specifiesConnectorNotExtendingConnectorInterface_hasError() {
     JavaFileObject crossProfileType =
         JavaFileObjects.forSourceLines(
             NOTES_PACKAGE + ".NotesType",
@@ -291,29 +233,6 @@
   }
 
   @Test
-  public void specifiesAlternativeProfileClassName_generatesCorrectClass() {
-    JavaFileObject crossProfileType =
-        JavaFileObjects.forSourceLines(
-            NOTES_PACKAGE + ".NotesType",
-            "package " + NOTES_PACKAGE + ";",
-            "import " + annotationStrings.crossProfileQualifiedName() + ";",
-            annotationStrings.crossProfileAsAnnotation(
-                "profileClassName=\"" + NOTES_PACKAGE + ".CrossProfileNotes\""),
-            "public final class NotesType {",
-            annotationStrings.crossProfileAsAnnotation(),
-            "  public void refreshNotes() {",
-            "  }",
-            "}");
-
-    Compilation compilation =
-        javac()
-            .withProcessors(new Processor())
-            .compile(crossProfileType, annotatedNotesProvider(annotationStrings));
-
-    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".CrossProfileNotes");
-  }
-
-  @Test
   public void isStaticContainsNoNonStaticMethods_compiles() {
     JavaFileObject notesType =
         JavaFileObjects.forSourceLines(
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossUserInterfaceTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossUserInterfaceTest.java
new file mode 100644
index 0000000..af44fca
--- /dev/null
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossUserInterfaceTest.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.processor;
+
+import static com.google.android.enterprise.connectedapps.processor.TestUtilities.NOTES_PACKAGE;
+import static com.google.android.enterprise.connectedapps.processor.TestUtilities.annotatedNotesCrossProfileType;
+import static com.google.android.enterprise.connectedapps.processor.TestUtilities.annotatedNotesProvider;
+import static com.google.testing.compile.CompilationSubject.assertThat;
+import static com.google.testing.compile.Compiler.javac;
+
+import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationFinder;
+import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationPrinter;
+import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationStrings;
+import com.google.testing.compile.Compilation;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class CrossUserInterfaceTest {
+
+  private final AnnotationPrinter annotationPrinter;
+
+  public CrossUserInterfaceTest(AnnotationPrinter annotationPrinter) {
+    this.annotationPrinter = annotationPrinter;
+  }
+
+  @Parameters(name = "{0}")
+  public static Iterable<AnnotationStrings> getAnnotationPrinters() {
+    return AnnotationFinder.annotationStrings();
+  }
+
+  @Test
+  public void compile_generatesCurrentMethod() {
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(
+                annotatedNotesProvider(annotationPrinter),
+                annotatedNotesCrossProfileType(annotationPrinter));
+
+    assertThat(compilation)
+        .generatedSourceFile(NOTES_PACKAGE + ".UserNotesType")
+        .contentsAsUtf8String()
+        .contains("NotesType_SingleSender current()");
+  }
+
+  @Test
+  public void compile_generatesUserMethod() {
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(
+                annotatedNotesProvider(annotationPrinter),
+                annotatedNotesCrossProfileType(annotationPrinter));
+
+    assertThat(compilation)
+        .generatedSourceFile(NOTES_PACKAGE + ".UserNotesType")
+        .contentsAsUtf8String()
+        .contains("NotesType_SingleSenderCanThrow user(UserHandle userHandle)");
+  }
+
+  @Test
+  public void compile_generatesDefaultImplementation() {
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(
+                annotatedNotesProvider(annotationPrinter),
+                annotatedNotesCrossProfileType(annotationPrinter));
+
+    assertThat(compilation)
+        .generatedSourceFile(NOTES_PACKAGE + ".DefaultUserNotesType")
+        .contentsAsUtf8String()
+        .contains("class DefaultUserNotesType implements UserNotesType");
+  }
+}
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossUserTestTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossUserTestTest.java
new file mode 100644
index 0000000..23144bd
--- /dev/null
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/CrossUserTestTest.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.processor;
+
+import static com.google.android.enterprise.connectedapps.processor.TestUtilities.NOTES_PACKAGE;
+import static com.google.android.enterprise.connectedapps.processor.TestUtilities.annotatedNotesConfigurationWithNotesProviderAndCrossUserConnector;
+import static com.google.android.enterprise.connectedapps.processor.TestUtilities.annotatedNotesProvider;
+import static com.google.android.enterprise.connectedapps.processor.TestUtilities.notesTypeWithDefaultConnector;
+import static com.google.testing.compile.CompilationSubject.assertThat;
+import static com.google.testing.compile.Compiler.javac;
+
+import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationFinder;
+import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationPrinter;
+import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.AnnotationStrings;
+import com.google.testing.compile.Compilation;
+import com.google.testing.compile.JavaFileObjects;
+import javax.tools.JavaFileObject;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class CrossUserTestTest {
+
+  private final AnnotationPrinter annotationPrinter;
+
+  public CrossUserTestTest(AnnotationPrinter annotationPrinter) {
+    this.annotationPrinter = annotationPrinter;
+  }
+
+  @Parameters(name = "{0}")
+  public static Iterable<AnnotationStrings> getAnnotationPrinters() {
+    return AnnotationFinder.annotationStrings();
+  }
+
+  @Test
+  public void generatesFakeCrossUserConnector() {
+    JavaFileObject crossUserTest =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesTest",
+            "package " + NOTES_PACKAGE + ";",
+            "import " + annotationPrinter.crossProfileTestQualifiedName() + ";",
+            annotationPrinter.crossProfileTestAsAnnotation(
+                "configuration=NotesConfiguration.class"),
+            "public final class NotesTest {",
+            "}");
+
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(
+                crossUserTest,
+                annotatedNotesConfigurationWithNotesProviderAndCrossUserConnector(
+                    annotationPrinter),
+                annotatedNotesProvider(annotationPrinter),
+                notesTypeWithDefaultConnector(annotationPrinter));
+
+    assertThat(compilation)
+        .generatedSourceFile("com.google.android.enterprise.connectedapps.FakeCrossUserConnector");
+  }
+
+  @Test
+  public void fakeCrossUserConnector_extendsAbstractFakeUserConnector() {
+    JavaFileObject crossUserTest =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesTest",
+            "package " + NOTES_PACKAGE + ";",
+            "import " + annotationPrinter.crossProfileTestQualifiedName() + ";",
+            annotationPrinter.crossProfileTestAsAnnotation(
+                "configuration=NotesConfiguration.class"),
+            "public final class NotesTest {",
+            "}");
+
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(
+                crossUserTest,
+                annotatedNotesConfigurationWithNotesProviderAndCrossUserConnector(
+                    annotationPrinter),
+                annotatedNotesProvider(annotationPrinter),
+                notesTypeWithDefaultConnector(annotationPrinter));
+
+    assertThat(compilation)
+        .generatedSourceFile("com.google.android.enterprise.connectedapps.FakeCrossUserConnector")
+        .contentsAsUtf8String()
+        .contains("extends AbstractFakeUserConnector");
+  }
+
+  @Test
+  public void fakeCrossUserConnector_implementsConnector() {
+    JavaFileObject crossUserTest =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesTest",
+            "package " + NOTES_PACKAGE + ";",
+            "import " + annotationPrinter.crossProfileTestQualifiedName() + ";",
+            annotationPrinter.crossProfileTestAsAnnotation(
+                "configuration=NotesConfiguration.class"),
+            "public final class NotesTest {",
+            "}");
+
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(
+                crossUserTest,
+                annotatedNotesConfigurationWithNotesProviderAndCrossUserConnector(
+                    annotationPrinter),
+                annotatedNotesProvider(annotationPrinter),
+                notesTypeWithDefaultConnector(annotationPrinter));
+
+    assertThat(compilation)
+        .generatedSourceFile("com.google.android.enterprise.connectedapps.FakeCrossUserConnector")
+        .contentsAsUtf8String()
+        .contains("implements CrossUserConnector");
+  }
+
+  @Test
+  public void generatesFakeCrossUserClass() {
+    JavaFileObject crossUserTest =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesTest",
+            "package " + NOTES_PACKAGE + ";",
+            "import " + annotationPrinter.crossProfileTestQualifiedName() + ";",
+            annotationPrinter.crossProfileTestAsAnnotation(
+                "configuration=NotesConfiguration.class"),
+            "public final class NotesTest {",
+            "}");
+
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(
+                crossUserTest,
+                annotatedNotesConfigurationWithNotesProviderAndCrossUserConnector(
+                    annotationPrinter),
+                annotatedNotesProvider(annotationPrinter),
+                notesTypeWithDefaultConnector(annotationPrinter));
+
+    assertThat(compilation)
+        .generatedSourceFile(NOTES_PACKAGE + ".FakeUserNotesType")
+        .contentsAsUtf8String()
+        .contains("class FakeUserNotesType implements UserNotesType");
+  }
+}
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/DispatcherTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/DispatcherTest.java
index 1ce6023..48dd37e 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/DispatcherTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/DispatcherTest.java
@@ -18,6 +18,7 @@
 import static com.google.android.enterprise.connectedapps.processor.TestUtilities.CUSTOM_PROFILE_CONNECTOR_QUALIFIED_NAME;
 import static com.google.android.enterprise.connectedapps.processor.TestUtilities.NOTES_PACKAGE;
 import static com.google.android.enterprise.connectedapps.processor.TestUtilities.PROFILE_CONNECTOR_QUALIFIED_NAME;
+import static com.google.android.enterprise.connectedapps.processor.TestUtilities.UNCAUGHT_EXCEPTIONS_POLICY_QUALIFIED_NAME;
 import static com.google.android.enterprise.connectedapps.processor.TestUtilities.annotatedNotesConfigurationWithNotesProvider;
 import static com.google.android.enterprise.connectedapps.processor.TestUtilities.annotatedNotesCrossProfileType;
 import static com.google.android.enterprise.connectedapps.processor.TestUtilities.annotatedNotesProvider;
@@ -39,9 +40,31 @@
 public class DispatcherTest {
 
   private final AnnotationPrinter annotationPrinter;
+  private final JavaFileObject notesConfiguration;
+  private final JavaFileObject notesCrossProfileType;
 
   public DispatcherTest(AnnotationPrinter annotationPrinter) {
     this.annotationPrinter = annotationPrinter;
+    this.notesConfiguration =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesConfiguration",
+            "package " + NOTES_PACKAGE + ";",
+            "import " + annotationPrinter.crossProfileConfigurationQualifiedName() + ";",
+            annotationPrinter.crossProfileConfigurationAsAnnotation(
+                "providers=NotesProvider.class"),
+            "public abstract class NotesConfiguration {",
+            "}");
+    this.notesCrossProfileType =
+        JavaFileObjects.forSourceLines(
+            NOTES_PACKAGE + ".NotesType",
+            "package " + NOTES_PACKAGE + ";",
+            "import " + annotationPrinter.crossProfileQualifiedName() + ";",
+            annotationPrinter.crossProfileAsAnnotation("connector=NotesConnector.class"),
+            "public final class NotesType {",
+            annotationPrinter.crossProfileAsAnnotation(),
+            "  public void refreshNotes() {",
+            "  }",
+            "}");
   }
 
   @Parameters(name = "{0}")
@@ -66,45 +89,83 @@
 
   @Test
   public void specifiedClassName_generatesSpecifiedClassNameDispatcher() {
-    JavaFileObject notesConfiguration =
-        JavaFileObjects.forSourceLines(
-            NOTES_PACKAGE + ".NotesConfiguration",
-            "package " + NOTES_PACKAGE + ";",
-            "import " + annotationPrinter.crossProfileConfigurationQualifiedName() + ";",
-            annotationPrinter.crossProfileConfigurationAsAnnotation(
-                "providers=NotesProvider.class"),
-            "public abstract class NotesConfiguration {",
-            "}");
-    JavaFileObject crossProfileType =
-        JavaFileObjects.forSourceLines(
-            NOTES_PACKAGE + ".NotesType",
-            "package " + NOTES_PACKAGE + ";",
-            "import " + annotationPrinter.crossProfileQualifiedName() + ";",
-            annotationPrinter.crossProfileAsAnnotation("connector=NotesConnector.class"),
-            "public final class NotesType {",
-            annotationPrinter.crossProfileAsAnnotation(),
-            "  public void refreshNotes() {",
-            "  }",
-            "}");
-    JavaFileObject notesConnector =
-        JavaFileObjects.forSourceLines(
-            NOTES_PACKAGE + ".NotesConnector",
-            "package " + NOTES_PACKAGE + ";",
-            "import " + CUSTOM_PROFILE_CONNECTOR_QUALIFIED_NAME + ";",
-            "import " + PROFILE_CONNECTOR_QUALIFIED_NAME + ";",
-            "@CustomProfileConnector(serviceClassName=\"com.google.android.CustomConnector\")",
-            "public interface NotesConnector extends ProfileConnector {",
-            "}");
-
     Compilation compilation =
         javac()
             .withProcessors(new Processor())
             .compile(
                 notesConfiguration,
                 annotatedNotesProvider(annotationPrinter),
-                notesConnector,
-                crossProfileType);
+                buildNotesConnector("serviceClassName=\"com.google.android.CustomConnector\""),
+                notesCrossProfileType);
 
     assertThat(compilation).generatedSourceFile("com.google.android.CustomConnector_Dispatcher");
   }
+
+  @Test
+  public void whenExceptionsPolicyIsUnspecified_generatesClassWithRethrowCode() {
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(
+                notesConfiguration,
+                annotatedNotesProvider(annotationPrinter),
+                buildNotesConnector(""),
+                notesCrossProfileType);
+
+    assertThat(compilation)
+        .generatedSourceFile(
+            "com.google.android.enterprise.notes.NotesConnector_Service_Dispatcher")
+        .contentsAsUtf8String()
+        .contains("delayThrow");
+  }
+
+  @Test
+  public void whenExceptionsPolicyIsRethrow_generatesClassWithRethrowCode() {
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(
+                notesConfiguration,
+                annotatedNotesProvider(annotationPrinter),
+                buildNotesConnector(
+                    "uncaughtExceptionsPolicy=UncaughtExceptionsPolicy.NOTIFY_RETHROW"),
+                notesCrossProfileType);
+
+    assertThat(compilation)
+        .generatedSourceFile(
+            "com.google.android.enterprise.notes.NotesConnector_Service_Dispatcher")
+        .contentsAsUtf8String()
+        .contains("delayThrow");
+  }
+
+  @Test
+  public void whenExceptionsPolicyIsSuppress_generatesClassWithoutRethrowCode() {
+    Compilation compilation =
+        javac()
+            .withProcessors(new Processor())
+            .compile(
+                notesConfiguration,
+                annotatedNotesProvider(annotationPrinter),
+                buildNotesConnector(
+                    "uncaughtExceptionsPolicy=UncaughtExceptionsPolicy.NOTIFY_SUPPRESS"),
+                notesCrossProfileType);
+
+    assertThat(compilation)
+        .generatedSourceFile(
+            "com.google.android.enterprise.notes.NotesConnector_Service_Dispatcher")
+        .contentsAsUtf8String()
+        .doesNotContain("delayThrow");
+  }
+
+  private static JavaFileObject buildNotesConnector(String customProfileConnectorParams) {
+    return JavaFileObjects.forSourceLines(
+        NOTES_PACKAGE + ".NotesConnector",
+        "package " + NOTES_PACKAGE + ";",
+        "import " + CUSTOM_PROFILE_CONNECTOR_QUALIFIED_NAME + ";",
+        "import " + PROFILE_CONNECTOR_QUALIFIED_NAME + ";",
+        "import " + UNCAUGHT_EXCEPTIONS_POLICY_QUALIFIED_NAME + ";",
+        String.format("@CustomProfileConnector(%s)", customProfileConnectorParams),
+        "public interface NotesConnector extends ProfileConnector {",
+        "}");
+  }
 }
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/IfAvailableTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/IfAvailableTest.java
index 59de5e9..a4491c5 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/IfAvailableTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/IfAvailableTest.java
@@ -57,7 +57,7 @@
                 annotatedNotesProvider(annotationPrinter),
                 annotatedNotesCrossProfileType(annotationPrinter));
 
-    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_IfAvailable");
+    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".NotesType_IfAvailable");
   }
 
   @Test
@@ -79,7 +79,7 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_IfAvailable")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_IfAvailable")
         .contentsAsUtf8String()
         .contains("void refreshNotes()");
   }
@@ -104,7 +104,7 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_IfAvailable")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_IfAvailable")
         .contentsAsUtf8String()
         .contains("int refreshNotes(int defaultValue)");
   }
@@ -131,7 +131,7 @@
                 installationListener(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_IfAvailable")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_IfAvailable")
         .contentsAsUtf8String()
         .contains("void refreshNotes(InstallationListener listener)");
   }
@@ -158,7 +158,7 @@
                 installationListenerWithStringParam(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_IfAvailable")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_IfAvailable")
         .contentsAsUtf8String()
         .contains(
             "void refreshNotes(String s, InstallationListener listener, String defaultValue)");
@@ -185,7 +185,7 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_IfAvailable")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_IfAvailable")
         .contentsAsUtf8String()
         .contains("ListenableFuture<String> refreshNotes(String defaultValue)");
   }
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InterfaceTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InterfaceTest.java
index e59a306..485bb9f 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InterfaceTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InterfaceTest.java
@@ -55,7 +55,7 @@
                 annotatedNotesCrossProfileType(annotationPrinter),
                 annotatedNotesProvider(annotationPrinter));
 
-    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSender");
+    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSender");
   }
 
   @Test
@@ -77,7 +77,7 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSender")
         .contentsAsUtf8String()
         .contains("void refreshNotes()");
   }
@@ -105,12 +105,12 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSender")
         .contentsAsUtf8String()
         .contains("void refreshNotes()");
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSender")
         .contentsAsUtf8String()
         .contains("int anotherMethod(String s)");
   }
@@ -137,7 +137,7 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSender")
         .contentsAsUtf8String()
         .doesNotContain("anotherMethod");
   }
@@ -151,8 +151,7 @@
                 annotatedNotesCrossProfileType(annotationPrinter),
                 annotatedNotesProvider(annotationPrinter));
 
-    assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSenderCanThrow");
+    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSenderCanThrow");
   }
 
   @Test
@@ -174,7 +173,7 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSenderCanThrow")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSenderCanThrow")
         .contentsAsUtf8String()
         .contains("void refreshNotes() throws UnavailableProfileException");
   }
@@ -202,12 +201,12 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSenderCanThrow")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSenderCanThrow")
         .contentsAsUtf8String()
         .contains("void refreshNotes() throws UnavailableProfileException");
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSenderCanThrow")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSenderCanThrow")
         .contentsAsUtf8String()
         .contains("int anotherMethod(String s) throws UnavailableProfileException");
   }
@@ -234,7 +233,7 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSenderCanThrow")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSenderCanThrow")
         .contentsAsUtf8String()
         .doesNotContain("anotherMethod");
   }
@@ -258,9 +257,9 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSenderCanThrow")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSenderCanThrow")
         .contentsAsUtf8String()
-        .contains("ProfileNotesType_IfAvailable ifAvailable()");
+        .contains("NotesType_IfAvailable ifAvailable()");
   }
 
   @Test
@@ -272,7 +271,7 @@
                 annotatedNotesCrossProfileType(annotationPrinter),
                 annotatedNotesProvider(annotationPrinter));
 
-    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_MultipleSender");
+    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".NotesType_MultipleSender");
   }
 
   @Test
@@ -294,7 +293,7 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_MultipleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_MultipleSender")
         .contentsAsUtf8String()
         .contains("void refreshNotes()");
   }
@@ -319,7 +318,7 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_MultipleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_MultipleSender")
         .contentsAsUtf8String()
         .contains("Map<Profile, Integer> refreshNotes()");
   }
@@ -344,7 +343,7 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_MultipleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_MultipleSender")
         .contentsAsUtf8String()
         .contains("Map<Profile, String> refreshNotes()");
   }
@@ -372,12 +371,12 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_MultipleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_MultipleSender")
         .contentsAsUtf8String()
         .contains("void refreshNotes()");
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_MultipleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_MultipleSender")
         .contentsAsUtf8String()
         .contains("Map<Profile, Integer> anotherMethod(String s)");
   }
@@ -404,7 +403,7 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_MultipleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_MultipleSender")
         .contentsAsUtf8String()
         .doesNotContain("anotherMethod");
   }
@@ -429,7 +428,7 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSender")
         .contentsAsUtf8String()
         .contains("refreshNotes() throws IOException");
   }
@@ -457,15 +456,15 @@
 
     // Order is not predictable
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSender")
         .contentsAsUtf8String()
         .contains("refreshNotes() throws ");
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSender")
         .contentsAsUtf8String()
         .contains("SQLException");
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSender")
         .contentsAsUtf8String()
         .contains("IOException");
   }
@@ -491,7 +490,7 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSender")
         .contentsAsUtf8String()
         .contains("refreshNotes() throws IOException");
   }
@@ -519,15 +518,15 @@
 
     // Order is not predictable
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSender")
         .contentsAsUtf8String()
         .contains("refreshNotes() throws ");
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSender")
         .contentsAsUtf8String()
         .contains("SQLException");
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_SingleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_SingleSender")
         .contentsAsUtf8String()
         .contains("IOException");
   }
@@ -553,7 +552,7 @@
             .compile(notesType, annotatedNotesProvider(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_MultipleSender")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_MultipleSender")
         .contentsAsUtf8String()
         .doesNotContain("refreshNotes()");
   }
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InternalCrossProfileTypeTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InternalCrossProfileTypeTest.java
index d0af193..a68c948 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InternalCrossProfileTypeTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InternalCrossProfileTypeTest.java
@@ -53,7 +53,7 @@
                 annotatedNotesProvider(annotationPrinter),
                 annotatedNotesCrossProfileType(annotationPrinter));
 
-    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_Internal");
+    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".NotesType_Internal");
   }
 
   @Test
@@ -66,9 +66,9 @@
                 annotatedNotesCrossProfileType(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_Internal")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_Internal")
         .contentsAsUtf8String()
-        .contains("private ProfileNotesType_Internal() {");
+        .contains("private NotesType_Internal() {");
   }
 
   @Test
@@ -81,9 +81,9 @@
                 annotatedNotesCrossProfileType(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_Internal")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_Internal")
         .contentsAsUtf8String()
-        .contains("public Parcel call(Context context, int methodIdentifier, Parcel params,");
+        .contains("public Bundle call(Context context, int methodIdentifier, Bundle params,");
   }
 
   @Test
@@ -96,8 +96,8 @@
                 annotatedNotesCrossProfileType(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_Internal")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_Internal")
         .contentsAsUtf8String()
-        .contains("static ProfileNotesType_Internal instance()");
+        .contains("static NotesType_Internal instance()");
   }
 }
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InternalProviderClassTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InternalProviderClassTest.java
index 2afee1b..9d1ef94 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InternalProviderClassTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/InternalProviderClassTest.java
@@ -86,7 +86,7 @@
         .generatedSourceFile(NOTES_PACKAGE + ".Profile_NotesProvider_Internal")
         .contentsAsUtf8String()
         .contains(
-            "public Parcel call(Context context, long crossProfileTypeIdentifier, int"
+            "public Bundle call(Context context, long crossProfileTypeIdentifier, int"
                 + " methodIdentifier,");
   }
 
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/OtherProfileTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/OtherProfileTest.java
index 8d38369..e7eae98 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/OtherProfileTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/OtherProfileTest.java
@@ -53,7 +53,7 @@
                 annotatedNotesProvider(annotationPrinter),
                 annotatedNotesCrossProfileType(annotationPrinter));
 
-    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_OtherProfile");
+    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".NotesType_OtherProfile");
   }
 
   @Test
@@ -66,10 +66,9 @@
                 annotatedNotesCrossProfileType(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_OtherProfile")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_OtherProfile")
         .contentsAsUtf8String()
-        .contains(
-            "class ProfileNotesType_OtherProfile implements" + " ProfileNotesType_SingleSender");
+        .contains("class NotesType_OtherProfile implements NotesType_SingleSender");
   }
 
   @Test
@@ -82,9 +81,8 @@
                 annotatedNotesCrossProfileType(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_OtherProfile")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_OtherProfile")
         .contentsAsUtf8String()
-        .contains(
-            "public ProfileNotesType_OtherProfile(ProfileConnector connector)");
+        .contains("public NotesType_OtherProfile(ProfileConnector connector)");
   }
 }
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProcessorCrossProfileConfigurationTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProcessorCrossProfileConfigurationTest.java
index 49b797e..6ccbef0 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProcessorCrossProfileConfigurationTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProcessorCrossProfileConfigurationTest.java
@@ -49,7 +49,7 @@
           + " ProfileConnector";
   private static final String CONNECTOR_MUST_BE_INTERFACE = "Connectors must be interfaces";
   private static final String CONNECTOR_MUST_EXTEND_CONNECTOR =
-      "Interfaces specified as a connector must extend ProfileConnector";
+      "Interfaces specified as a connector must extend ProfileConnector or UserConnector";
 
   private final AnnotationStrings annotationStrings;
 
@@ -435,7 +435,7 @@
   }
 
   @Test
-  public void specifiesConnectorNotExtendingProfileConnector_hasError() {
+  public void specifiesConnectorNotExtendingConnectorInterface_hasError() {
     final JavaFileObject configuration =
         JavaFileObjects.forSourceLines(
             NOTES_PACKAGE + ".NotesConfiguration",
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProcessorCurrentProfileTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProcessorCurrentProfileTest.java
index b009f2f..6daab70 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProcessorCurrentProfileTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProcessorCurrentProfileTest.java
@@ -53,7 +53,7 @@
                 annotatedNotesProvider(annotationPrinter),
                 annotatedNotesCrossProfileType(annotationPrinter));
 
-    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_CurrentProfile");
+    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".NotesType_CurrentProfile");
   }
 
   @Test
@@ -66,10 +66,9 @@
                 annotatedNotesCrossProfileType(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_CurrentProfile")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_CurrentProfile")
         .contentsAsUtf8String()
-        .contains(
-            "class ProfileNotesType_CurrentProfile implements" + " ProfileNotesType_SingleSender");
+        .contains("class NotesType_CurrentProfile implements NotesType_SingleSender");
   }
 
   @Test
@@ -82,9 +81,8 @@
                 annotatedNotesCrossProfileType(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_CurrentProfile")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_CurrentProfile")
         .contentsAsUtf8String()
-        .contains(
-            "public ProfileNotesType_CurrentProfile(Context context, NotesType crossProfileType)");
+        .contains("public NotesType_CurrentProfile(Context context, NotesType crossProfileType)");
   }
 }
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProcessorMultipleProfilesTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProcessorMultipleProfilesTest.java
index 2f732d1..d56007f 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProcessorMultipleProfilesTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProcessorMultipleProfilesTest.java
@@ -53,8 +53,7 @@
                 annotatedNotesProvider(annotationPrinter),
                 annotatedNotesCrossProfileType(annotationPrinter));
 
-    assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_MultipleProfiles");
+    assertThat(compilation).generatedSourceFile(NOTES_PACKAGE + ".NotesType_MultipleProfiles");
   }
 
   @Test
@@ -67,11 +66,9 @@
                 annotatedNotesCrossProfileType(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_MultipleProfiles")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_MultipleProfiles")
         .contentsAsUtf8String()
-        .contains(
-            "class ProfileNotesType_MultipleProfiles implements"
-                + " ProfileNotesType_MultipleSender");
+        .contains("class NotesType_MultipleProfiles implements NotesType_MultipleSender");
   }
 
   @Test
@@ -84,13 +81,13 @@
                 annotatedNotesCrossProfileType(annotationPrinter));
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_MultipleProfiles")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_MultipleProfiles")
         .contentsAsUtf8String()
-        .contains("public ProfileNotesType_MultipleProfiles(");
+        .contains("public NotesType_MultipleProfiles(");
 
     assertThat(compilation)
-        .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType_MultipleProfiles")
+        .generatedSourceFile(NOTES_PACKAGE + ".NotesType_MultipleProfiles")
         .contentsAsUtf8String()
-        .contains("Map<Profile, ProfileNotesType_SingleSenderCanThrow>" + " senders) {");
+        .contains("Map<Profile, NotesType_SingleSenderCanThrow> senders) {");
   }
 }
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProfileInterfaceTest.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProfileInterfaceTest.java
index 7922750..41ede91 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProfileInterfaceTest.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/ProfileInterfaceTest.java
@@ -87,24 +87,24 @@
     assertThat(compilation)
         .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType")
         .contentsAsUtf8String()
-        .contains("ProfileNotesType_SingleSender current()");
+        .contains("NotesType_SingleSender current()");
     assertThat(compilation)
         .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType")
         .contentsAsUtf8String()
-        .contains("ProfileNotesType_SingleSenderCanThrow other()");
+        .contains("NotesType_SingleSenderCanThrow other()");
     assertThat(compilation)
         .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType")
         .contentsAsUtf8String()
         // We ignore the "profile" argument as it gets moved onto another line by the processor
-        .contains("ProfileNotesType_SingleSenderCanThrow profile(");
+        .contains("NotesType_SingleSenderCanThrow profile(");
     assertThat(compilation)
         .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType")
         .contentsAsUtf8String()
-        .contains("ProfileNotesType_MultipleSender both()");
+        .contains("NotesType_MultipleSender both()");
     assertThat(compilation)
         .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType")
         .contentsAsUtf8String()
-        .contains("ProfileNotesType_MultipleSender profiles(");
+        .contains("NotesType_MultipleSender profiles(");
   }
 
   @Test
@@ -179,15 +179,15 @@
     assertThat(compilation)
         .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType")
         .contentsAsUtf8String()
-        .contains("ProfileNotesType_SingleSenderCanThrow primary()");
+        .contains("NotesType_SingleSenderCanThrow primary()");
     assertThat(compilation)
         .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType")
         .contentsAsUtf8String()
-        .contains("ProfileNotesType_SingleSenderCanThrow secondary()");
+        .contains("NotesType_SingleSenderCanThrow secondary()");
     assertThat(compilation)
         .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType")
         .contentsAsUtf8String()
-        .contains("ProfileNotesType_MultipleSender suppliers()");
+        .contains("NotesType_MultipleSender suppliers()");
   }
 
   @Test
@@ -212,14 +212,14 @@
     assertThat(compilation)
         .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType")
         .contentsAsUtf8String()
-        .contains("ProfileNotesType_SingleSenderCanThrow primary()");
+        .contains("NotesType_SingleSenderCanThrow primary()");
     assertThat(compilation)
         .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType")
         .contentsAsUtf8String()
-        .contains("ProfileNotesType_SingleSenderCanThrow secondary()");
+        .contains("NotesType_SingleSenderCanThrow secondary()");
     assertThat(compilation)
         .generatedSourceFile(NOTES_PACKAGE + ".ProfileNotesType")
         .contentsAsUtf8String()
-        .contains("ProfileNotesType_MultipleSender suppliers()");
+        .contains("NotesType_MultipleSender suppliers()");
   }
 }
diff --git a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/TestUtilities.java b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/TestUtilities.java
index b1c05bf..055e22d 100644
--- a/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/TestUtilities.java
+++ b/tests/processor/src/main/java/com/google/android/enterprise/connectedapps/processor/TestUtilities.java
@@ -32,6 +32,8 @@
       "com.google.android.enterprise.connectedapps.annotations.CustomUserConnector";
   public static final String GENERATED_PROFILE_CONNECTOR_QUALIFIED_NAME =
       "com.google.android.enterprise.connectedapps.annotations.GeneratedProfileConnector";
+  public static final String UNCAUGHT_EXCEPTIONS_POLICY_QUALIFIED_NAME =
+      "com.google.android.enterprise.connectedapps.annotations.UncaughtExceptionsPolicy";
   public static final String GENERATED_USER_CONNECTOR_QUALIFIED_NAME =
       "com.google.android.enterprise.connectedapps.annotations.GeneratedUserConnector";
   public static final String PROFILE_CONNECTOR_QUALIFIED_NAME =
@@ -498,6 +500,42 @@
         "}");
   }
 
+  public static JavaFileObject installationListenerSimple(AnnotationPrinter annotationPrinter) {
+    return JavaFileObjects.forSourceLines(
+        NOTES_PACKAGE + ".InstallationListener",
+        "package " + NOTES_PACKAGE + ";",
+        "import " + annotationPrinter.crossProfileCallbackQualifiedName() + ";",
+        annotationPrinter.crossProfileCallbackAsAnnotation("simple=true"),
+        "public interface InstallationListener {",
+        "  void installationComplete();",
+        "}");
+  }
+
+  public static JavaFileObject installationListenerSimpleWithStringParam(
+      AnnotationPrinter annotationPrinter) {
+    return JavaFileObjects.forSourceLines(
+        NOTES_PACKAGE + ".InstallationListener",
+        "package " + NOTES_PACKAGE + ";",
+        "import " + annotationPrinter.crossProfileCallbackQualifiedName() + ";",
+        annotationPrinter.crossProfileCallbackAsAnnotation("simple=true"),
+        "public interface InstallationListener {",
+        "  void installationComplete(String s);",
+        "}");
+  }
+
+  public static JavaFileObject installationListenerSimpleWithIntentParam(
+      AnnotationPrinter annotationPrinter) {
+    return JavaFileObjects.forSourceLines(
+        NOTES_PACKAGE + ".InstallationListener",
+        "package " + NOTES_PACKAGE + ";",
+        "import android.content.Intent;",
+        "import " + annotationPrinter.crossProfileCallbackQualifiedName() + ";",
+        annotationPrinter.crossProfileCallbackAsAnnotation("simple=true"),
+        "public interface InstallationListener {",
+        "  void installationComplete(Intent i);",
+        "}");
+  }
+
   public static JavaFileObject installationListenerWithStringParam(
       AnnotationPrinter annotationPrinter) {
     return JavaFileObjects.forSourceLines(
@@ -577,6 +615,19 @@
         "}");
   }
 
+  public static JavaFileObject annotatedNotesConfigurationWithNotesProviderAndCrossUserConnector(
+      AnnotationPrinter annotationPrinter) {
+    return JavaFileObjects.forSourceLines(
+        NOTES_PACKAGE + ".NotesConfiguration",
+        "package " + NOTES_PACKAGE + ";",
+        "import " + annotationPrinter.crossProfileConfigurationQualifiedName() + ";",
+        "import com.google.android.enterprise.connectedapps.CrossUserConnector;",
+        annotationPrinter.crossProfileConfigurationAsAnnotation(
+            "providers=NotesProvider.class, connector=CrossUserConnector.class"),
+        "public abstract class NotesConfiguration {",
+        "}");
+  }
+
   /** Combines two iterables into an iterable of all possible pairs. */
   public static Iterable<Object[]> combineParameters(
       Iterable<?> parameters1, Iterable<?> parameters2) {
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/ConnectedAppsUtilsTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/ConnectedAppsUtilsTest.java
index 6692067..b4dec77 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/ConnectedAppsUtilsTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/ConnectedAppsUtilsTest.java
@@ -53,7 +53,7 @@
     testUtilities.createWorkUser();
     testUtilities.turnOnWorkProfile();
     testUtilities.setRunningOnPersonalProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
   }
 
   @Test
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/CrossProfileSenderTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/CrossProfileSenderTest.java
index a8f14d8..0bea467 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/CrossProfileSenderTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/CrossProfileSenderTest.java
@@ -18,6 +18,7 @@
 import static com.google.android.enterprise.connectedapps.RobolectricTestUtilities.TEST_CONNECTOR_CLASS_NAME;
 import static com.google.android.enterprise.connectedapps.RobolectricTestUtilities.TEST_SERVICE_CLASS_NAME;
 import static com.google.android.enterprise.connectedapps.SharedTestUtilities.INTERACT_ACROSS_USERS;
+import static com.google.android.enterprise.connectedapps.SharedTestUtilities.tryForceRaceCondition;
 import static com.google.common.truth.Truth.assertThat;
 import static org.junit.Assert.assertThrows;
 import static org.robolectric.Shadows.shadowOf;
@@ -27,11 +28,12 @@
 import android.app.admin.DevicePolicyManager;
 import android.content.ComponentName;
 import android.os.Build.VERSION_CODES;
-import android.os.Parcel;
+import android.os.Bundle;
 import android.os.UserHandle;
 import androidx.test.core.app.ApplicationProvider;
 import com.google.android.enterprise.connectedapps.annotations.AvailabilityRestrictions;
 import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
+import com.google.android.enterprise.connectedapps.internal.Bundler;
 import com.google.common.collect.ImmutableList;
 import org.junit.Before;
 import org.junit.Test;
@@ -53,6 +55,7 @@
   private CrossProfileSender sender;
   private final TestConnectionListener connectionListener = new TestConnectionListener();
   private final TestAvailabilityListener availabilityListener = new TestAvailabilityListener();
+  private final TestAvailabilityListener availabilityListener2 = new TestAvailabilityListener();
   private final TestScheduledExecutorService scheduledExecutorService =
       new TestScheduledExecutorService();
   private final RobolectricTestUtilities testUtilities =
@@ -70,7 +73,6 @@
             availabilityListener,
             scheduledExecutorService,
             AvailabilityRestrictions.DEFAULT);
-    sender.beginMonitoringAvailabilityChanges();
 
     testUtilities.setBinding(testService, TEST_CONNECTOR_CLASS_NAME);
     testUtilities.createWorkUser();
@@ -78,6 +80,7 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
+    sender.clearConnectionHolders();
   }
 
   @Test
@@ -189,47 +192,49 @@
   // handle the multiple threads very well
   @Test
   public void manuallyBind_callingFromUIThread_throwsIllegalStateException() {
-    assertThrows(IllegalStateException.class, sender::manuallyBind);
+    assertThrows(
+        IllegalStateException.class,
+        () -> sender.manuallyBind(CrossProfileSender.MANUAL_MANAGEMENT_CONNECTION_HOLDER));
   }
 
   @Test
-  public void startManuallyBinding_otherProfileIsNotAvailable_doesNotbind() {
+  public void addConnectionHolder_otherProfileIsNotAvailable_doesNotbind() {
     testUtilities.turnOffWorkProfile();
-    sender.startManuallyBinding();
+    sender.addConnectionHolder(this);
 
     assertThat(sender.isBound()).isFalse();
   }
 
   @Test
-  public void startManuallyBinding_bindingIsNotPossible_doesNotCallConnectionListener() {
+  public void addConnectionHolder_bindingIsNotPossible_doesNotCallConnectionListener() {
     testUtilities.turnOffWorkProfile();
 
-    sender.startManuallyBinding();
+    sender.addConnectionHolder(this);
 
     assertThat(connectionListener.connectionChangedCount()).isEqualTo(0);
   }
 
   @Test
-  public void startManuallyBinding_otherProfileIsAvailable_binds() {
+  public void addConnectionHolder_otherProfileIsAvailable_binds() {
     testUtilities.turnOnWorkProfile();
-    sender.startManuallyBinding();
+    sender.addConnectionHolder(this);
 
     assertThat(sender.isBound()).isTrue();
   }
 
   @Test
-  public void startManuallyBinding_binds_callsConnectionListener() {
+  public void addConnectionHolder_binds_callsConnectionListener() {
     testUtilities.turnOnWorkProfile();
-    sender.startManuallyBinding();
+    sender.addConnectionHolder(this);
     testUtilities.advanceTimeBySeconds(1);
 
     assertThat(connectionListener.connectionChangedCount()).isEqualTo(1);
   }
 
   @Test
-  public void startManuallyBinding_otherProfileBecomesAvailable_binds() {
+  public void addConnectionHolder_otherProfileBecomesAvailable_binds() {
     testUtilities.turnOffWorkProfile();
-    sender.startManuallyBinding();
+    sender.addConnectionHolder(this);
 
     testUtilities.turnOnWorkProfile();
 
@@ -237,9 +242,9 @@
   }
 
   @Test
-  public void startManuallyBinding_otherProfileBecomesAvailable_callsConnectionListener() {
+  public void addConnectionHolder_otherProfileBecomesAvailable_callsConnectionListener() {
     testUtilities.turnOffWorkProfile();
-    sender.startManuallyBinding();
+    sender.addConnectionHolder(this);
 
     testUtilities.turnOnWorkProfile();
 
@@ -247,8 +252,8 @@
   }
 
   @Test
-  public void startManuallyBinding_profileBecomesUnavailable_unbinds() {
-    sender.startManuallyBinding();
+  public void addConnectionHolder_profileBecomesUnavailable_unbinds() {
+    sender.addConnectionHolder(this);
     testUtilities.advanceTimeBySeconds(10);
 
     testUtilities.turnOffWorkProfile();
@@ -257,8 +262,8 @@
   }
 
   @Test
-  public void startManuallyBinding_profileBecomesUnavailable_callsConnectionListener() {
-    sender.startManuallyBinding();
+  public void addConnectionHolder_profileBecomesUnavailable_callsConnectionListener() {
+    sender.addConnectionHolder(this);
     testUtilities.advanceTimeBySeconds(10);
     connectionListener.resetConnectionChangedCount();
 
@@ -268,8 +273,8 @@
   }
 
   @Test
-  public void startManuallyBinding_profileBecomesAvailableAgain_rebinds() {
-    sender.startManuallyBinding();
+  public void addConnectionHolder_profileBecomesAvailableAgain_rebinds() {
+    sender.addConnectionHolder(this);
     testUtilities.advanceTimeBySeconds(10);
     testUtilities.turnOffWorkProfile();
 
@@ -279,8 +284,8 @@
   }
 
   @Test
-  public void startManuallyBinding_profileBecomesAvailableAgain_callsConnectionListener() {
-    sender.startManuallyBinding();
+  public void addConnectionHolder_profileBecomesAvailableAgain_callsConnectionListener() {
+    sender.addConnectionHolder(this);
     testUtilities.advanceTimeBySeconds(10);
     testUtilities.turnOffWorkProfile();
     connectionListener.resetConnectionChangedCount();
@@ -291,41 +296,21 @@
   }
 
   @Test
-  public void unbind_isNotBound() {
-    sender.startManuallyBinding();
-
-    sender.unbind();
-
-    assertThat(sender.isBound()).isFalse();
-  }
-
-  @Test
   public void unbind_callsConnectionListener() {
-    sender.startManuallyBinding();
+    sender.addConnectionHolder(this);
     testUtilities.advanceTimeBySeconds(1);
     connectionListener.resetConnectionChangedCount();
 
-    sender.unbind();
-    testUtilities.advanceTimeBySeconds(1);
+    sender.removeConnectionHolder(this);
+    testUtilities.advanceTimeBySeconds(31);
 
     assertThat(connectionListener.connectionChangedCount()).isEqualTo(1);
   }
 
   @Test
-  public void unbind_profileBecomesAvailable_doesNotBind() {
-    testUtilities.turnOffWorkProfile();
-    sender.startManuallyBinding();
-    sender.unbind();
-
-    testUtilities.turnOnWorkProfile();
-
-    assertThat(sender.isBound()).isFalse();
-  }
-
-  @Test
   public void bind_bindingFromPersonalProfile_binds() {
     testUtilities.setRunningOnPersonalProfile();
-    sender.startManuallyBinding();
+    sender.addConnectionHolder(this);
 
     assertThat(sender.isBound()).isTrue();
   }
@@ -333,17 +318,19 @@
   @Test
   public void bind_bindingFromWorkProfile_binds() {
     testUtilities.setRunningOnWorkProfile();
-    sender.startManuallyBinding();
+    sender.addConnectionHolder(this);
 
     assertThat(sender.isBound()).isTrue();
   }
 
   @Test
-  public void call_isNotBound_throwsUnavailableProfileException() {
+  public void call_isNotBound_throwsException() {
+    // As we can't force disconnection - we just give time for any existing connections to close
+    testUtilities.advanceTimeBySeconds(31);
     int crossProfileTypeIdentifier = 1;
     int methodIdentifier = 0;
-    Parcel params = Parcel.obtain();
-    sender.unbind();
+
+    Bundle params = new Bundle(Bundler.class.getClassLoader());
 
     assertThrows(
         UnavailableProfileException.class,
@@ -354,39 +341,39 @@
   public void call_isBound_callsMethod() throws UnavailableProfileException {
     int crossProfileTypeIdentifier = 1;
     int methodIdentifier = 0;
-    Parcel params = Parcel.obtain();
-    params.writeString("value");
-    sender.startManuallyBinding();
+
+    Bundle params = new Bundle(Bundler.class.getClassLoader());
+    params.putString("value", "value");
+    sender.addConnectionHolder(this);
 
     sender.call(crossProfileTypeIdentifier, methodIdentifier, params);
 
     assertThat(testService.lastCall().getCrossProfileTypeIdentifier())
         .isEqualTo(crossProfileTypeIdentifier);
     assertThat(testService.lastCall().getMethodIdentifier()).isEqualTo(methodIdentifier);
-    assertThat(testService.lastCall().getParams().readString()).isEqualTo("value");
+    assertThat(testService.lastCall().getParams().getString("value")).isEqualTo("value");
   }
 
   @Test
   public void call_isBound_returnsResponse() throws UnavailableProfileException {
     int crossProfileTypeIdentifier = 1;
     int methodIdentifier = 0;
-    Parcel params = Parcel.obtain();
-    Parcel expectedResponseParcel = Parcel.obtain();
-    expectedResponseParcel.writeInt(0); // No error
-    expectedResponseParcel.writeString("value");
-    testService.setResponseParcel(expectedResponseParcel);
-    sender.startManuallyBinding();
+    Bundle params = new Bundle(Bundler.class.getClassLoader());
+    Bundle expectedResponseBundle = new Bundle(Bundler.class.getClassLoader());
+    expectedResponseBundle.putString("value", "value");
+    testService.setResponseBundle(expectedResponseBundle);
+    sender.addConnectionHolder(this);
 
-    Parcel actualResponseParcel = sender.call(crossProfileTypeIdentifier, methodIdentifier, params);
+    Bundle actualResponseBundle = sender.call(crossProfileTypeIdentifier, methodIdentifier, params);
 
-    assertThat(actualResponseParcel.readString()).isEqualTo("value");
+    assertThat(actualResponseBundle.getString("value")).isEqualTo("value");
   }
 
   @Test
   public void bind_usingDpcBinding_otherProfileIsAvailable_binds() {
     initWithDpcBinding();
     testUtilities.turnOnWorkProfile();
-    sender.startManuallyBinding();
+    sender.addConnectionHolder(this);
 
     assertThat(sender.isBound()).isTrue();
   }
@@ -395,7 +382,7 @@
   public void bind_usingDpcBinding_binds_callsConnectionListener() {
     initWithDpcBinding();
     testUtilities.turnOnWorkProfile();
-    sender.startManuallyBinding();
+    sender.addConnectionHolder(this);
     testUtilities.advanceTimeBySeconds(1);
 
     assertThat(connectionListener.connectionChangedCount()).isEqualTo(1);
@@ -406,7 +393,7 @@
     initWithDpcBinding();
     shadowOf(devicePolicyManager).setBindDeviceAdminTargetUsers(ImmutableList.of());
 
-    sender.startManuallyBinding();
+    sender.addConnectionHolder(this);
     testUtilities.advanceTimeBySeconds(10);
 
     assertThat(sender.isBound()).isFalse();
@@ -416,7 +403,7 @@
   public void bind_usingDpcBinding_otherProfileIsCreated_binds() {
     initWithDpcBinding();
     shadowOf(devicePolicyManager).setBindDeviceAdminTargetUsers(ImmutableList.of());
-    sender.startManuallyBinding();
+    sender.addConnectionHolder(this);
     testUtilities.advanceTimeBySeconds(10);
 
     shadowOf(devicePolicyManager)
@@ -430,7 +417,7 @@
   public void bind_usingDpcBinding_otherProfileBecomesAvailable_binds() {
     initWithDpcBinding();
     testUtilities.turnOffWorkProfile();
-    sender.startManuallyBinding();
+    sender.addConnectionHolder(this);
     testUtilities.advanceTimeBySeconds(10);
 
     testUtilities.turnOnWorkProfile();
@@ -442,7 +429,7 @@
   public void bind_usingDpcBinding_otherProfileBecomesAvailable_callsConnectionListener() {
     initWithDpcBinding();
     testUtilities.turnOffWorkProfile();
-    sender.startManuallyBinding();
+    sender.addConnectionHolder(this);
     testUtilities.advanceTimeBySeconds(10);
 
     testUtilities.turnOnWorkProfile();
@@ -470,6 +457,57 @@
     assertThat(availabilityListener.availabilityChangedCount()).isEqualTo(1);
   }
 
+  @Test
+  public void createMultipleSenders_workProfileBecomesAvailable_callsAvailabilityListenerForEachSender() {
+    CrossProfileSender sender2 =
+        new CrossProfileSender(
+            context,
+            TEST_SERVICE_CLASS_NAME,
+            new DefaultProfileBinder(),
+            connectionListener,
+            availabilityListener2,
+            scheduledExecutorService,
+            AvailabilityRestrictions.DEFAULT);
+
+    testUtilities.turnOffWorkProfile();
+    availabilityListener.reset();
+    availabilityListener2.reset();
+
+    testUtilities.turnOnWorkProfile();
+
+    assertThat(availabilityListener.availabilityChangedCount()).isEqualTo(1);
+    assertThat(availabilityListener2.availabilityChangedCount()).isEqualTo(1);
+  }
+
+  @Test
+  // Regression test for b/195910311.
+  // Do not ignore if this test turns flaky, this likely highlights a real race condition.
+  public void concurrentDisconnectionCall_doesntCrash() throws Exception {
+    int crossProfileTypeIdentifier = 1;
+    int methodIdentifier = 0;
+    Bundle params = new Bundle(Bundler.class.getClassLoader());
+    params.putString("value", "value");
+    sender.addConnectionHolder(this);
+    Object connectionHolderAlias = new Object();
+
+    tryForceRaceCondition(
+        10000,
+        () ->
+            sender.callAsync(
+                crossProfileTypeIdentifier,
+                methodIdentifier,
+                params,
+                new LocalCallback() {
+                  @Override
+                  public void onResult(int methodIdentifier, Bundle params) {}
+
+                  @Override
+                  public void onException(Bundle exception) {}
+                },
+                connectionHolderAlias),
+        testUtilities::simulateDisconnectingServiceConnection);
+  }
+
   private void initWithDpcBinding() {
     shadowOf(devicePolicyManager)
         .setBindDeviceAdminTargetUsers(ImmutableList.of(getWorkUserHandle()));
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/ProfileConnectorTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/ProfileConnectorTest.java
index a6b5b17..0e74616 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/ProfileConnectorTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/ProfileConnectorTest.java
@@ -32,6 +32,7 @@
 import com.google.android.enterprise.connectedapps.testapp.connector.DirectBootAwareConnector;
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnectorWithCustomServiceClass;
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -70,6 +71,11 @@
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
   }
 
+  @After
+  public void teardown() {
+    testProfileConnector.clearConnectionHolders();
+  }
+
   @Test
   public void construct_nullConnector_throwsNullPointerException() {
     assertThrows(NullPointerException.class, () -> TestProfileConnector.create(null));
@@ -79,21 +85,34 @@
   // handle the multiple threads very well
   @Test
   public void connect_callingFromUIThread_throwsIllegalStateException() {
-    assertThrows(IllegalStateException.class, testProfileConnector::connect);
+    assertThrows(IllegalStateException.class, () -> testProfileConnector.connect(this));
   }
 
   @Test
-  public void startConnecting_fromPersonalProfile_binds() {
+  public void addConnectionHolder_null_throwsException() {
+    assertThrows(NullPointerException.class, () -> testProfileConnector.addConnectionHolder(null));
+  }
+
+  @Test
+  public void removeConnectionHolder_null_throwsException() {
+    assertThrows(NullPointerException.class,
+        () -> testProfileConnector.removeConnectionHolder(null));
+  }
+
+  @Test
+  public void addConnectionHolder_fromPersonalProfile_binds() {
     testUtilities.setRunningOnPersonalProfile();
-    testUtilities.startConnectingAndWait();
+
+    testProfileConnector.addConnectionHolder(this);
 
     assertThat(testProfileConnector.isConnected()).isTrue();
   }
 
   @Test
-  public void startConnecting_fromWorkProfile_binds() {
+  public void addConnectionHolder_fromWorkProfile_binds() {
     testUtilities.setRunningOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+
+    testProfileConnector.addConnectionHolder(this);
 
     assertThat(testProfileConnector.isConnected()).isTrue();
   }
@@ -116,7 +135,7 @@
 
   @Test
   public void disconnect_isBound_unbinds() {
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     testUtilities.disconnect();
 
@@ -124,26 +143,26 @@
   }
 
   @Test
-  public void startConnecting_callsConnectionListener() {
-    testProfileConnector.registerConnectionListener(connectionListener);
-    testUtilities.startConnectingAndWait();
+  public void addConnectionHolder_callsConnectionListener() {
+    testProfileConnector.addConnectionListener(connectionListener);
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     assertThat(connectionListener.connectionChangedCount()).isEqualTo(1);
   }
 
   @Test
-  public void startConnecting_doesNotCallUnregisteredConnectionListener() {
-    testProfileConnector.registerConnectionListener(connectionListener);
-    testProfileConnector.unregisterConnectionListener(connectionListener);
-    testUtilities.startConnectingAndWait();
+  public void addConnectionHolder_doesNotCallUnregisteredConnectionListener() {
+    testProfileConnector.addConnectionListener(connectionListener);
+    testProfileConnector.removeConnectionListener(connectionListener);
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     assertThat(connectionListener.connectionChangedCount()).isEqualTo(0);
   }
 
   @Test
   public void disconnect_callsConnectionListener() {
-    testProfileConnector.registerConnectionListener(connectionListener);
-    testUtilities.startConnectingAndWait();
+    testProfileConnector.addConnectionListener(connectionListener);
+    testUtilities.addDefaultConnectionHolderAndWait();
     connectionListener.resetConnectionChangedCount();
 
     testUtilities.disconnect();
@@ -153,8 +172,8 @@
 
   @Test
   public void bindingDies_callsConnectionListener() {
-    testProfileConnector.registerConnectionListener(connectionListener);
-    testUtilities.startConnectingAndWait();
+    testProfileConnector.addConnectionListener(connectionListener);
+    testUtilities.addDefaultConnectionHolderAndWait();
     connectionListener.resetConnectionChangedCount();
 
     testUtilities.turnOffWorkProfile();
@@ -163,9 +182,9 @@
   }
 
   @Test
-  public void startConnecting_profileConnectorWithCustomServiceClass() {
+  public void addConnectionHolder_profileConnectorWithCustomServiceClass() {
     TestProfileConnectorWithCustomServiceClass.create(context, scheduledExecutorService)
-        .startConnecting();
+        .addConnectionHolder(this);
     testUtilities.advanceTimeBySeconds(1); // Allow connection
 
     assertThat(shadowOf(context).getNextStartedService().getComponent().getClassName())
@@ -232,23 +251,13 @@
   }
 
   @Test
-  public void isManuallyManagingConnection_returnsFalse() {
-    assertThat(testProfileConnector.isManuallyManagingConnection()).isFalse();
-  }
+  public void addConnectionHolder_autocloseReturnedConnectionHolder_unbinds() {
+    try (ProfileConnectionHolder p = testProfileConnector.addConnectionHolder(this)) {
+      // Intentionally empty
+    }
 
-  @Test
-  public void isManuallyManagingConnection_hasManuallyConnected_returnsTrue() {
-    testUtilities.startConnectingAndWait();
+    testUtilities.advanceTimeBySeconds(31);
 
-    assertThat(testProfileConnector.isManuallyManagingConnection()).isTrue();
-  }
-
-  @Test
-  public void isManuallyManagingConnection_hasCalledStopManualConnectionManagement_returnsFalse() {
-    testUtilities.startConnectingAndWait();
-
-    testProfileConnector.stopManualConnectionManagement();
-
-    assertThat(testProfileConnector.isManuallyManagingConnection()).isFalse();
+    assertThat(testProfileConnector.isConnected()).isFalse();
   }
 }
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/ProfileConnectorUnsupportedTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/ProfileConnectorUnsupportedTest.java
index d6bb9ca..d97179b 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/ProfileConnectorUnsupportedTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/ProfileConnectorUnsupportedTest.java
@@ -37,8 +37,18 @@
   private final TestProfileConnector testProfileConnector = TestProfileConnector.create(context);
 
   @Test
-  public void startConnecting_doesNotCrash() {
-    testProfileConnector.startConnecting();
+  public void addConnectionHolder_doesNotCrash() {
+    testProfileConnector.addConnectionHolder(this);
+  }
+
+  @Test
+  public void removeConnectionHolder_doesNotCrash() {
+    testProfileConnector.removeConnectionHolder(this);
+  }
+
+  @Test
+  public void clearConnectionHolderS_doesNotCrash() {
+    testProfileConnector.clearConnectionHolders();
   }
 
   @Test
@@ -57,33 +67,28 @@
   }
 
   @Test
-  public void stopManualConnectionManagement_doesNotCrash() {
-    testProfileConnector.stopManualConnectionManagement();
-  }
-
-  @Test
   public void crossProfileSender_returnsNull() {
     assertThat(testProfileConnector.crossProfileSender()).isNull();
   }
 
   @Test
-  public void registerConnectionListener_doesNotCrash() {
-    testProfileConnector.registerConnectionListener(() -> {});
+  public void addConnectionListener_doesNotCrash() {
+    testProfileConnector.addConnectionListener(() -> {});
   }
 
   @Test
-  public void unregisterConnectionListener_doesNotCrash() {
-    testProfileConnector.unregisterConnectionListener(() -> {});
+  public void removeConnectionListener_doesNotCrash() {
+    testProfileConnector.removeConnectionListener(() -> {});
   }
 
   @Test
-  public void registerAvailabilityListener_doesNotCrash() {
-    testProfileConnector.registerAvailabilityListener(() -> {});
+  public void addAvailabilityListener_doesNotCrash() {
+    testProfileConnector.addAvailabilityListener(() -> {});
   }
 
   @Test
-  public void unregisterAvailabilityListener_doesNotCrash() {
-    testProfileConnector.unregisterAvailabilityListener(() -> {});
+  public void removeAvailabilityListener_doesNotCrash() {
+    testProfileConnector.removeAvailabilityListener(() -> {});
   }
 
   @Test
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/RobolectricTestUtilities.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/RobolectricTestUtilities.java
index ae14549..cd7c0d3 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/RobolectricTestUtilities.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/RobolectricTestUtilities.java
@@ -19,6 +19,7 @@
 import static android.os.Looper.getMainLooper;
 import static com.google.android.enterprise.connectedapps.SharedTestUtilities.INTERACT_ACROSS_USERS;
 import static com.google.android.enterprise.connectedapps.SharedTestUtilities.INTERACT_ACROSS_USERS_FULL;
+import static com.google.android.enterprise.connectedapps.SharedTestUtilities.getUserHandleForUserId;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 import static java.util.concurrent.TimeUnit.SECONDS;
@@ -61,9 +62,8 @@
   private static final int PER_USER_RANGE = 100000;
 
   private final UserHandle personalProfileUserHandle =
-      SharedTestUtilities.getUserHandleForUserId(PERSONAL_PROFILE_USER_ID);
-  private final UserHandle workProfileUserHandle =
-      SharedTestUtilities.getUserHandleForUserId(WORK_PROFILE_USER_ID);
+      getUserHandleForUserId(PERSONAL_PROFILE_USER_ID);
+  private final UserHandle workProfileUserHandle = getUserHandleForUserId(WORK_PROFILE_USER_ID);
   private static final int WORK_UID = PER_USER_RANGE * WORK_PROFILE_USER_ID;
   private static final int PERSONAL_UID = PER_USER_RANGE * PERSONAL_PROFILE_USER_ID;
   private final Application context;
@@ -109,16 +109,22 @@
   public void initTests() {
     TestCrossProfileType.voidMethodCalls = 0;
     CrossProfileSDKUtilities.clearCache();
+    CrossProfileSender.clearStaticState();
     createPersonalUser();
   }
 
-  public void startConnectingAndWait() {
-    connector.startConnecting();
+  public void addDefaultConnectionHolderAndWait() {
+    connector.addConnectionHolder(this);
+    advanceTimeBySeconds(1);
+  }
+
+  public void addDefaultConnectionHolderAndWait(UserConnector connector, UserHandle handle) {
+    connector.addConnectionHolder(handle, this);
     advanceTimeBySeconds(1);
   }
 
   public void disconnect() {
-    connector.stopManualConnectionManagement();
+    connector.clearConnectionHolders();
     advanceTimeBySeconds(31); // Give time to timeout connection
   }
 
@@ -138,6 +144,13 @@
         .addProfile(WORK_PROFILE_USER_ID, PERSONAL_PROFILE_USER_ID, "Personal Profile", 0);
   }
 
+  public UserHandle createCustomUser(int id) {
+    UserHandle handle = getUserHandleForUserId(id);
+    shadowOf(userManager).addUser(id, "Custom User", /* flags= */ 0);
+    shadowOf(userManager).setUserState(handle, UserState.STATE_RUNNING_UNLOCKED);
+    return handle;
+  }
+
   public void turnOnWorkProfileWithoutUnlocking() {
     shadowOf(userManager).setUserState(workProfileUserHandle, UserState.STATE_RUNNING_LOCKED);
     tryAddTargetUserProfile(workProfileUserHandle);
@@ -169,7 +182,7 @@
     advanceTimeBySeconds(10);
   }
 
-  private void tryAddTargetUserProfile(UserHandle userHandle) {
+  public void tryAddTargetUserProfile(UserHandle userHandle) {
     try {
       addTargetUserProfile(userHandle);
     } catch (IllegalArgumentException e) {
@@ -184,7 +197,7 @@
     shadowOf(crossProfileApps).addTargetUserProfile(userHandle);
   }
 
-  private void tryRemoveTargetUserProfile(UserHandle userHandle) {
+  public void tryRemoveTargetUserProfile(UserHandle userHandle) {
     try {
       removeTargetUserProfile(userHandle);
     } catch (IllegalArgumentException e) {
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/TestICrossProfileCallback.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/TestICrossProfileCallback.java
index 461cc9a..9fa2182 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/TestICrossProfileCallback.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/TestICrossProfileCallback.java
@@ -15,9 +15,11 @@
  */
 package com.google.android.enterprise.connectedapps;
 
+import android.os.Bundle;
 import android.os.IBinder;
 import android.os.Parcel;
 import android.os.RemoteException;
+import com.google.android.enterprise.connectedapps.internal.Bundler;
 
 /**
  * An implementation of {@link ICrossProfileCallback} which just redirects call to a given {@link
@@ -38,13 +40,18 @@
       throws RemoteException {}
 
   @Override
+  public void prepareBundle(long callId, int bundleId, Bundle bundle) {}
+
+  @Override
   public void onResult(long callId, int blockId, int methodIdentifier, byte[] params)
       throws RemoteException {
     Parcel p = Parcel.obtain(); // Recycled in this method
     p.unmarshall(params, 0, params.length);
     p.setDataPosition(0);
-    localCallback.onResult(methodIdentifier, p);
+    Bundle bundle = new Bundle(Bundler.class.getClassLoader());
+    bundle.readFromParcel(p);
     p.recycle();
+    localCallback.onResult(methodIdentifier, bundle);
   }
 
   @Override
@@ -52,8 +59,10 @@
     Parcel p = Parcel.obtain(); // Recycled in this method
     p.unmarshall(params, 0, params.length);
     p.setDataPosition(0);
-    localCallback.onException(p);
+    Bundle bundle = new Bundle(Bundler.class.getClassLoader());
+    bundle.readFromParcel(p);
     p.recycle();
+    localCallback.onException(bundle);
   }
 
   @Override
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/TestScheduledExecutorService.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/TestScheduledExecutorService.java
index f237e66..06b2b4c 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/TestScheduledExecutorService.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/TestScheduledExecutorService.java
@@ -17,11 +17,13 @@
 
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
+import java.util.Collections;
+import java.util.Iterator;
 import java.util.List;
-import java.util.Queue;
+import java.util.Set;
 import java.util.concurrent.AbstractExecutorService;
 import java.util.concurrent.Callable;
-import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Delayed;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
@@ -37,7 +39,9 @@
 public class TestScheduledExecutorService extends AbstractExecutorService implements ScheduledExecutorService {
 
   private long millisPast = 0;
-  private final Queue<SimpleScheduledFuture<?>> executeQueue = new ConcurrentLinkedQueue<>();
+  private final Set<SimpleScheduledFuture<?>> executeQueue =
+      Collections.newSetFromMap(new ConcurrentHashMap<>());
+
   public TestScheduledExecutorService() {}
 
   @Override
@@ -107,8 +111,14 @@
 
   private void advanceTimeByMillis(long timeoutMillis) throws Exception {
     millisPast += timeoutMillis;
-    while (!executeQueue.isEmpty() && executeQueue.peek().getDelay(MILLISECONDS) <= millisPast) {
-      executeQueue.remove().complete();
+    Iterator<SimpleScheduledFuture<?>> scheduledFutures = executeQueue.iterator();
+
+    while (scheduledFutures.hasNext()) {
+      SimpleScheduledFuture<?> next = scheduledFutures.next();
+      if (next.getDelay(MILLISECONDS) <= millisPast) {
+        scheduledFutures.remove();
+        next.complete();
+      }
     }
   }
 
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/TestService.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/TestService.java
index cb11f3d..701a30f 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/TestService.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/TestService.java
@@ -15,8 +15,10 @@
  */
 package com.google.android.enterprise.connectedapps;
 
+import android.os.Bundle;
 import android.os.Parcel;
 import android.os.RemoteException;
+import com.google.android.enterprise.connectedapps.internal.Bundler;
 import com.google.android.enterprise.connectedapps.internal.ByteUtilities;
 import com.google.auto.value.AutoValue;
 import org.checkerframework.checker.nullness.qual.Nullable;
@@ -29,7 +31,7 @@
 
     abstract long getMethodIdentifier();
 
-    abstract Parcel getParams();
+    abstract Bundle getParams();
 
     @Nullable
     abstract ICrossProfileCallback callback();
@@ -37,7 +39,7 @@
     static LoggedCrossProfileMethodCall create(
         long crossProfileTypeIdentifier,
         long methodIdentifier,
-        Parcel params,
+        Bundle params,
         ICrossProfileCallback callback) {
       return new AutoValue_TestService_LoggedCrossProfileMethodCall(
           crossProfileTypeIdentifier, methodIdentifier, params, callback);
@@ -45,26 +47,24 @@
   }
 
   private LoggedCrossProfileMethodCall lastCall;
-  private Parcel responseParcel = Parcel.obtain(); // Recycled in #setResponseParcel
+  private Bundle responseBundle = new Bundle(Bundler.class.getClassLoader());
 
   LoggedCrossProfileMethodCall lastCall() {
     return lastCall;
   }
 
-  /**
-   * Set the parcel to be returned from a call to this service.
-   *
-   * <p>The previously set parcel will be recycled.
-   */
-  void setResponseParcel(Parcel responseParcel) {
-    this.responseParcel.recycle();
-    this.responseParcel = responseParcel;
+  /** Set the bundle to be returned from a call to this service. */
+  void setResponseBundle(Bundle responseBundle) {
+    this.responseBundle = responseBundle;
   }
 
   @Override
   public void prepareCall(long callId, int blockId, int numBytes, byte[] paramsBytes) {}
 
   @Override
+  public void prepareBundle(long callId, int blockId, Bundle bundle) {}
+
+  @Override
   public byte[] call(
       long callId,
       int blockId,
@@ -77,16 +77,18 @@
     Parcel parcel = Parcel.obtain(); // Recycled by this method on next call
     parcel.unmarshall(paramsBytes, 0, paramsBytes.length);
     parcel.setDataPosition(0);
-
-    if (lastCall != null) {
-      lastCall.getParams().recycle();
-    }
+    Bundle bundle = new Bundle(Bundler.class.getClassLoader());
+    bundle.readFromParcel(parcel);
 
     lastCall =
         LoggedCrossProfileMethodCall.create(
-            crossProfileTypeIdentifier, methodIdentifier, parcel, callback);
+            crossProfileTypeIdentifier, methodIdentifier, bundle, callback);
 
+    Parcel responseParcel = Parcel.obtain();
+    responseBundle.writeToParcel(responseParcel, /* flags= */ 0);
     byte[] parcelBytes = responseParcel.marshall();
+    responseParcel.recycle();
+
     return prepareResponse(parcelBytes);
   }
 
@@ -99,4 +101,9 @@
   public byte[] fetchResponse(long callId, int blockId) {
     return null;
   }
+
+  @Override
+  public Bundle fetchResponseBundle(long callId, int bundleId) {
+    return null;
+  }
 }
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/TestStringCrossProfileCallback.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/TestStringCrossProfileCallback.java
index a88c222..d5085fa 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/TestStringCrossProfileCallback.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/TestStringCrossProfileCallback.java
@@ -15,39 +15,48 @@
  */
 package com.google.android.enterprise.connectedapps;
 
+import android.os.Bundle;
 import android.os.IBinder;
 import android.os.Parcel;
 import android.os.RemoteException;
-import com.google.android.enterprise.connectedapps.internal.ParcelUtilities;
+import com.google.android.enterprise.connectedapps.internal.BundleUtilities;
+import com.google.android.enterprise.connectedapps.internal.Bundler;
 
 public class TestStringCrossProfileCallback implements ICrossProfileCallback {
 
   public int lastReceivedMethodIdentifier = -1;
-  public String lastReceivedMethodParam;
+  public String lastReceivedMethodValueParam;
   public Throwable lastReceivedException;
 
   @Override
   public void prepareResult(long callId, int blockId, int numBytes, byte[] params) {}
 
   @Override
+  public void prepareBundle(long callId, int bundleId, Bundle bundle) {}
+
+  @Override
   public void onResult(long callId, int blockId, int methodIdentifier, byte[] paramsBytes)
       throws RemoteException {
     lastReceivedMethodIdentifier = methodIdentifier;
-    Parcel parcel = Parcel.obtain(); // Recycled in this method
+    Parcel parcel = Parcel.obtain();
     parcel.unmarshall(paramsBytes, 0, paramsBytes.length);
     parcel.setDataPosition(0);
-    lastReceivedMethodParam = parcel.readString();
+    Bundle params = new Bundle(Bundler.class.getClassLoader());
+    params.readFromParcel(parcel);
     parcel.recycle();
+    lastReceivedMethodValueParam = params.getString("value");
   }
 
   @Override
   public void onException(long callId, int blockId, byte[] paramsBytes) throws RemoteException {
-    Parcel parcel = Parcel.obtain(); // Recycled in this method
+    Parcel parcel = Parcel.obtain();
     parcel.unmarshall(paramsBytes, 0, paramsBytes.length);
     parcel.setDataPosition(0);
-
-    lastReceivedException = ParcelUtilities.readThrowableFromParcel(parcel);
+    Bundle bundle = new Bundle(Bundler.class.getClassLoader());
+    bundle.readFromParcel(parcel);
     parcel.recycle();
+
+    lastReceivedException = BundleUtilities.readThrowableFromBundle(bundle, "throwable");
   }
 
   @Override
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/UserConnectorTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/UserConnectorTest.java
new file mode 100644
index 0000000..dd252b0
--- /dev/null
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/UserConnectorTest.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps;
+
+import static com.google.android.enterprise.connectedapps.SharedTestUtilities.INTERACT_ACROSS_USERS_FULL;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Application;
+import android.app.Service;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.ServiceConnection;
+import android.os.Build.VERSION_CODES;
+import android.os.IBinder;
+import android.os.UserHandle;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.enterprise.connectedapps.annotations.AvailabilityRestrictions;
+import com.google.android.enterprise.connectedapps.exceptions.MissingApiException;
+import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
+import com.google.android.enterprise.connectedapps.testapp.crossuser.AppCrossUserConfiguration;
+import com.google.android.enterprise.connectedapps.testapp.crossuser.AppCrossUserConnector;
+import com.google.android.enterprise.connectedapps.testapp.crossuser.UserTestCrossUserType;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = VERSION_CODES.O)
+public final class UserConnectorTest {
+
+  private static final class TestBinder extends DefaultUserBinder {
+
+    TestBinder(UserHandle userHandle) {
+      super(userHandle);
+    }
+
+    static int tryBindCalls = 0;
+
+    @Override
+    public boolean tryBind(
+        Context context,
+        ComponentName bindToService,
+        ServiceConnection connection,
+        AvailabilityRestrictions availabilityRestrictions)
+        throws MissingApiException {
+      ++tryBindCalls;
+      return super.tryBind(context, bindToService, connection, availabilityRestrictions);
+    }
+  }
+
+  private static final String STRING = "hello";
+  private static final int TARGET_USER_ID = 5;
+
+  private final Application context = ApplicationProvider.getApplicationContext();
+  private final TestScheduledExecutorService scheduledExecutorService =
+      new TestScheduledExecutorService();
+  private final RobolectricTestUtilities utilities =
+      new RobolectricTestUtilities(context, scheduledExecutorService);
+
+  private final UserHandle targetUserHandle = utilities.createCustomUser(TARGET_USER_ID);
+
+  @Before
+  public void setUp() {
+    Service profileAwareService = Robolectric.setupService(AppCrossUserConfiguration.getService());
+    IBinder binder = profileAwareService.onBind(/* intent= */ null);
+    utilities.setBinding(binder, AppCrossUserConnector.class.getName());
+    utilities.setRequestsPermissions(INTERACT_ACROSS_USERS_FULL);
+    utilities.grantPermissions(INTERACT_ACROSS_USERS_FULL);
+  }
+
+  @Test
+  public void defaultBinder_works() throws UnavailableProfileException {
+    AppCrossUserConnector connector =
+        AppCrossUserConnector.create(context, scheduledExecutorService);
+    UserTestCrossUserType testCrossUserType = UserTestCrossUserType.create(connector);
+
+    utilities.addDefaultConnectionHolderAndWait(connector, targetUserHandle);
+
+    assertThat(testCrossUserType.user(targetUserHandle).identityStringMethod(STRING))
+        .isEqualTo(STRING);
+  }
+
+  @Test
+  public void testBinderFactory_isUsed_tryBindCallsIncremented() {
+    TestBinder.tryBindCalls = 0;
+    AppCrossUserConnector connector =
+        AppCrossUserConnector.create(context, scheduledExecutorService, TestBinder::new);
+
+    utilities.addDefaultConnectionHolderAndWait(connector, targetUserHandle);
+
+    assertThat(TestBinder.tryBindCalls).isEqualTo(1);
+  }
+}
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/internal/BundleCallSenderTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/internal/BundleCallSenderTest.java
new file mode 100644
index 0000000..75a2b0b
--- /dev/null
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/internal/BundleCallSenderTest.java
@@ -0,0 +1,199 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.internal;
+
+import static com.google.android.enterprise.connectedapps.StringUtilities.randomString;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.os.Binder;
+import android.os.Bundle;
+import android.os.RemoteException;
+import android.os.TransactionTooLargeException;
+import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class BundleCallSenderTest {
+
+  static class TestBundleCallSender extends BundleCallSender {
+
+    int failPrepareCalls = 0;
+    int failCalls = 0;
+    int failFetchResponse = 0;
+    int failPrepareBundleCalls = 0;
+    int failFetchResponseBundle = 0;
+
+    private final BundleCallReceiver bundleCallReceiver = new BundleCallReceiver();
+
+    @Override
+    void prepareCall(long callId, int blockId, int totalBytes, byte[] bytes)
+        throws RemoteException {
+      if (failPrepareCalls-- > 0) {
+        throw new TransactionTooLargeException();
+      }
+
+      bundleCallReceiver.prepareCall(callId, blockId, totalBytes, bytes);
+    }
+
+    @Override
+    void prepareBundle(long callId, int bundleId, Bundle bundle) throws RemoteException {
+      if (failPrepareBundleCalls-- > 0) {
+        throw new TransactionTooLargeException();
+      }
+
+      bundleCallReceiver.prepareBundle(callId, bundleId, bundle);
+    }
+
+    @Override
+    byte[] call(long callId, int blockId, byte[] bytes) throws RemoteException {
+      if (failCalls-- > 0) {
+        throw new TransactionTooLargeException();
+      }
+
+      return bundleCallReceiver.prepareResponse(
+          callId, bundleCallReceiver.getPreparedCall(callId, blockId, bytes));
+    }
+
+    @Override
+    byte[] fetchResponse(long callId, int blockId) throws RemoteException {
+      if (failFetchResponse-- > 0) {
+        throw new TransactionTooLargeException();
+      }
+
+      return bundleCallReceiver.getPreparedResponse(callId, blockId);
+    }
+
+    @Override
+    Bundle fetchResponseBundle(long callId, int bundleId) throws RemoteException {
+      if (failFetchResponseBundle-- > 0) {
+        throw new TransactionTooLargeException();
+      }
+
+      return bundleCallReceiver.getPreparedResponseBundle(callId, bundleId);
+    }
+  }
+
+  private final TestBundleCallSender bundleCallSender = new TestBundleCallSender();
+  private static final String LARGE_STRING = randomString(1500000); // 3Mb
+  private static final Bundle LARGE_BUNDLE = new Bundle(Bundler.class.getClassLoader());
+  private final Binder binder = new Binder();
+  private final Bundle bundleContainingBinder = new Bundle();
+
+  static {
+    LARGE_BUNDLE.putString("value", LARGE_STRING);
+  }
+
+  public BundleCallSenderTest() {
+    bundleContainingBinder.putBinder("binder", binder);
+  }
+
+  @Test
+  public void makeBundleCall_prepareCallHasError_retriesUntilSuccess()
+      throws UnavailableProfileException {
+    bundleCallSender.failPrepareCalls = 5;
+
+    assertThat(bundleCallSender.makeBundleCall(LARGE_BUNDLE).getString("value"))
+        .isEqualTo(LARGE_STRING);
+  }
+
+  @Test
+  public void makeBundleCall_prepareCallHasError_failsAfter10Retries() {
+    bundleCallSender.failPrepareCalls = 11;
+
+    assertThrows(
+        UnavailableProfileException.class, () -> bundleCallSender.makeBundleCall(LARGE_BUNDLE));
+  }
+
+  @Test
+  public void makeBundleCall_callHasError_retriesUntilSuccess() throws UnavailableProfileException {
+    bundleCallSender.failCalls = 5;
+
+    assertThat(bundleCallSender.makeBundleCall(LARGE_BUNDLE).getString("value"))
+        .isEqualTo(LARGE_STRING);
+  }
+
+  @Test
+  public void makeBundleCall_callHasError_failsAfter10Retries() {
+    bundleCallSender.failCalls = 11;
+
+    assertThrows(
+        UnavailableProfileException.class, () -> bundleCallSender.makeBundleCall(LARGE_BUNDLE));
+  }
+
+  @Test
+  public void makeBundleCall_fetchResponseHasError_retriesUntilSuccess()
+      throws UnavailableProfileException {
+    bundleCallSender.failFetchResponse = 5;
+
+    assertThat(bundleCallSender.makeBundleCall(LARGE_BUNDLE).getString("value"))
+        .isEqualTo(LARGE_STRING);
+  }
+
+  @Test
+  public void makeBundleCall_fetchResponseHasError_failsAfter10Retries() {
+    bundleCallSender.failFetchResponse = 11;
+
+    assertThrows(
+        UnavailableProfileException.class, () -> bundleCallSender.makeBundleCall(LARGE_BUNDLE));
+  }
+
+  @Test
+  public void makeBundleCall_bundleContainsBinder_succeeds() throws UnavailableProfileException {
+    assertThat(bundleCallSender.makeBundleCall(bundleContainingBinder).getBinder("binder"))
+        .isEqualTo(binder);
+  }
+
+  @Test
+  public void makeBundleCall_prepareBundleHasError_retriesUntilSuccess()
+      throws UnavailableProfileException {
+    bundleCallSender.failPrepareBundleCalls = 5;
+
+    assertThat(bundleCallSender.makeBundleCall(bundleContainingBinder).getBinder("binder"))
+        .isEqualTo(binder);
+  }
+
+  @Test
+  public void makeBundleCall_prepareBundleHasError_failsAfter10Retries()
+      throws UnavailableProfileException {
+    bundleCallSender.failPrepareBundleCalls = 11;
+
+    assertThrows(
+        UnavailableProfileException.class,
+        () -> bundleCallSender.makeBundleCall(bundleContainingBinder));
+  }
+
+  @Test
+  public void makeBundleCall_fetchResponseBundleHasError_retriesUntilSuccess()
+      throws UnavailableProfileException {
+    bundleCallSender.failFetchResponseBundle = 5;
+
+    assertThat(bundleCallSender.makeBundleCall(bundleContainingBinder).getBinder("binder"))
+        .isEqualTo(binder);
+  }
+
+  @Test
+  public void makeBundleCall_fetchResponseBundleHasError_failsAfter10Retries()
+      throws UnavailableProfileException {
+    bundleCallSender.failFetchResponseBundle = 11;
+
+    assertThrows(
+        UnavailableProfileException.class,
+        () -> bundleCallSender.makeBundleCall(bundleContainingBinder));
+  }
+}
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackMultiMergerTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackMultiMergerTest.java
index b7dbc95..088622e 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackMultiMergerTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/internal/CrossProfileCallbackMultiMergerTest.java
@@ -16,11 +16,18 @@
 package com.google.android.enterprise.connectedapps.internal;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 
 import android.os.Build.VERSION_CODES;
 import com.google.android.enterprise.connectedapps.Profile;
 import com.google.android.enterprise.connectedapps.internal.CrossProfileCallbackMultiMerger.CrossProfileCallbackMultiMergerCompleteListener;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
 import java.util.Map;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
@@ -188,10 +195,88 @@
   public void construct_noExpectedResults_reportsResultImmediately() {
     int expectedResults = 0;
 
-    CrossProfileCallbackMultiMerger<String> ignoredMerger =
+    CrossProfileCallbackMultiMerger<String> unusedMerger =
         new CrossProfileCallbackMultiMerger<>(expectedResults, stringListener);
 
     assertThat(stringListener.timesResultsPosted).isEqualTo(1);
     assertThat(stringListener.results).isEmpty();
   }
+
+  @Test
+  // Do not ignore if this test turns flaky or times out, this likely highlights a real race
+  // condition.
+  public void listenableFuturesCompletingOnSeparateThreads() throws Exception {
+    Executor executorWithThread1 = Executors.newSingleThreadExecutor();
+    Executor executorWithThread2 = Executors.newSingleThreadExecutor();
+    int aboutThatManyIterationsToBeRacy = 1000;
+
+    for (int i = 0; i < aboutThatManyIterationsToBeRacy; i++) {
+      ListenableFuture<String> future1 = Futures.submit(() -> "Hello", executorWithThread1);
+      ListenableFuture<String> future2 = Futures.submit(() -> "World", executorWithThread2);
+      int expectedResults = 2;
+      SettableFuture<Map<Profile, String>> settableFuture = SettableFuture.create();
+      CrossProfileCallbackMultiMerger<String> merger =
+          new CrossProfileCallbackMultiMerger<>(expectedResults, settableFuture::set);
+      Futures.addCallback(
+          future1, new MergerFutureCallback<String>(profile0, merger), directExecutor());
+      Futures.addCallback(
+          future2, new MergerFutureCallback<String>(profile1, merger), directExecutor());
+
+      Map<Profile, String> results = settableFuture.get();
+
+      assertThat(results).containsExactly(profile0, "Hello", profile1, "World");
+    }
+  }
+
+  @Test
+  // Do not ignore if this test turns flaky or times out, this likely highlights a real race
+  // condition.
+  public void listenableFuturesCompletingWithErrorsOnSeparateThreads() throws Exception {
+    Executor executorWithThread1 = Executors.newSingleThreadExecutor();
+    Executor executorWithThread2 = Executors.newSingleThreadExecutor();
+    int aboutThatManyIterationsToBeRacy = 1000;
+
+    for (int i = 0; i < aboutThatManyIterationsToBeRacy; i++) {
+      ListenableFuture<String> future1 = Futures.submit(() -> "Hello", executorWithThread1);
+      ListenableFuture<String> future2 =
+          Futures.submit(
+              () -> {
+                throw new RuntimeException("Whoopsies");
+              },
+              executorWithThread2);
+      int expectedResults = 2;
+      SettableFuture<Map<Profile, String>> settableFuture = SettableFuture.create();
+      CrossProfileCallbackMultiMerger<String> merger =
+          new CrossProfileCallbackMultiMerger<>(expectedResults, settableFuture::set);
+      Futures.addCallback(
+          future1, new MergerFutureCallback<String>(profile0, merger), directExecutor());
+      Futures.addCallback(
+          future2, new MergerFutureCallback<String>(profile1, merger), directExecutor());
+
+      Map<Profile, String> results = settableFuture.get();
+
+      assertThat(results).containsExactly(profile0, "Hello");
+    }
+  }
+
+  private static class MergerFutureCallback<E> implements FutureCallback<E> {
+
+    private final Profile profileId;
+    private final CrossProfileCallbackMultiMerger<E> merger;
+
+    MergerFutureCallback(Profile profileId, CrossProfileCallbackMultiMerger<E> merger) {
+      this.profileId = profileId;
+      this.merger = merger;
+    }
+
+    @Override
+    public void onSuccess(E result) {
+      merger.onResult(profileId, result);
+    }
+
+    @Override
+    public void onFailure(Throwable t) {
+      merger.missingResult(profileId);
+    }
+  }
 }
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/internal/ParcelCallSenderTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/internal/ParcelCallSenderTest.java
deleted file mode 100644
index dd39fa1..0000000
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/internal/ParcelCallSenderTest.java
+++ /dev/null
@@ -1,125 +0,0 @@
-/*
- * Copyright 2021 Google LLC
- *
- * 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
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.google.android.enterprise.connectedapps.internal;
-
-import static com.google.android.enterprise.connectedapps.StringUtilities.randomString;
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.assertThrows;
-
-import android.os.Parcel;
-import android.os.RemoteException;
-import android.os.TransactionTooLargeException;
-import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.robolectric.RobolectricTestRunner;
-
-@RunWith(RobolectricTestRunner.class)
-public class ParcelCallSenderTest {
-
-  static class TestParcelCallSender extends ParcelCallSender {
-
-    int failPrepareCalls = 0;
-    int failCalls = 0;
-    int failFetchResponse = 0;
-
-    private final ParcelCallReceiver parcelCallReceiver = new ParcelCallReceiver();
-
-    @Override
-    void prepareCall(long callId, int blockId, int totalBytes, byte[] bytes)
-        throws RemoteException {
-      if (failPrepareCalls-- > 0) {
-        throw new TransactionTooLargeException();
-      }
-
-      parcelCallReceiver.prepareCall(callId, blockId, totalBytes, bytes);
-    }
-
-    @Override
-    byte[] call(long callId, int blockId, byte[] bytes) throws RemoteException {
-      if (failCalls-- > 0) {
-        throw new TransactionTooLargeException();
-      }
-
-      return parcelCallReceiver.prepareResponse(
-          callId, parcelCallReceiver.getPreparedCall(callId, blockId, bytes));
-    }
-
-    @Override
-    byte[] fetchResponse(long callId, int blockId) throws RemoteException {
-      if (failFetchResponse-- > 0) {
-        throw new TransactionTooLargeException();
-      }
-
-      return parcelCallReceiver.getPreparedResponse(callId, blockId);
-    }
-  }
-
-  private final TestParcelCallSender parcelCallSender = new TestParcelCallSender();
-  private static final String LARGE_STRING = randomString(1500000); // 3Mb
-  private static final Parcel LARGE_PARCEL = Parcel.obtain();
-
-  static {
-    LARGE_PARCEL.writeString(LARGE_STRING);
-  }
-
-  @Test
-  public void makeParcelCall_prepareCallHasError_retriesUntilSuccess()
-      throws UnavailableProfileException {
-    parcelCallSender.failPrepareCalls = 5;
-
-    assertThat(parcelCallSender.makeParcelCall(LARGE_PARCEL).readString()).isEqualTo(LARGE_STRING);
-  }
-
-  @Test
-  public void makeParcelCall_prepareCallHasError_failsAfter10Retries() {
-    parcelCallSender.failPrepareCalls = 11;
-
-    assertThrows(
-        UnavailableProfileException.class, () -> parcelCallSender.makeParcelCall(LARGE_PARCEL));
-  }
-
-  @Test
-  public void makeParcelCall_callHasError_retriesUntilSuccess() throws UnavailableProfileException {
-    parcelCallSender.failCalls = 5;
-
-    assertThat(parcelCallSender.makeParcelCall(LARGE_PARCEL).readString()).isEqualTo(LARGE_STRING);
-  }
-
-  @Test
-  public void makeParcelCall_callHasError_failsAfter10Retries() {
-    parcelCallSender.failCalls = 11;
-
-    assertThrows(
-        UnavailableProfileException.class, () -> parcelCallSender.makeParcelCall(LARGE_PARCEL));
-  }
-
-  @Test
-  public void makeParcelCall_fetchResponseHasError_retriesUntilSuccess()
-      throws UnavailableProfileException {
-    parcelCallSender.failFetchResponse = 5;
-
-    assertThat(parcelCallSender.makeParcelCall(LARGE_PARCEL).readString()).isEqualTo(LARGE_STRING);
-  }
-
-  @Test
-  public void makeParcelCall_fetchResponseHasError_failsAfter10Retries() {
-    parcelCallSender.failFetchResponse = 11;
-
-    assertThrows(
-        UnavailableProfileException.class, () -> parcelCallSender.makeParcelCall(LARGE_PARCEL));
-  }
-}
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/AutomaticConnectionManagementTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/AutomaticConnectionManagementTest.java
index 3f43c16..6e6a7a8 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/AutomaticConnectionManagementTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/AutomaticConnectionManagementTest.java
@@ -25,22 +25,24 @@
 import android.os.Build.VERSION_CODES;
 import android.os.IBinder;
 import androidx.test.core.app.ApplicationProvider;
+import com.google.android.enterprise.connectedapps.ProfileConnectionHolder;
 import com.google.android.enterprise.connectedapps.RobolectricTestUtilities;
 import com.google.android.enterprise.connectedapps.TestExceptionCallbackListener;
 import com.google.android.enterprise.connectedapps.TestScheduledExecutorService;
+import com.google.android.enterprise.connectedapps.TestStringCallbackListenerImpl;
 import com.google.android.enterprise.connectedapps.TestVoidCallbackListenerImpl;
 import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
 import com.google.android.enterprise.connectedapps.testapp.configuration.TestApplication;
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
 import com.google.android.enterprise.connectedapps.testapp.types.ProfileTestCrossProfileType;
 import com.google.common.util.concurrent.ListenableFuture;
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.Robolectric;
 import org.robolectric.RobolectricTestRunner;
 import org.robolectric.annotation.Config;
-
 import org.robolectric.annotation.LooperMode;
 
 @LooperMode(LooperMode.Mode.LEGACY)
@@ -51,6 +53,8 @@
   private final Application context = ApplicationProvider.getApplicationContext();
   private final TestVoidCallbackListenerImpl testVoidCallbackListener =
       new TestVoidCallbackListenerImpl();
+  private final TestStringCallbackListenerImpl testStringCallbackListener =
+      new TestStringCallbackListenerImpl();
   private final TestExceptionCallbackListener testExceptionCallbackListener =
       new TestExceptionCallbackListener();
   private final TestScheduledExecutorService scheduledExecutorService =
@@ -61,6 +65,7 @@
       new RobolectricTestUtilities(testProfileConnector, scheduledExecutorService);
   private final ProfileTestCrossProfileType profileTestCrossProfileType =
       ProfileTestCrossProfileType.create(testProfileConnector);
+  private final Object connectionHolder = new Object();
 
   @Before
   public void setUp() {
@@ -73,7 +78,11 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testProfileConnector.stopManualConnectionManagement();
+  }
+
+  @After
+  public void teardown() {
+    testProfileConnector.clearConnectionHolders();
   }
 
   @Test
@@ -104,7 +113,7 @@
         .other()
         .asyncVoidMethod(testVoidCallbackListener, testExceptionCallbackListener);
     testUtilities.advanceTimeBySeconds(29);
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     testUtilities.advanceTimeBySeconds(31);
 
@@ -145,7 +154,7 @@
   public void callWhichTakesALongTime_doesNotDisconnectDuringCall() {
     profileTestCrossProfileType
         .other()
-        .asyncVoidMethodWithNonBlockingDelayWith50SecondTimeout(
+        .asyncVoidMethodWithNonBlockingDelay(
             testVoidCallbackListener, /* secondsDelay= */ 40, testExceptionCallbackListener);
 
     testUtilities.advanceTimeBySeconds(31);
@@ -157,7 +166,7 @@
   public void lessThanThirtySecondsAfterCallWhichTakesALongTime_doesNotDisconnect() {
     profileTestCrossProfileType
         .other()
-        .asyncVoidMethodWithNonBlockingDelayWith50SecondTimeout(
+        .asyncVoidMethodWithNonBlockingDelay(
             testVoidCallbackListener, /* secondsDelay= */ 40, testExceptionCallbackListener);
 
     testUtilities.advanceTimeBySeconds(69);
@@ -169,7 +178,7 @@
   public void thirtySecondsAfterCallWhichTakesALongTime_disconnects() {
     profileTestCrossProfileType
         .other()
-        .asyncVoidMethodWithNonBlockingDelayWith50SecondTimeout(
+        .asyncVoidMethodWithNonBlockingDelay(
             testVoidCallbackListener, /* secondsDelay= */ 40, testExceptionCallbackListener);
 
     testUtilities.advanceTimeBySeconds(70);
@@ -193,9 +202,9 @@
   }
 
   @Test
-  public void stopManualConnectionManagement_lessThan30SecondsLater_doesNotDisconnect() {
-    testUtilities.startConnectingAndWait();
-    testProfileConnector.stopManualConnectionManagement();
+  public void clearConnectionHolders_lessThan30SecondsLater_doesNotDisconnect() {
+    testUtilities.addDefaultConnectionHolderAndWait();
+    testProfileConnector.clearConnectionHolders();
 
     testUtilities.advanceTimeBySeconds(29);
 
@@ -203,9 +212,9 @@
   }
 
   @Test
-  public void stopManualConnectionManagement_moreThan30SecondsLater_disconnects() {
-    testUtilities.startConnectingAndWait();
-    testProfileConnector.stopManualConnectionManagement();
+  public void clearConnectionHolders_moreThan30SecondsLater_disconnects() {
+    testUtilities.addDefaultConnectionHolderAndWait();
+    testProfileConnector.clearConnectionHolders();
 
     testUtilities.advanceTimeBySeconds(29);
 
@@ -213,6 +222,90 @@
   }
 
   @Test
+  public void addConnectionHolder_moreThan30SecondsLater_doesNotDisconnect() {
+    testProfileConnector.addConnectionHolder(this);
+
+    testUtilities.advanceTimeBySeconds(31);
+
+    assertThat(testProfileConnector.isConnected()).isTrue();
+  }
+
+  @Test
+  public void removeConnectionHolder_lessThan30SecondsLater_doesNotDisconnect() {
+    testProfileConnector.addConnectionHolder(this);
+    testProfileConnector.removeConnectionHolder(this);
+
+    testUtilities.advanceTimeBySeconds(29);
+
+    assertThat(testProfileConnector.isConnected()).isTrue();
+  }
+
+  @Test
+  public void removeConnectionHolder_moreThan30SecondsLater_disconnects() {
+    testProfileConnector.addConnectionHolder(this);
+    testProfileConnector.removeConnectionHolder(this);
+
+    testUtilities.advanceTimeBySeconds(31);
+
+    assertThat(testProfileConnector.isConnected()).isFalse();
+  }
+
+  @Test
+  public void removeConnectionHolder_removingAlias_moreThan30SecondsLater_disconnects() {
+    testProfileConnector.addConnectionHolder(this);
+    testProfileConnector.addConnectionHolderAlias(connectionHolder, this);
+    testProfileConnector.removeConnectionHolder(connectionHolder);
+
+    testUtilities.advanceTimeBySeconds(31);
+
+    assertThat(testProfileConnector.isConnected()).isFalse();
+  }
+
+  @Test
+  public void removeConnectionHolder_removingWrapper_moreThan30SecondsLater_disconnects() {
+    ProfileConnectionHolder connectionHolder = testProfileConnector.addConnectionHolder(this);
+    testProfileConnector.removeConnectionHolder(connectionHolder);
+
+    testUtilities.advanceTimeBySeconds(31);
+
+    assertThat(testProfileConnector.isConnected()).isFalse();
+  }
+
+  @Test
+  public void
+      removeConnectionHolder_stillAnotherConnectionHolder_moreThan30SecondsLater_doesNotDisconnect() {
+    testProfileConnector.addConnectionHolder(this);
+    testProfileConnector.addConnectionHolder(connectionHolder);
+    testProfileConnector.removeConnectionHolder(this);
+
+    testUtilities.advanceTimeBySeconds(31);
+
+    assertThat(testProfileConnector.isConnected()).isTrue();
+  }
+
+  @Test
+  public void removeConnectionHolder_removingCallback_noResultOnCallback_moreThan30SecondsLater_disconnects() {
+    profileTestCrossProfileType.other()
+        .asyncMethodWhichNeverCallsBack(testStringCallbackListener, testExceptionCallbackListener);
+    testProfileConnector.removeConnectionHolder(testStringCallbackListener);
+
+    testUtilities.advanceTimeBySeconds(31);
+
+    assertThat(testProfileConnector.isConnected()).isFalse();
+  }
+
+  @Test
+  public void removeConnectionHolder_removingFuture_noResultOnFuture_moreThan30SecondsLater_disconnects() {
+    ListenableFuture<Void> future = profileTestCrossProfileType.other()
+        .listenableFutureMethodWhichNeverSetsTheValue();
+    testProfileConnector.removeConnectionHolder(future);
+
+    testUtilities.advanceTimeBySeconds(31);
+
+    assertThat(testProfileConnector.isConnected()).isFalse();
+  }
+
+  @Test
   public void asyncCall_doesNotHavePermission_failsImmediately() {
     testUtilities.denyPermissions(INTERACT_ACROSS_USERS);
 
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/AvailabilityListenerTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/AvailabilityListenerTest.java
index d999d22..54e77ca 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/AvailabilityListenerTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/AvailabilityListenerTest.java
@@ -63,7 +63,7 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testProfileConnector.stopManualConnectionManagement();
+    testProfileConnector.clearConnectionHolders();
   }
 
   @Test
@@ -71,7 +71,7 @@
       throws InterruptedException, ExecutionException {
     testUtilities.turnOnWorkProfile();
     TestAvailabilityListener availabilityListener = new TestAvailabilityListener();
-    testProfileConnector.registerAvailabilityListener(availabilityListener);
+    testProfileConnector.addAvailabilityListener(availabilityListener);
 
     ListenableFuture<Void> unusedFuture = type.other().listenableFutureVoidMethod();
     testUtilities.advanceTimeBySeconds(1);
@@ -85,7 +85,7 @@
   public void temporaryConnectionError_inProgressCall_availabilityListenerFires() {
     testUtilities.turnOnWorkProfile();
     TestAvailabilityListener availabilityListener = new TestAvailabilityListener();
-    testProfileConnector.registerAvailabilityListener(availabilityListener);
+    testProfileConnector.addAvailabilityListener(availabilityListener);
 
     ListenableFuture<Void> unusedFuture =
         type.other().listenableFutureVoidMethodWithNonBlockingDelay(/* secondsDelay= */ 5);
@@ -95,18 +95,4 @@
     assertThat(availabilityListener.availabilityChangedCount()).isGreaterThan(0);
     assertThat(testProfileConnector.isAvailable()).isTrue();
   }
-
-  @Test
-  public void temporaryConnectionError_noInProgressCall_availabilityListenerDoesNotFire() {
-    testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
-    TestAvailabilityListener availabilityListener = new TestAvailabilityListener();
-    testProfileConnector.registerAvailabilityListener(availabilityListener);
-
-    testUtilities.simulateDisconnectingServiceConnection();
-    testUtilities.advanceTimeBySeconds(1);
-
-    assertThat(availabilityListener.availabilityChangedCount()).isEqualTo(0);
-    assertThat(testProfileConnector.isAvailable()).isTrue();
-  }
 }
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesAsyncTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesAsyncTest.java
index cb89aad..6905053 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesAsyncTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesAsyncTest.java
@@ -31,6 +31,7 @@
 import com.google.android.enterprise.connectedapps.TestScheduledExecutorService;
 import com.google.android.enterprise.connectedapps.TestStringCallbackListenerMultiImpl;
 import com.google.android.enterprise.connectedapps.TestVoidCallbackListenerMultiImpl;
+import com.google.android.enterprise.connectedapps.testapp.CustomError;
 import com.google.android.enterprise.connectedapps.testapp.CustomRuntimeException;
 import com.google.android.enterprise.connectedapps.testapp.configuration.TestApplication;
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
@@ -82,7 +83,7 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testProfileConnector.stopManualConnectionManagement();
+    testProfileConnector.clearConnectionHolders();
   }
 
   @Test
@@ -177,36 +178,21 @@
   }
 
   @Test
-  public void both_async_timeoutSet_doesTimeout() {
+  public void both_async_doesNotTimeout() {
     profileTestCrossProfileType
         .both()
-        .asyncIdentityStringMethodWithNonBlockingDelayWith3SecondTimeout(
-            STRING, stringCallback, /* secondsDelay= */ 5);
-
-    testUtilities.advanceTimeBySeconds(6);
-
-    assertThat(stringCallback.stringCallbackValues.get(currentProfileIdentifier)).isEqualTo(STRING);
-    assertThat(stringCallback.stringCallbackValues).doesNotContainKey(otherProfileIdentifier);
-  }
-
-  @Test
-  public void both_async_timeoutSetByCaller_doesTimeout() {
-    profileTestCrossProfileType
-        .both()
-        .timeout(3000)
         .asyncIdentityStringMethodWithNonBlockingDelay(
-            STRING, stringCallback, /* secondsDelay= */ 5);
+            STRING, stringCallback, /* secondsDelay= */ 100);
 
-    testUtilities.advanceTimeBySeconds(6);
+    testUtilities.advanceTimeBySeconds(99);
 
-    assertThat(stringCallback.stringCallbackValues.get(currentProfileIdentifier)).isEqualTo(STRING);
-    assertThat(stringCallback.stringCallbackValues).doesNotContainKey(otherProfileIdentifier);
+    assertThat(stringCallback.stringCallbackValues).isNull();
   }
 
   @Test
   public void both_async_throwsRuntimeException_exceptionThrownOnCurrentProfileIsThrown() {
     assertThrows(
-        CustomRuntimeException.class,
+        Exception.class,
         () ->
             profileTestCrossProfileType
                 .both()
@@ -214,10 +200,30 @@
   }
 
   @Test
+  public void both_async_throwsError_errorThrownOnCurrentProfileIsThrown() {
+    assertThrows(
+        CustomError.class,
+        () ->
+            profileTestCrossProfileType
+                .both()
+                .asyncStringMethodWhichThrowsError(stringCallback));
+  }
+
+  @Test
   public void both_async_contextArgument_works() {
     profileTestCrossProfileType.both().asyncIsContextArgumentPassed(booleanCallback);
 
     assertThat(booleanCallback.booleanCallbackValues.get(currentProfileIdentifier)).isTrue();
     assertThat(booleanCallback.booleanCallbackValues.get(otherProfileIdentifier)).isTrue();
   }
+
+  @Test
+  public void both_async_passesMultipleValues_onlyReceivesFirstValues() {
+    stringCallback.numberOfCallbacks = 0;
+
+    profileTestCrossProfileType.both()
+        .asyncIdentityStringMethodWhichCallsBackTwice(STRING, stringCallback);
+
+    assertThat(stringCallback.numberOfCallbacks).isEqualTo(1);
+  }
 }
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesListenableFutureTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesListenableFutureTest.java
index 78b70c6..f44c9e1 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesListenableFutureTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesListenableFutureTest.java
@@ -28,7 +28,7 @@
 import com.google.android.enterprise.connectedapps.Profile;
 import com.google.android.enterprise.connectedapps.RobolectricTestUtilities;
 import com.google.android.enterprise.connectedapps.TestScheduledExecutorService;
-import com.google.android.enterprise.connectedapps.testapp.CustomRuntimeException;
+import com.google.android.enterprise.connectedapps.testapp.CustomError;
 import com.google.android.enterprise.connectedapps.testapp.configuration.TestApplication;
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
 import com.google.android.enterprise.connectedapps.testapp.types.ProfileTestCrossProfileType;
@@ -75,7 +75,7 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testProfileConnector.stopManualConnectionManagement();
+    testProfileConnector.clearConnectionHolders();
   }
 
   @Test
@@ -222,43 +222,24 @@
   }
 
   @Test
-  public void both_listenableFuture_timeoutSet_doesTimeout()
+  public void both_listenableFuture_doesNotTimeout()
       throws ExecutionException, InterruptedException {
     ListenableFuture<Map<Profile, String>> future =
         profileTestCrossProfileType
             .both()
-            .listenableFutureIdentityStringMethodWithNonBlockingDelayWith3SecondTimeout(
-                STRING, /* secondsDelay= */ 5);
-
-    testUtilities.advanceTimeBySeconds(6);
-
-    Map<Profile, String> results = future.get();
-    assertThat(results.get(currentProfileIdentifier)).isEqualTo(STRING);
-    assertThat(results).doesNotContainKey(otherProfileIdentifier);
-  }
-
-  @Test
-  public void both_listenableFuture_timeoutSetByCaller_doesTimeout()
-      throws ExecutionException, InterruptedException {
-    ListenableFuture<Map<Profile, String>> future =
-        profileTestCrossProfileType
-            .both()
-            .timeout(3000)
             .listenableFutureIdentityStringMethodWithNonBlockingDelay(
-                STRING, /* secondsDelay= */ 5);
+                STRING, /* secondsDelay= */ 100);
 
-    testUtilities.advanceTimeBySeconds(6);
+    testUtilities.advanceTimeBySeconds(99);
 
-    Map<Profile, String> results = future.get();
-    assertThat(results.get(currentProfileIdentifier)).isEqualTo(STRING);
-    assertThat(results).doesNotContainKey(otherProfileIdentifier);
+    assertThat(future.isDone()).isFalse();
   }
 
   @Test
   public void
       both_listenableFuture_throwsRuntimeException_exceptionThrownOnCurrentProfileIsThrown() {
     assertThrows(
-        CustomRuntimeException.class,
+        Exception.class,
         () ->
             profileTestCrossProfileType
                 .both()
@@ -266,6 +247,17 @@
   }
 
   @Test
+  public void
+  both_listenableFuture_throwsError_errorThrownOnCurrentProfileIsThrown() {
+    assertThrows(
+        CustomError.class,
+        () ->
+            profileTestCrossProfileType
+                .both()
+                .listenableFutureVoidMethodWhichThrowsError());
+  }
+
+  @Test
   public void both_listenableFuture_contextArgument_works() throws Exception {
     ListenableFuture<Map<Profile, Boolean>> resultFuture =
         profileTestCrossProfileType.both().futureIsContextArgumentPassed();
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesManualAsyncTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesManualAsyncTest.java
index 66b7ef1..feaeb6c 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesManualAsyncTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesManualAsyncTest.java
@@ -77,13 +77,13 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
   }
 
   @Test
   public void both_async_manualConnection_isBound_calledOnBothProfiles() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType.both().asyncVoidMethod(voidCallback);
 
@@ -94,7 +94,7 @@
   @Test
   public void both_async_manualConnection_isBound_resultContainsBothProfilesResults() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType.both().asyncIdentityStringMethod(STRING, stringCallback);
 
@@ -105,7 +105,7 @@
   @Test // This behaviour is expected right now but will change
   public void both_async_manualConnection_isBound_blockingMethod_blocks() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType
         .both()
@@ -117,7 +117,7 @@
   @Test
   public void both_async_manualConnection_isBound_nonblockingMethod_doesNotBlock() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType
         .both()
@@ -129,7 +129,7 @@
   @Test
   public void both_async_manualConnection_isBound_nonblockingMethod_doesCallback() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType
         .both()
@@ -141,7 +141,7 @@
 
   @Test
   public void both_async_manualConnection_isNotBound_calledOnOnlyCurrentProfile() {
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
     testUtilities.turnOffWorkProfile();
 
     profileTestCrossProfileType.both().asyncVoidMethod(voidCallback);
@@ -164,7 +164,7 @@
   @Test
   public void both_async_manualConnection_isBound_becomesUnbound_calledOnBothProfiles() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
     profileTestCrossProfileType
         .both()
         .asyncVoidMethodWithNonBlockingDelay(voidCallback, /* secondsDelay= */ 5);
@@ -182,7 +182,7 @@
   @Test
   public void both_async_manualConnection_isBound_becomesUnbound_callbackFires() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
     profileTestCrossProfileType
         .both()
         .asyncVoidMethodWithNonBlockingDelay(voidCallback, /* secondsDelay= */ 5);
@@ -197,7 +197,7 @@
   public void
       both_async_manualConnection_connectionDropsDuringCall_resultContainsOnlyCurrentProfilesResult() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
     profileTestCrossProfileType
         .both()
         .asyncIdentityStringMethodWithNonBlockingDelay(
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesManualListenableFutureTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesManualListenableFutureTest.java
index a3ca7eb..2e2ed24 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesManualListenableFutureTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesManualListenableFutureTest.java
@@ -71,15 +71,15 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
   }
 
   @Test
   public void both_listenableFuture_manualConnection_isBound_calledOnBothProfiles()
       throws ExecutionException, InterruptedException {
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType.both().listenableFutureVoidMethod().get();
 
@@ -90,9 +90,9 @@
   @Test
   public void both_listenableFuture_manualConnection_isBound_resultContainsBothProfilesResults()
       throws ExecutionException, InterruptedException {
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     Map<Profile, String> results =
         profileTestCrossProfileType.both().listenableFutureIdentityStringMethod(STRING).get();
@@ -103,9 +103,9 @@
 
   @Test // This behaviour is expected right now but will change
   public void both_listenableFuture_manualConnection_isBound_blockingMethod_blocks() {
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     ListenableFuture<Map<Profile, Void>> future =
         profileTestCrossProfileType
@@ -118,7 +118,7 @@
   @Test
   public void both_listenableFuture_manualConnection_isBound_nonblockingMethod_doesNotBlock() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     ListenableFuture<Map<Profile, Void>> future =
         profileTestCrossProfileType
@@ -131,7 +131,7 @@
   @Test
   public void both_listenableFuture_manualConnection_isBound_nonblockingMethod_doesCallback() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     ListenableFuture<Map<Profile, Void>> future =
         profileTestCrossProfileType
@@ -145,7 +145,7 @@
   @Test
   public void both_listenableFuture_manualConnection_isNotBound_calledOnOnlyCurrentProfile()
       throws ExecutionException, InterruptedException {
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
     testUtilities.turnOffWorkProfile();
 
     profileTestCrossProfileType.both().listenableFutureVoidMethod().get();
@@ -158,7 +158,7 @@
   public void
       both_listenableFuture_manualConnection_isNotBound_resultContainsOnlyCurrentProfilesResult()
           throws ExecutionException, InterruptedException {
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
     testUtilities.turnOffWorkProfile();
 
     Map<Profile, String> results =
@@ -171,7 +171,7 @@
   @Test
   public void both_listenableFuture_manualConnection_isBound_becomesUnbound_calledOnBothProfiles() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
     ListenableFuture<Map<Profile, Void>> unusedFuture =
         profileTestCrossProfileType
             .both()
@@ -189,7 +189,7 @@
   @Test
   public void both_listenableFuture_manualConnection_isBound_becomesUnbound_callbackFires() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
     ListenableFuture<Map<Profile, Void>> future =
         profileTestCrossProfileType
             .both()
@@ -203,7 +203,7 @@
   @Test
   public void both_listenableFuture_manualConnection_profilesWithExceptionsAreNotIncludedInResults()
       throws ExecutionException, InterruptedException {
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
     ListenableFuture<Map<Profile, Void>> future =
         profileTestCrossProfileType
             .both()
@@ -216,7 +216,7 @@
   public void
       both_listenableFuture_manualConnection_connectionDropsDuringCall_resultContainsOnlyCurrentProfilesResult()
           throws ExecutionException, InterruptedException {
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
     ListenableFuture<Map<Profile, String>> future =
         profileTestCrossProfileType
             .both()
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesSynchronousTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesSynchronousTest.java
index 20ee748..5289c4f 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesSynchronousTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/BothProfilesSynchronousTest.java
@@ -29,6 +29,7 @@
 import com.google.android.enterprise.connectedapps.RobolectricTestUtilities;
 import com.google.android.enterprise.connectedapps.TestScheduledExecutorService;
 import com.google.android.enterprise.connectedapps.exceptions.ProfileRuntimeException;
+import com.google.android.enterprise.connectedapps.testapp.CustomError;
 import com.google.android.enterprise.connectedapps.testapp.CustomRuntimeException;
 import com.google.android.enterprise.connectedapps.testapp.configuration.TestApplication;
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
@@ -73,13 +74,13 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
   }
 
   @Test
   public void both_synchronous_isBound_resultContainsBothProfileResults() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     Map<Profile, String> result = profileTestCrossProfileType.both().identityStringMethod(STRING);
 
@@ -112,6 +113,20 @@
   }
 
   @Test
+  public void both_synchronous_throwsError_errorThrownOnCurrentProfileIsThrown() {
+    // Since the exception is thrown on both sides, which is thrown first is not deterministic.
+    // This test just confirms one of the two is thrown
+    try {
+      profileTestCrossProfileType.both().methodWhichThrowsError();
+      fail();
+    } catch (CustomError expected) {
+
+    } catch (ProfileRuntimeException expected) {
+      assertThat(expected).hasCauseThat().isInstanceOf(CustomError.class);
+    }
+  }
+
+  @Test
   public void both_synchronous_contextArgument_works() {
     Map<Profile, Boolean> result = profileTestCrossProfileType.both().isContextArgumentPassed();
 
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileCallbackReceiverTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileCallbackReceiverTest.java
index b4118b1..f7a7029 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileCallbackReceiverTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileCallbackReceiverTest.java
@@ -20,7 +20,7 @@
 import com.google.android.enterprise.connectedapps.TestStringCrossProfileCallback;
 import com.google.android.enterprise.connectedapps.internal.Bundler;
 import com.google.android.enterprise.connectedapps.testapp.Profile_TestStringCallbackListener_Receiver;
-import com.google.android.enterprise.connectedapps.testapp.types.ProfileTestCrossProfileType_Bundler;
+import com.google.android.enterprise.connectedapps.testapp.types.TestCrossProfileType_Bundler;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
@@ -31,7 +31,7 @@
   private static final String STRING = "String";
 
   private final TestStringCrossProfileCallback callback = new TestStringCrossProfileCallback();
-  private final Bundler bundler = new ProfileTestCrossProfileType_Bundler();
+  private final Bundler bundler = new TestCrossProfileType_Bundler();
   private final Profile_TestStringCallbackListener_Receiver receiver =
       new Profile_TestStringCallbackListener_Receiver(callback, bundler);
 
@@ -46,6 +46,6 @@
   public void asyncCallbackListenerReceiver_bundlesParams() {
     receiver.stringCallback(STRING);
 
-    assertThat(callback.lastReceivedMethodParam).isEqualTo(STRING);
+    assertThat(callback.lastReceivedMethodValueParam).isEqualTo(STRING);
   }
 }
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileCallbackSenderTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileCallbackSenderTest.java
index 5bd46b2..248b821 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileCallbackSenderTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileCallbackSenderTest.java
@@ -24,7 +24,7 @@
 import com.google.android.enterprise.connectedapps.testapp.Profile_TestStringCallbackListener_Receiver;
 import com.google.android.enterprise.connectedapps.testapp.Profile_TestStringCallbackListener_Sender;
 import com.google.android.enterprise.connectedapps.testapp.TestStringCallbackListener;
-import com.google.android.enterprise.connectedapps.testapp.types.ProfileTestCrossProfileType_Bundler;
+import com.google.android.enterprise.connectedapps.testapp.types.TestCrossProfileType_Bundler;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
@@ -44,7 +44,7 @@
   private final TestExceptionCallbackListener exceptionCallback =
       new TestExceptionCallbackListener();
   private final TestStringCallbackListenerImpl callback = new TestStringCallbackListenerImpl();
-  private final Bundler bundler = new ProfileTestCrossProfileType_Bundler();
+  private final Bundler bundler = new TestCrossProfileType_Bundler();
   private final Profile_TestStringCallbackListener_Sender sender =
       new Profile_TestStringCallbackListener_Sender(callback, exceptionCallback, bundler);
   private final Profile_TestStringCallbackListener_Receiver receiver =
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileInterfaceTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileInterfaceTest.java
index eb44ea0..7f6d249 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileInterfaceTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileInterfaceTest.java
@@ -72,7 +72,7 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
   }
 
   @Test
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileServiceTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileServiceTest.java
index f9af23b..5414300 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileServiceTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileServiceTest.java
@@ -16,8 +16,6 @@
 package com.google.android.enterprise.connectedapps.robotests;
 
 import static com.google.android.enterprise.connectedapps.SharedTestUtilities.INTERACT_ACROSS_USERS;
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
 import static org.robolectric.Shadows.shadowOf;
 import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
 
@@ -28,7 +26,6 @@
 import androidx.test.core.app.ApplicationProvider;
 import com.google.android.enterprise.connectedapps.RobolectricTestUtilities;
 import com.google.android.enterprise.connectedapps.TestScheduledExecutorService;
-import com.google.android.enterprise.connectedapps.exceptions.ProfileRuntimeException;
 import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
 import com.google.android.enterprise.connectedapps.testapp.configuration.TestApplication;
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
@@ -69,7 +66,7 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
   }
 
   @After
@@ -79,7 +76,7 @@
 
   @Test
   public void crossProfileMethodCall_doesNotThrowException() throws UnavailableProfileException {
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType.other().voidMethod();
   }
@@ -90,23 +87,8 @@
     ShadowBinder.setCallingUid(10);
     shadowOf(context.getPackageManager())
         .setPackagesForUid(10, DIFFERENT_PACKAGE_NAME, context.getPackageName());
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType.other().voidMethod();
   }
-
-  @Test
-  public void crossProfileMethodCall_callingFromInvalidPackage_throwsWrappedIllegalStateException()
-      throws UnavailableProfileException {
-    testUtilities.startConnectingAndWait();
-    ShadowBinder.setCallingUid(10);
-    shadowOf(context.getPackageManager()).setPackagesForUid(10, DIFFERENT_PACKAGE_NAME);
-
-    try {
-      profileTestCrossProfileType.other().voidMethod();
-      fail();
-    } catch (ProfileRuntimeException expected) {
-      assertThat(expected).hasCauseThat().isInstanceOf(IllegalStateException.class);
-    }
-  }
 }
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileTypeProfileTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileTypeProfileTest.java
index 3adf174..1ffe662 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileTypeProfileTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossProfileTypeProfileTest.java
@@ -80,7 +80,7 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
   }
 
   @Test
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossUserTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossUserTest.java
deleted file mode 100644
index 10b7d1f..0000000
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossUserTest.java
+++ /dev/null
@@ -1,81 +0,0 @@
-/*
- * Copyright 2021 Google LLC
- *
- * 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
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.google.android.enterprise.connectedapps.robotests;
-
-import static com.google.android.enterprise.connectedapps.SharedTestUtilities.INTERACT_ACROSS_USERS;
-import static com.google.common.truth.Truth.assertThat;
-
-import android.app.Application;
-import android.app.Service;
-import android.os.Build.VERSION_CODES;
-import android.os.IBinder;
-import androidx.test.core.app.ApplicationProvider;
-import com.google.android.enterprise.connectedapps.RobolectricTestUtilities;
-import com.google.android.enterprise.connectedapps.TestScheduledExecutorService;
-import com.google.android.enterprise.connectedapps.testapp.crossuser.ProfileTestCrossUserType;
-import com.google.android.enterprise.connectedapps.testapp.crossuser.TestCrossUserConfiguration;
-import com.google.android.enterprise.connectedapps.testapp.crossuser.TestCrossUserConnector;
-import com.google.android.enterprise.connectedapps.testapp.crossuser.TestCrossUserStringCallbackListenerImpl;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.robolectric.Robolectric;
-import org.robolectric.RobolectricTestRunner;
-import org.robolectric.annotation.Config;
-
-@RunWith(RobolectricTestRunner.class)
-@Config(minSdk = VERSION_CODES.O)
-public class CrossUserTest {
-
-  private static final String STRING = "String";
-
-  private final Application context = ApplicationProvider.getApplicationContext();
-  private final TestScheduledExecutorService scheduledExecutorService =
-      new TestScheduledExecutorService();
-  private final TestCrossUserConnector testCrossUserConnector =
-      TestCrossUserConnector.create(context, scheduledExecutorService);
-  private final RobolectricTestUtilities testUtilities =
-      new RobolectricTestUtilities(testCrossUserConnector, scheduledExecutorService);
-  private final ProfileTestCrossUserType profileCrossUserType =
-      ProfileTestCrossUserType.create(testCrossUserConnector);
-  private final TestCrossUserStringCallbackListenerImpl crossUserStringCallback =
-      new TestCrossUserStringCallbackListenerImpl();
-
-  @Before
-  public void setUp() {
-    Service profileAwareService = Robolectric.setupService(TestCrossUserConfiguration.getService());
-    testUtilities.initTests();
-    IBinder binder = profileAwareService.onBind(/* intent= */ null);
-    testUtilities.setBinding(binder, TestCrossUserConnector.class.getName());
-    testUtilities.createWorkUser();
-    testUtilities.turnOnWorkProfile();
-    testUtilities.setRunningOnPersonalProfile();
-    testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
-    testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testCrossUserConnector.stopManualConnectionManagement();
-  }
-
-  @Test
-  // This test covers all CrossUser annotations
-  public void passArgumentToCallback_works() {
-    testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
-
-    profileCrossUserType.current().passString(STRING, crossUserStringCallback);
-
-    assertThat(crossUserStringCallback.stringCallbackValue).isEqualTo(STRING);
-  }
-}
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossUserTestTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossUserTestTest.java
new file mode 100644
index 0000000..80bd1d5
--- /dev/null
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CrossUserTestTest.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.robotests;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.app.Application;
+import android.os.UserHandle;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.enterprise.connectedapps.RobolectricTestUtilities;
+import com.google.android.enterprise.connectedapps.TestScheduledExecutorService;
+import com.google.android.enterprise.connectedapps.testapp.crossuser.AppCrossUserConfiguration;
+import com.google.android.enterprise.connectedapps.testapp.crossuser.FakeAppCrossUserConnector;
+import com.google.android.enterprise.connectedapps.testapp.crossuser.FakeUserNotesManager;
+import com.google.android.enterprise.connectedapps.testapp.crossuser.NotesManager;
+import com.google.android.enterprise.connectedapps.testing.annotations.CrossUserTest;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@CrossUserTest(configuration = AppCrossUserConfiguration.class)
+@RunWith(RobolectricTestRunner.class)
+public class CrossUserTestTest {
+
+  private static final String NOTE_1 = "I stayed at home today";
+  private static final String NOTE_2 = "I am hungry";
+  private static final String NOTE_3 = "I should eat something, probably";
+
+  private final Application context = ApplicationProvider.getApplicationContext();
+  private final TestScheduledExecutorService executor = new TestScheduledExecutorService();
+  private final RobolectricTestUtilities utilities =
+      new RobolectricTestUtilities(context, executor);
+
+  private final UserHandle user4Handle = utilities.createCustomUser(4);
+  private final UserHandle user5Handle = utilities.createCustomUser(5);
+
+  private final NotesManager user4NotesManager = new NotesManager();
+  private final NotesManager user5NotesManager = new NotesManager();
+
+  private final FakeAppCrossUserConnector connector = new FakeAppCrossUserConnector(context);
+  private final FakeUserNotesManager defaultFakeUserNotesManager =
+      FakeUserNotesManager.builder()
+          .user(user4Handle, user4NotesManager)
+          .user(user5Handle, user5NotesManager)
+          .connector(connector)
+          .build();
+
+  @Before
+  public void setUp() {
+    connector.setRunningOnUser(user4Handle);
+    connector.turnOnUser(user5Handle);
+  }
+
+  @Test
+  public void currentCall_returnsCorrectNotesManager()
+      throws ExecutionException, InterruptedException {
+    user4NotesManager.addNote(NOTE_1);
+    user4NotesManager.addNote(NOTE_2);
+    user5NotesManager.addNote(NOTE_3);
+
+    ListenableFuture<Set<String>> currentUserNotesFuture =
+        defaultFakeUserNotesManager.current().getNotesFuture();
+
+    assertThat(currentUserNotesFuture.get()).containsExactly(NOTE_1, NOTE_2);
+  }
+
+  @Test
+  public void userCall_returnsCorrectNotesManager()
+      throws ExecutionException, InterruptedException {
+    user4NotesManager.addNote(NOTE_1);
+    user5NotesManager.addNote(NOTE_2);
+    user5NotesManager.addNote(NOTE_3);
+
+    ListenableFuture<Set<String>> user5NotesFuture =
+        defaultFakeUserNotesManager.user(user5Handle).getNotesFuture();
+
+    assertThat(user5NotesFuture.get()).containsExactly(NOTE_2, NOTE_3);
+  }
+
+  @Test
+  public void currentCall_runningUserNull_throwsException() {
+    connector.setRunningOnUser(null);
+
+    assertThrows(UnsupportedOperationException.class, defaultFakeUserNotesManager::current);
+  }
+
+  @Test
+  public void currentCall_noTargetType_throwsException() {
+    FakeUserNotesManager noTargetTypeNotesManager =
+        FakeUserNotesManager.builder().connector(connector).build();
+
+    assertThrows(UnsupportedOperationException.class, noTargetTypeNotesManager::current);
+  }
+
+  @Test
+  public void userCall_userHandleNull_throwsException() {
+    assertThrows(IllegalArgumentException.class, () -> defaultFakeUserNotesManager.user(null));
+  }
+
+  @Test
+  public void userCall_noTargetType_throwsException() {
+    FakeUserNotesManager noTargetTypeNotesManager =
+        FakeUserNotesManager.builder().connector(connector).build();
+
+    assertThrows(
+        UnsupportedOperationException.class, () -> noTargetTypeNotesManager.user(user4Handle));
+  }
+
+  @Test
+  public void userCall_userTurnedOff_throwsException() {
+    connector.turnOffUser(user5Handle);
+
+    assertThrows(
+        ExecutionException.class,
+        () -> defaultFakeUserNotesManager.user(user5Handle).getNotesFuture().get());
+  }
+
+  @Test
+  public void builder_noConnector_throws() {
+    assertThrows(IllegalStateException.class, () -> FakeUserNotesManager.builder().build());
+  }
+}
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CurrentProfileTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CurrentProfileTest.java
index 7693a54..cd26001 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CurrentProfileTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/CurrentProfileTest.java
@@ -95,13 +95,13 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
   }
 
   @Test
   public void current_isBound_callsMethod() throws UnavailableProfileException {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     assertThat(profileTestCrossProfileType.current().identityStringMethod(STRING))
         .isEqualTo(STRING);
@@ -118,7 +118,7 @@
   @Test
   public void current_async_isBound_callsMethod() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType.current().asyncVoidMethod(voidCallbackListener);
 
@@ -128,7 +128,7 @@
   @Test
   public void current_synchronous_isBound_automaticConnectionManagement_callsMethod() {
     testUtilities.turnOnWorkProfile();
-    testProfileConnector.stopManualConnectionManagement();
+    testProfileConnector.clearConnectionHolders();
     ListenableFuture<Void> ignored =
         profileTestCrossProfileType.other().listenableFutureVoidMethod(); // Causes it to bind
 
@@ -191,7 +191,7 @@
   public void current_listenableFuture_isBound_callsMethod()
       throws ExecutionException, InterruptedException {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType.current().listenableFutureVoidMethod().get();
 
@@ -268,9 +268,7 @@
   @Test
   public void current_listenableFuture_doesNotTimeout() {
     ListenableFuture<Void> future =
-        profileTestCrossProfileType
-            .current()
-            .listenableFutureMethodWhichNeverSetsTheValueWith5SecondTimeout();
+        profileTestCrossProfileType.current().listenableFutureMethodWhichNeverSetsTheValue();
     testUtilities.advanceTimeBySeconds(10);
 
     assertFutureDoesNotHaveException(future, UnavailableProfileException.class);
@@ -278,10 +276,8 @@
 
   @Test
   public void current_async_doesNotTimeout() {
-    profileTestCrossProfileType
-        .current()
-        .asyncMethodWhichNeverCallsBackWith5SecondTimeout(stringCallbackListener);
-    testUtilities.advanceTimeBySeconds(10);
+    profileTestCrossProfileType.current().asyncMethodWhichNeverCallsBack(stringCallbackListener);
+    testUtilities.advanceTimeBySeconds(100);
 
     assertThat(stringCallbackListener.callbackMethodCalls).isEqualTo(0);
   }
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/DefaultUserBinderTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/DefaultUserBinderTest.java
new file mode 100644
index 0000000..c22ca3a
--- /dev/null
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/DefaultUserBinderTest.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.robotests;
+
+import static android.Manifest.permission.INTERACT_ACROSS_PROFILES;
+import static com.google.android.enterprise.connectedapps.SharedTestUtilities.INTERACT_ACROSS_USERS;
+import static com.google.android.enterprise.connectedapps.SharedTestUtilities.INTERACT_ACROSS_USERS_FULL;
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.Application;
+import android.os.Build.VERSION_CODES;
+import android.os.UserHandle;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.enterprise.connectedapps.DefaultUserBinder;
+import com.google.android.enterprise.connectedapps.RobolectricTestUtilities;
+import com.google.android.enterprise.connectedapps.TestScheduledExecutorService;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = VERSION_CODES.O)
+public final class DefaultUserBinderTest {
+
+  private static final int OTHER_USER_ID = 5;
+
+  private final Application context = ApplicationProvider.getApplicationContext();
+  private final TestScheduledExecutorService scheduledExecutorService =
+      new TestScheduledExecutorService();
+  private final RobolectricTestUtilities utilities =
+      new RobolectricTestUtilities(context, scheduledExecutorService);
+
+  private final UserHandle otherUserHandle = utilities.createCustomUser(OTHER_USER_ID);
+  private final DefaultUserBinder binder = new DefaultUserBinder(otherUserHandle);
+
+  @Before
+  public void setUp() {
+    utilities.denyPermissions(
+        INTERACT_ACROSS_PROFILES, INTERACT_ACROSS_USERS, INTERACT_ACROSS_USERS_FULL);
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void hasPermissionToBind_isTargetUserProfile_hasInteractAcrossProfiles_true() {
+    utilities.tryAddTargetUserProfile(otherUserHandle);
+    utilities.setRequestsPermissions(INTERACT_ACROSS_PROFILES);
+    utilities.grantPermissions(INTERACT_ACROSS_PROFILES);
+
+    assertThat(binder.hasPermissionToBind(context)).isTrue();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void hasPermissionToBind_isNotTargetUserProfile_hasInteractAcrossProfiles_false() {
+    utilities.tryRemoveTargetUserProfile(otherUserHandle);
+    utilities.setRequestsPermissions(INTERACT_ACROSS_PROFILES);
+    utilities.grantPermissions(INTERACT_ACROSS_PROFILES);
+
+    assertThat(binder.hasPermissionToBind(context)).isFalse();
+  }
+
+  @Test
+  @Config(minSdk = VERSION_CODES.R)
+  public void hasPermissionToBind_isTargetUserProfile_noInteractAcrossProfiles_false() {
+    utilities.tryAddTargetUserProfile(otherUserHandle);
+    utilities.denyPermissions(INTERACT_ACROSS_PROFILES);
+
+    assertThat(binder.hasPermissionToBind(context)).isFalse();
+  }
+
+  @Test
+  public void hasPermissionToBind_interactAcrossUsersOnly_false() {
+    utilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
+    utilities.grantPermissions(INTERACT_ACROSS_USERS);
+
+    assertThat(binder.hasPermissionToBind(context)).isFalse();
+  }
+
+  @Test
+  public void hasPermissionToBind_interactAcrossUsersFull_true() {
+    utilities.setRequestsPermissions(INTERACT_ACROSS_USERS_FULL);
+    utilities.grantPermissions(INTERACT_ACROSS_USERS_FULL);
+
+    assertThat(binder.hasPermissionToBind(context)).isTrue();
+  }
+}
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/IfAvailableTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/IfAvailableTest.java
index 6106125..ac5f21a 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/IfAvailableTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/IfAvailableTest.java
@@ -77,36 +77,20 @@
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
   }
 
-  @Test
-  public void synchronous_notConnected_returnsDefaultValue() {
-    testUtilities.disconnect();
-
-    assertThat(
-            profileTestCrossProfileType
-                .other()
-                .ifAvailable()
-                .identityStringMethod(STRING1, /* defaultValue= */ STRING2))
-        .isEqualTo(STRING2);
-  }
+  // We cannot test synchronous calls when not connected as robolectric automatically connects
+  // if we add a connection holder
 
   @Test
   public void synchronous_connected_makesCall() throws Exception {
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     assertThat(profileTestCrossProfileType.other().identityStringMethod(STRING1))
         .isEqualTo(STRING1);
   }
 
   @Test
-  public void synchronousVoid_notConnected_doesNotThrowException() {
-    testUtilities.disconnect();
-
-    profileTestCrossProfileType.other().ifAvailable().voidMethod();
-  }
-
-  @Test
   public void synchronousVoid_connected_doesNotThrowException() {
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType.other().ifAvailable().voidMethod();
   }
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/ManualConnectionManagementTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/ManualConnectionManagementTest.java
index bb1845e..8544934 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/ManualConnectionManagementTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/ManualConnectionManagementTest.java
@@ -63,7 +63,7 @@
   public void connect_doesNotHavePermission_doesNotConnect() throws Exception {
     testUtilities.denyPermissions(INTERACT_ACROSS_USERS);
 
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
     testUtilities.advanceTimeBySeconds(60);
 
     assertThat(testProfileConnector.isConnected()).isFalse();
@@ -72,7 +72,7 @@
   @Test
   public void connect_getsPermissionAfterStartingConnecting_connects() throws Exception {
     testUtilities.denyPermissions(INTERACT_ACROSS_USERS);
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
     testUtilities.advanceTimeBySeconds(5);
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/MessageSizeTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/MessageSizeTest.java
index 759d0c2..7fa793f 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/MessageSizeTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/MessageSizeTest.java
@@ -75,7 +75,7 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
   }
 
   @Test
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileAsyncTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileAsyncTest.java
index 92cd481..c7679cf 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileAsyncTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileAsyncTest.java
@@ -34,7 +34,6 @@
 import com.google.android.enterprise.connectedapps.TestVoidCallbackListenerImpl;
 import com.google.android.enterprise.connectedapps.exceptions.ProfileRuntimeException;
 import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
-import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.interfaces.CrossProfileAnnotation;
 import com.google.android.enterprise.connectedapps.testapp.CustomRuntimeException;
 import com.google.android.enterprise.connectedapps.testapp.configuration.TestApplication;
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
@@ -95,18 +94,19 @@
   }
 
   @Test
-  public void other_async_callbackTriggeredMultipleTimes_isOnlyReceivedOnce() {
+  public void other_async_callbackTriggeredMultipleTimes_isReceivedTwice() {
     profileTestCrossProfileType
         .other()
-        .asyncVoidMethodWhichCallsBackTwice(voidCallbackListener, exceptionCallbackListener);
+        .asyncIdentityStringMethodWhichCallsBackTwice(
+            STRING, stringCallbackListener, exceptionCallbackListener);
 
-    assertThat(voidCallbackListener.callbackMethodCalls).isEqualTo(1);
+    assertThat(stringCallbackListener.callbackMethodCalls).isEqualTo(2);
   }
 
   @Test
   public void
       other_async_automaticConnection_workProfileIsTurnedOff_doesReceiveUnavailableProfileExceptionImmediately() {
-    testProfileConnector.stopManualConnectionManagement();
+    testProfileConnector.clearConnectionHolders();
     testUtilities.turnOffWorkProfile();
 
     profileTestCrossProfileType
@@ -120,7 +120,7 @@
   @Test
   public void
       other_async_automaticConnection_workProfileIsTurnedOn_doesNotSetUnavailableProfileException() {
-    testProfileConnector.stopManualConnectionManagement();
+    testProfileConnector.clearConnectionHolders();
     testUtilities.turnOnWorkProfile();
 
     profileTestCrossProfileType
@@ -134,7 +134,7 @@
 
   @Test
   public void other_async_automaticConnection_callsMethod() {
-    testProfileConnector.stopManualConnectionManagement();
+    testProfileConnector.clearConnectionHolders();
     testUtilities.turnOnWorkProfile();
 
     profileTestCrossProfileType
@@ -146,7 +146,7 @@
 
   @Test
   public void other_async_automaticConnection_resultIsSet() {
-    testProfileConnector.stopManualConnectionManagement();
+    testProfileConnector.clearConnectionHolders();
     testUtilities.turnOnWorkProfile();
 
     profileTestCrossProfileType
@@ -159,7 +159,7 @@
   @Test
   public void
       other_async_automaticConnection_connectionIsDroppedDuringCall_setUnavailableProfileException() {
-    testProfileConnector.stopManualConnectionManagement();
+    testProfileConnector.clearConnectionHolders();
     testUtilities.turnOnWorkProfile();
     profileTestCrossProfileType
         .other()
@@ -173,120 +173,12 @@
   }
 
   @Test
-  public void other_async_timeoutSetOnMethod_doesNotTimeoutEarly() {
-    profileTestCrossProfileType
-        .other()
-        .asyncMethodWhichNeverCallsBackWith5SecondTimeout(
-            stringCallbackListener, exceptionCallbackListener);
-
-    testUtilities.advanceTimeBySeconds(4);
-
-    assertThat(exceptionCallbackListener.lastException).isNull();
-  }
-
-  @Test
-  public void other_async_timeoutSetOnMethod_timesOut() {
-    profileTestCrossProfileType
-        .other()
-        .asyncMethodWhichNeverCallsBackWith5SecondTimeout(
-            stringCallbackListener, exceptionCallbackListener);
-
-    testUtilities.advanceTimeBySeconds(6);
-
-    assertThat(exceptionCallbackListener.lastException)
-        .isInstanceOf(UnavailableProfileException.class);
-  }
-
-  @Test
-  public void other_async_timeoutSetOnType_doesNotTimeoutEarly() {
-    profileTestCrossProfileType
-        .other()
-        .asyncMethodWhichNeverCallsBackWith7SecondTimeout(
-            stringCallbackListener, exceptionCallbackListener);
-
-    testUtilities.advanceTimeBySeconds(6);
-
-    assertThat(exceptionCallbackListener.lastException).isNull();
-  }
-
-  @Test
-  public void other_async_timeoutSetOnType_timesOut() {
-    profileTestCrossProfileType
-        .other()
-        .asyncMethodWhichNeverCallsBackWith7SecondTimeout(
-            stringCallbackListener, exceptionCallbackListener);
-
-    testUtilities.advanceTimeBySeconds(8);
-
-    assertThat(exceptionCallbackListener.lastException)
-        .isInstanceOf(UnavailableProfileException.class);
-  }
-
-  @Test
-  public void other_async_timeoutSetByDefault_doesNotTimeoutEarly() throws Exception {
+  public void other_async_doesNotTimeOut() throws Exception {
     profileTestCrossProfileTypeWhichNeedsContext
         .other()
-        .asyncMethodWhichNeverCallsBackWithDefaultTimeout(
-            stringCallbackListener, exceptionCallbackListener);
+        .asyncMethodWhichNeverCallsBack(stringCallbackListener, exceptionCallbackListener);
 
-    scheduledExecutorService.advanceTimeBy(
-        CrossProfileAnnotation.DEFAULT_TIMEOUT_MILLIS - 1, TimeUnit.MILLISECONDS);
-
-    assertThat(exceptionCallbackListener.lastException).isNull();
-  }
-
-  @Test
-  public void other_async_timeoutSetByDefault_timesOut() throws Exception {
-    profileTestCrossProfileTypeWhichNeedsContext
-        .other()
-        .asyncMethodWhichNeverCallsBackWithDefaultTimeout(
-            stringCallbackListener, exceptionCallbackListener);
-
-    scheduledExecutorService.advanceTimeBy(
-        CrossProfileAnnotation.DEFAULT_TIMEOUT_MILLIS + 1, TimeUnit.MILLISECONDS);
-
-    assertThat(exceptionCallbackListener.lastException)
-        .isInstanceOf(UnavailableProfileException.class);
-  }
-
-  @Test
-  public void other_async_timeoutSetByCaller_doesNotTimeoutEarly() throws Exception {
-    long timeoutMillis = 5000;
-    profileTestCrossProfileTypeWhichNeedsContext
-        .other()
-        .timeout(timeoutMillis)
-        .asyncMethodWhichNeverCallsBackWithDefaultTimeout(
-            stringCallbackListener, exceptionCallbackListener);
-
-    scheduledExecutorService.advanceTimeBy(timeoutMillis - 1, TimeUnit.MILLISECONDS);
-
-    assertThat(exceptionCallbackListener.lastException).isNull();
-  }
-
-  @Test
-  public void other_async_timeoutSetByCaller_timesOut() throws Exception {
-    long timeoutMillis = 5000;
-    profileTestCrossProfileTypeWhichNeedsContext
-        .other()
-        .timeout(timeoutMillis)
-        .asyncMethodWhichNeverCallsBackWithDefaultTimeout(
-            stringCallbackListener, exceptionCallbackListener);
-
-    scheduledExecutorService.advanceTimeBy(timeoutMillis + 1, TimeUnit.MILLISECONDS);
-
-    assertThat(exceptionCallbackListener.lastException)
-        .isInstanceOf(UnavailableProfileException.class);
-  }
-
-  @Test
-  public void other_async_doesNotTimeoutAfterCompletion() throws Exception {
-    // We would expect an exception if the timeout continued after completion
-    profileTestCrossProfileType
-        .other()
-        .asyncVoidMethod(voidCallbackListener, exceptionCallbackListener);
-
-    scheduledExecutorService.advanceTimeBy(
-        CrossProfileAnnotation.DEFAULT_TIMEOUT_MILLIS + 1, TimeUnit.MILLISECONDS);
+    scheduledExecutorService.advanceTimeBy(10, TimeUnit.MINUTES);
 
     assertThat(exceptionCallbackListener.lastException).isNull();
   }
@@ -294,7 +186,7 @@
   @Test
   public void other_async_throwsException_exceptionIsWrapped() {
     // The exception is only catchable when the connection is already established.
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     try {
       profileTestCrossProfileType
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileListenableFutureTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileListenableFutureTest.java
index 7d71ade..964126c 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileListenableFutureTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileListenableFutureTest.java
@@ -31,7 +31,6 @@
 import com.google.android.enterprise.connectedapps.TestScheduledExecutorService;
 import com.google.android.enterprise.connectedapps.exceptions.ProfileRuntimeException;
 import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
-import com.google.android.enterprise.connectedapps.processor.annotationdiscovery.interfaces.CrossProfileAnnotation;
 import com.google.android.enterprise.connectedapps.testapp.CustomRuntimeException;
 import com.google.android.enterprise.connectedapps.testapp.configuration.TestApplication;
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
@@ -83,7 +82,7 @@
   @Test
   public void
       other_listenableFuture_automaticConnection_workProfileIsTurnedOff_doesSetUnavailableProfileExceptionImmediately() {
-    testProfileConnector.stopManualConnectionManagement();
+    testProfileConnector.clearConnectionHolders();
     testUtilities.turnOffWorkProfile();
 
     ListenableFuture<Void> future =
@@ -95,7 +94,7 @@
   @Test
   public void
       other_listenableFuture_automaticConnection_workProfileIsTurnedOn_doesNotSetUnavailableProfileException() {
-    testProfileConnector.stopManualConnectionManagement();
+    testProfileConnector.clearConnectionHolders();
     testUtilities.turnOnWorkProfile();
 
     ListenableFuture<Void> future =
@@ -110,7 +109,7 @@
   @Test
   public void other_listenableFuture_automaticConnection_callsMethod()
       throws ExecutionException, InterruptedException {
-    testProfileConnector.stopManualConnectionManagement();
+    testProfileConnector.clearConnectionHolders();
     testUtilities.turnOnWorkProfile();
 
     profileTestCrossProfileType.other().listenableFutureVoidMethod().get();
@@ -121,7 +120,7 @@
   @Test
   public void other_listenableFuture_automaticConnection_setsFuture()
       throws ExecutionException, InterruptedException {
-    testProfileConnector.stopManualConnectionManagement();
+    testProfileConnector.clearConnectionHolders();
     testUtilities.turnOnWorkProfile();
 
     // This would throw an exception if it wasn't set
@@ -131,7 +130,7 @@
   @Test
   public void
       other_listenableFuture_automaticConnection_connectionIsDroppedDuringCall_setUnavailableProfileException() {
-    testProfileConnector.stopManualConnectionManagement();
+    testProfileConnector.clearConnectionHolders();
     testUtilities.turnOnWorkProfile();
     ListenableFuture<Void> future =
         profileTestCrossProfileType.other().listenableFutureMethodWhichNeverSetsTheValue();
@@ -143,138 +142,21 @@
   }
 
   @Test
-  public void other_listenableFuture_timeoutSetOnMethod_doesNotTimeoutEarly() throws Exception {
-    ListenableFuture<Void> listenableFuture =
-        profileTestCrossProfileType
-            .other()
-            .listenableFutureMethodWhichNeverSetsTheValueWith5SecondTimeout();
-
-    scheduledExecutorService.advanceTimeBy(4, TimeUnit.SECONDS);
-
-    assertFutureDoesNotHaveException(listenableFuture, UnavailableProfileException.class);
-  }
-
-  @Test
-  public void other_listenableFuture_timeoutSetOnMethod_timesOut() throws Exception {
-    ListenableFuture<Void> listenableFuture =
-        profileTestCrossProfileType
-            .other()
-            .listenableFutureMethodWhichNeverSetsTheValueWith5SecondTimeout();
-
-    scheduledExecutorService.advanceTimeBy(6, TimeUnit.SECONDS);
-
-    assertFutureHasException(listenableFuture, UnavailableProfileException.class);
-  }
-
-  @Test
-  public void other_listenableFuture_timeoutSetOnType_doesNotTimeoutEarly() throws Exception {
-    ListenableFuture<Void> listenableFuture =
-        profileTestCrossProfileType
-            .other()
-            .listenableFutureMethodWhichNeverSetsTheValueWith7SecondTimeout();
-
-    scheduledExecutorService.advanceTimeBy(6, TimeUnit.SECONDS);
-
-    assertFutureDoesNotHaveException(listenableFuture, UnavailableProfileException.class);
-  }
-
-  @Test
-  public void other_listenableFuture_timeoutSetOnType_timesOut() throws Exception {
-    ListenableFuture<Void> listenableFuture =
-        profileTestCrossProfileType
-            .other()
-            .listenableFutureMethodWhichNeverSetsTheValueWith7SecondTimeout();
-
-    scheduledExecutorService.advanceTimeBy(8, TimeUnit.SECONDS);
-
-    assertFutureHasException(listenableFuture, UnavailableProfileException.class);
-  }
-
-  @Test
-  public void other_listenableFuture_timeoutSetByDefault_doesNotTimeoutEarly() throws Exception {
+  public void other_listenableFuture_doesNotTimeout() throws Exception {
     ListenableFuture<Void> listenableFuture =
         profileTestCrossProfileTypeWhichNeedsContext
             .other()
-            .listenableFutureMethodWhichNeverSetsTheValueWithDefaultTimeout();
+            .listenableFutureMethodWhichNeverSetsTheValue();
 
-    scheduledExecutorService.advanceTimeBy(
-        CrossProfileAnnotation.DEFAULT_TIMEOUT_MILLIS - 1, TimeUnit.MILLISECONDS);
+    scheduledExecutorService.advanceTimeBy(10, TimeUnit.MINUTES);
 
     assertFutureDoesNotHaveException(listenableFuture, UnavailableProfileException.class);
   }
 
   @Test
-  public void other_listenableFuture_timeoutSetByDefault_timesOut() throws Exception {
-    ListenableFuture<Void> listenableFuture =
-        profileTestCrossProfileTypeWhichNeedsContext
-            .other()
-            .listenableFutureMethodWhichNeverSetsTheValueWithDefaultTimeout();
-
-    scheduledExecutorService.advanceTimeBy(
-        CrossProfileAnnotation.DEFAULT_TIMEOUT_MILLIS + 1, TimeUnit.MILLISECONDS);
-
-    assertFutureHasException(listenableFuture, UnavailableProfileException.class);
-  }
-
-  @Test
-  public void other_listenableFuture_timeoutSetByCaller_doesNotTimeoutEarly() throws Exception {
-    long timeoutMillis = 5000;
-    ListenableFuture<Void> listenableFuture =
-        profileTestCrossProfileTypeWhichNeedsContext
-            .other()
-            .timeout(timeoutMillis)
-            .listenableFutureMethodWhichNeverSetsTheValueWithDefaultTimeout();
-
-    scheduledExecutorService.advanceTimeBy(timeoutMillis - 1, TimeUnit.MILLISECONDS);
-
-    assertFutureDoesNotHaveException(listenableFuture, UnavailableProfileException.class);
-  }
-
-  @Test
-  public void other_listenableFuture_timeoutSetByCaller_timesOut() throws Exception {
-    long timeoutMillis = 5000;
-    ListenableFuture<Void> listenableFuture =
-        profileTestCrossProfileTypeWhichNeedsContext
-            .other()
-            .timeout(timeoutMillis)
-            .listenableFutureMethodWhichNeverSetsTheValueWithDefaultTimeout();
-
-    scheduledExecutorService.advanceTimeBy(timeoutMillis + 1, TimeUnit.MILLISECONDS);
-
-    assertFutureHasException(listenableFuture, UnavailableProfileException.class);
-  }
-
-  @Test
-  public void other_listenableFuture_doesNotTimeoutAfterCompletion() throws Exception {
-    // We would expect an exception if the timeout continued after completion
-    ListenableFuture<Void> listenableFuture =
-        profileTestCrossProfileType.other().listenableFutureVoidMethod();
-
-    scheduledExecutorService.advanceTimeBy(
-        CrossProfileAnnotation.DEFAULT_TIMEOUT_MILLIS + 1, TimeUnit.MILLISECONDS);
-
-    assertFutureDoesNotHaveException(listenableFuture, UnavailableProfileException.class);
-  }
-
-  @Test
-  public void other_listenableFuture_doesNotTimeoutAfterException() throws Exception {
-    // We would expect an exception if the timeout continued after completion
-    ListenableFuture<Void> unusedFuture =
-        profileTestCrossProfileType
-            .other()
-            .listenableFutureVoidMethodWhichSetsIllegalStateException();
-
-    scheduledExecutorService.advanceTimeBy(
-        CrossProfileAnnotation.DEFAULT_TIMEOUT_MILLIS + 1, TimeUnit.MILLISECONDS);
-
-    // We expect there would be an exception thrown due to setting the future twice if it timed out
-    // now
-  }
-
-  @Test
   public void other_listenableFuture_throwsException_exceptionIsWrapped() {
     // The exception is only catchable when the connection is already established.
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     try {
       ListenableFuture<Void> unusedFuture =
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileManualAsyncTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileManualAsyncTest.java
index cac6278..fb494ed 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileManualAsyncTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileManualAsyncTest.java
@@ -82,7 +82,7 @@
   @Test
   public void other_async_manualConnection_isBound_callsMethod() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType
         .other()
@@ -94,7 +94,7 @@
   @Test
   public void other_async_manualConnection_isBound_firesCallback() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType
         .other()
@@ -106,7 +106,7 @@
   @Test
   public void other_async_manualConnection_isBound_unbundlesCorrectly() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType
         .other()
@@ -118,7 +118,7 @@
   @Test // This behaviour is expected right now but will change
   public void other_async_manualConnection_isBound_blockingMethod_blocks() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType
         .other()
@@ -131,7 +131,7 @@
   @Test
   public void other_async_manualConnection_isBound_nonBlockingMethod_doesNotBlock() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType
         .other()
@@ -144,7 +144,7 @@
   @Test
   public void other_async_manualConnection_isBound_nonBlockingMethod_doesCallback() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType
         .other()
@@ -182,7 +182,7 @@
   public void
       other_async_manualConnection_connectionIsDroppedDuringCall_setUnavailableProfileException() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
     profileTestCrossProfileType
         .other()
         .asyncMethodWhichNeverCallsBack(stringCallbackListener, exceptionCallbackListener);
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileManualListenableFutureTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileManualListenableFutureTest.java
index ae6aafb..5af23a2 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileManualListenableFutureTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileManualListenableFutureTest.java
@@ -74,7 +74,7 @@
   @Test
   public void
       other_listenableFuture_manualConnection_workProfileIsTurnedOff_doesSetUnavailableProfileExceptionImmediately() {
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
     testUtilities.turnOffWorkProfile();
 
     testUtilities.assertFutureHasException(
@@ -86,7 +86,7 @@
   public void other_listenableFuture_manualConnection_isBound_callsMethod()
       throws ExecutionException, InterruptedException {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     profileTestCrossProfileType.other().listenableFutureVoidMethod().get();
 
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileSynchronousTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileSynchronousTest.java
index 5b40c0f..18ff21d 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileSynchronousTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/OtherProfileSynchronousTest.java
@@ -18,6 +18,7 @@
 import static com.google.android.enterprise.connectedapps.SharedTestUtilities.INTERACT_ACROSS_USERS;
 import static com.google.common.truth.Truth.assertThat;
 import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.fail;
 import static org.robolectric.annotation.LooperMode.Mode.LEGACY;
 
 import android.app.Application;
@@ -27,6 +28,7 @@
 import androidx.test.core.app.ApplicationProvider;
 import com.google.android.enterprise.connectedapps.RobolectricTestUtilities;
 import com.google.android.enterprise.connectedapps.TestScheduledExecutorService;
+import com.google.android.enterprise.connectedapps.exceptions.ProfileRuntimeException;
 import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
 import com.google.android.enterprise.connectedapps.testapp.NotReallySerializableObject;
 import com.google.android.enterprise.connectedapps.testapp.ParcelableObject;
@@ -75,13 +77,13 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
   }
 
   @Test
   public void other_synchronous_isBound_callsMethod() throws UnavailableProfileException {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     assertThat(profileTestCrossProfileType.other().identityStringMethod(STRING)).isEqualTo(STRING);
   }
@@ -106,19 +108,6 @@
   }
 
   @Test
-  public void
-      other_synchronous_isBound_automaticConnectionManagement_throwsUnavailableProfileException() {
-    testUtilities.turnOnWorkProfile();
-    testProfileConnector.stopManualConnectionManagement();
-    ListenableFuture<Void> ignored =
-        profileTestCrossProfileType.other().listenableFutureVoidMethod(); // Causes it to bind
-
-    assertThrows(
-        UnavailableProfileException.class,
-        () -> profileTestCrossProfileType.other().voidMethod());
-  }
-
-  @Test
   public void other_serializableObjectParameterIsNotReallySerializable_throwsException() {
     assertThrows(
         RuntimeException.class,
@@ -143,6 +132,18 @@
   }
 
   @Test
+  public void other_synchronous_declaresExceptionButThrowsRuntimeException_wrapsException() throws Exception {
+    try {
+      profileTestCrossProfileType
+          .other()
+          .methodWhichThrowsRuntimeExceptionAndDeclaresException();
+      fail();
+    } catch (ProfileRuntimeException expected) {
+      // Expected
+    }
+  }
+
+  @Test
   public void other_synchronous_throwsException_works() {
     assertThrows(
         IOException.class,
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/ProfilesTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/ProfilesTest.java
index 4150353..1b945b7 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/ProfilesTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/ProfilesTest.java
@@ -71,13 +71,13 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
   }
 
   @Test
   public void profiles_isBound_resultContainsAllProfileResults() {
     testUtilities.turnOnWorkProfile();
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
 
     Map<Profile, String> result =
         profileTestCrossProfileType
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/ProviderTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/ProviderTest.java
index e66c09a..128116e 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/ProviderTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/ProviderTest.java
@@ -43,7 +43,7 @@
 
   @Before
   public void setup() {
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
   }
 
   @Test
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/StaticTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/StaticTest.java
index 5ad5534..0b1dce7 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/StaticTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/StaticTest.java
@@ -89,7 +89,7 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
   }
 
   @Test
@@ -113,7 +113,7 @@
   @Test
   public void staticCrossProfileMethod_fake_blocking_other_works() throws Exception {
     fakeConnector.turnOnWorkProfile();
-    fakeConnector.startConnecting();
+    fakeConnector.addConnectionHolder(this);
 
     assertThat(fakeType.other().staticIdentityStringMethod(STRING)).isEqualTo(STRING);
   }
@@ -126,7 +126,7 @@
   @Test
   public void staticCrossProfileMethod_fake_blocking_both_works() {
     fakeConnector.turnOnWorkProfile();
-    fakeConnector.startConnecting();
+    fakeConnector.addConnectionHolder(this);
 
     Map<Profile, String> result = fakeType.both().staticIdentityStringMethod(STRING);
 
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/TypesTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/TypesTest.java
index ff5d66c..717a6e9 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/TypesTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/robotests/TypesTest.java
@@ -22,17 +22,23 @@
 import android.app.Service;
 import android.content.Context;
 import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
 import android.os.Build.VERSION_CODES;
 import android.os.IBinder;
+import android.os.Parcelable;
 import android.util.Pair;
 import androidx.test.core.app.ApplicationProvider;
 import com.google.android.enterprise.connectedapps.RobolectricTestUtilities;
 import com.google.android.enterprise.connectedapps.TestCustomWrapperCallbackListenerImpl;
 import com.google.android.enterprise.connectedapps.TestExceptionCallbackListener;
+import com.google.android.enterprise.connectedapps.TestParcelableCallbackListenerImpl;
 import com.google.android.enterprise.connectedapps.TestScheduledExecutorService;
 import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
 import com.google.android.enterprise.connectedapps.testapp.CustomWrapper;
 import com.google.android.enterprise.connectedapps.testapp.CustomWrapper2;
+import com.google.android.enterprise.connectedapps.testapp.ParcelableContainingBinder;
 import com.google.android.enterprise.connectedapps.testapp.ParcelableObject;
 import com.google.android.enterprise.connectedapps.testapp.SerializableObject;
 import com.google.android.enterprise.connectedapps.testapp.SimpleFuture;
@@ -40,12 +46,13 @@
 import com.google.android.enterprise.connectedapps.testapp.configuration.TestApplication;
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
 import com.google.android.enterprise.connectedapps.testapp.types.ProfileTestCrossProfileType;
-import com.google.android.enterprise.connectedapps.testapp.types.ProfileTestCrossProfileType_SingleSenderCanThrow;
 import com.google.android.enterprise.connectedapps.testapp.types.TestCrossProfileType;
+import com.google.android.enterprise.connectedapps.testapp.types.TestCrossProfileType_SingleSenderCanThrow;
 import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.ListenableFuture;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -68,20 +75,38 @@
 
   private static final String STRING = "string";
   private static final byte BYTE = 1;
+  private static final byte[] BYTE_ARRAY = new byte[]{BYTE};
+  private static final byte[][][] MULTIDIMENSIONAL_BYTE_ARRAY = new byte[][][] {{BYTE_ARRAY}};
   private static final Byte BYTE_BOXED = 1;
   private static final short SHORT = 1;
+  private static final short[] SHORT_ARRAY = new short[]{SHORT};
+  private static final short[][][] MULTIDIMENSIONAL_SHORT_ARRAY = new short[][][] {{SHORT_ARRAY}};
   private static final Short SHORT_BOXED = 1;
   private static final int INT = 1;
+  private static final int[] INT_ARRAY = new int[]{INT};
+  private static final int[][][] MULTIDIMENSIONAL_INT_ARRAY = new int[][][] {{INT_ARRAY}};
   private static final Integer INTEGER = 1;
   private static final long LONG = 1;
+  private static final long[] LONG_ARRAY = new long[]{LONG};
+  private static final long[][][] MULTIDIMENSIONAL_LONG_ARRAY = new long[][][] {{LONG_ARRAY}};
   private static final Long LONG_BOXED = 1L;
   private static final float FLOAT = 1;
+  private static final float[] FLOAT_ARRAY = new float[]{FLOAT};
+  private static final float[][][] MULTIDIMENSIONAL_FLOAT_ARRAY = new float[][][] {{FLOAT_ARRAY}};
   private static final Float FLOAT_BOXED = 1f;
   private static final double DOUBLE = 1;
+  private static final double[] DOUBLE_ARRAY = new double[]{DOUBLE};
+  private static final double[][][] MULTIDIMENSIONAL_DOUBLE_ARRAY =
+      new double[][][] {{DOUBLE_ARRAY}};
   private static final Double DOUBLE_BOXED = 1d;
   private static final char CHAR = 1;
+  private static final char[] CHAR_ARRAY = new char[]{CHAR};
+  private static final char[][][] MULTIDIMENSIONAL_CHAR_ARRAY = new char[][][] {{CHAR_ARRAY}};
   private static final Character CHARACTER = 1;
   private static final boolean BOOLEAN = true;
+  private static final boolean[] BOOLEAN_ARRAY = new boolean[]{BOOLEAN};
+  private static final boolean[][][] MULTIDIMENSIONAL_BOOLEAN_ARRAY =
+      new boolean[][][] {{BOOLEAN_ARRAY}};
   private static final Boolean BOOLEAN_BOXED = true;
   private static final ParcelableObject PARCELABLE = new ParcelableObject("test");
   private static final SerializableObject SERIALIZABLE = new SerializableObject("test");
@@ -112,11 +137,16 @@
   private static final StringWrapper STRING_WRAPPER = new StringWrapper(STRING);
   private static final Optional<ParcelableObject> GUAVA_OPTIONAL = Optional.of(PARCELABLE);
   private static final int[] BITMAP_PIXELS = {1, 2, 3, 4, 5, 6, 7, 8, 9};
+  private static final CharSequence CHAR_SEQUENCE = STRING;
 
   private final Application context = ApplicationProvider.getApplicationContext();
   // Android type can't be static due to Robolectric
   private final Pair<String, Integer> pair = new Pair<>(STRING, INTEGER);
   private final Bitmap bitmap = Bitmap.createBitmap(BITMAP_PIXELS, 3, 3, Bitmap.Config.ARGB_8888);
+  private final ParcelableContainingBinder parcelableContainingBinder =
+      new ParcelableContainingBinder();
+  private final Drawable drawable = new BitmapDrawable(context.getResources(), bitmap);
+
 
   private final TestScheduledExecutorService scheduledExecutorService =
       new TestScheduledExecutorService();
@@ -126,7 +156,7 @@
       new RobolectricTestUtilities(testProfileConnector, scheduledExecutorService);
 
   private interface SenderProvider {
-    ProfileTestCrossProfileType_SingleSenderCanThrow provide(
+    TestCrossProfileType_SingleSenderCanThrow provide(
         Context context, TestProfileConnector testProfileConnector);
   }
 
@@ -135,7 +165,7 @@
 
     SenderProvider currentProfileSenderProvider =
         (Context context, TestProfileConnector testProfileConnector) ->
-            (ProfileTestCrossProfileType_SingleSenderCanThrow)
+            (TestCrossProfileType_SingleSenderCanThrow)
                 ProfileTestCrossProfileType.create(testProfileConnector).current();
     SenderProvider otherProfileSenderProvider =
         (Context context, TestProfileConnector testProfileConnector) ->
@@ -159,7 +189,7 @@
     testUtilities.setRunningOnPersonalProfile();
     testUtilities.setRequestsPermissions(INTERACT_ACROSS_USERS);
     testUtilities.grantPermissions(INTERACT_ACROSS_USERS);
-    testUtilities.startConnectingAndWait();
+    testUtilities.addDefaultConnectionHolderAndWait();
   }
 
   private SenderProvider senderProvider;
@@ -399,7 +429,7 @@
 
   @Test
   public void collectionOfArrayReturnTypeAndArgument_bothWork() throws UnavailableProfileException {
-    ProfileTestCrossProfileType_SingleSenderCanThrow sender =
+    TestCrossProfileType_SingleSenderCanThrow sender =
         senderProvider.provide(context, testProfileConnector);
 
     List<String[]> originalAsList = new ArrayList<>(collectionOfStringArray);
@@ -436,7 +466,7 @@
   @Test
   public void collectionOfParcelableArrayReturnTypeAndArgument_bothWork()
       throws UnavailableProfileException {
-    ProfileTestCrossProfileType_SingleSenderCanThrow sender =
+    TestCrossProfileType_SingleSenderCanThrow sender =
         senderProvider.provide(context, testProfileConnector);
 
     List<ParcelableObject[]> originalAsList = new ArrayList<>(collectionOfParcelableArray);
@@ -453,7 +483,7 @@
   @Test
   public void collectionOfSerializableArrayReturnTypeAndArgument_bothWork()
       throws UnavailableProfileException {
-    ProfileTestCrossProfileType_SingleSenderCanThrow sender =
+    TestCrossProfileType_SingleSenderCanThrow sender =
         senderProvider.provide(context, testProfileConnector);
 
     List<SerializableObject[]> originalAsList = new ArrayList<>(collectionOfSerializableArray);
@@ -627,12 +657,6 @@
     assertThat(getBitmapPixels(returnBitmap)).isEqualTo(BITMAP_PIXELS);
   }
 
-  @Test
-  public void nullBitmap_works() throws UnavailableProfileException {
-    assertThat(senderProvider.provide(context, testProfileConnector).identityBitmapMethod(null))
-        .isNull();
-  }
-
   private static int[] getBitmapPixels(Bitmap bitmap) {
     int[] pixels = new int[bitmap.getHeight() * bitmap.getWidth()];
     bitmap.getPixels(pixels, 0, bitmap.getWidth(), 0, 0, bitmap.getWidth(), bitmap.getHeight());
@@ -640,8 +664,212 @@
   }
 
   @Test
+  public void nullBitmap_works() throws UnavailableProfileException {
+    assertThat(senderProvider.provide(context, testProfileConnector).identityBitmapMethod(null))
+        .isNull();
+  }
+
+  @Test
   public void contextArgument_works() throws UnavailableProfileException {
     assertThat(senderProvider.provide(context, testProfileConnector).isContextArgumentPassed())
         .isTrue();
   }
+
+  @Test
+  public void parcelableArgumentAndReturnType_bothWork() throws UnavailableProfileException {
+    assertThat(senderProvider.provide(context, testProfileConnector)
+        .identityParcelableMethod((Parcelable) PARCELABLE)).isEqualTo(PARCELABLE);
+  }
+
+  @Test
+  public void parcelableContainingBinderArgumentAndReturnType_bothWork()
+      throws UnavailableProfileException {
+    assertThat(
+            senderProvider
+                .provide(context, testProfileConnector)
+                .identityParcelableMethod(parcelableContainingBinder))
+        .isEqualTo(parcelableContainingBinder);
+  }
+
+  @Test
+  public void parcelableContainingBinderAsyncMethod_works() {
+    TestParcelableCallbackListenerImpl callbackListener = new TestParcelableCallbackListenerImpl();
+    TestExceptionCallbackListener exceptionListener = new TestExceptionCallbackListener();
+
+    senderProvider
+        .provide(context, testProfileConnector)
+        .asyncIdentityParcelableMethod(
+            parcelableContainingBinder, callbackListener, exceptionListener);
+
+    assertThat(callbackListener.parcelableCallbackValue).isEqualTo(parcelableContainingBinder);
+  }
+
+  @Test
+  public void futureParcelableContainingBinder_works() throws Exception {
+    ListenableFuture<Parcelable> future =
+        senderProvider
+            .provide(context, testProfileConnector)
+            .futureIdentityParcelableMethod(parcelableContainingBinder);
+
+    assertThat(future.get()).isEqualTo(parcelableContainingBinder);
+  }
+
+  @Test
+  public void charSequenceReturnTypeAndArgument_bothWork() throws UnavailableProfileException {
+    assertThat(senderProvider.provide(context, testProfileConnector)
+                   .identityCharSequenceMethod(CHAR_SEQUENCE).toString())
+        .isEqualTo(CHAR_SEQUENCE.toString());
+  }
+
+  @Test
+  public void floatArrayReturnTypeAndArgument_bothWork() throws UnavailableProfileException {
+    assertThat(senderProvider.provide(context, testProfileConnector).identityFloatArrayMethod(FLOAT_ARRAY))
+        .isEqualTo(FLOAT_ARRAY);
+  }
+
+  @Test
+  public void multidimensionalFloatArrayReturnTypeAndArgument_bothWork()
+      throws UnavailableProfileException {
+    assertThat(
+            senderProvider
+                .provide(context, testProfileConnector)
+                .identityMultidimensionalFloatArrayMethod(MULTIDIMENSIONAL_FLOAT_ARRAY))
+        .isEqualTo(MULTIDIMENSIONAL_FLOAT_ARRAY);
+  }
+
+  @Test
+  public void byteArrayReturnTypeAndArgument_bothWork() throws UnavailableProfileException {
+    assertThat(
+            senderProvider
+                .provide(context, testProfileConnector)
+                .identityByteArrayMethod(BYTE_ARRAY))
+        .isEqualTo(BYTE_ARRAY);
+  }
+
+  @Test
+  public void multidimensionalByteArrayReturnTypeAndArgument_bothWork()
+      throws UnavailableProfileException {
+    assertThat(
+            senderProvider
+                .provide(context, testProfileConnector)
+                .identityMultidimensionalByteArrayMethod(MULTIDIMENSIONAL_BYTE_ARRAY))
+        .isEqualTo(MULTIDIMENSIONAL_BYTE_ARRAY);
+  }
+
+  @Test
+  public void shortArrayReturnTypeAndArgument_bothWork() throws UnavailableProfileException {
+    assertThat(senderProvider.provide(context, testProfileConnector).identityShortArrayMethod(SHORT_ARRAY))
+        .isEqualTo(SHORT_ARRAY);
+  }
+
+  @Test
+  public void multidimensionalShortArrayReturnTypeAndArgument_bothWork()
+      throws UnavailableProfileException {
+    assertThat(
+            senderProvider
+                .provide(context, testProfileConnector)
+                .identityMultidimensionalShortArrayMethod(MULTIDIMENSIONAL_SHORT_ARRAY))
+        .isEqualTo(MULTIDIMENSIONAL_SHORT_ARRAY);
+  }
+
+  @Test
+  public void intArrayReturnTypeAndArgument_bothWork() throws UnavailableProfileException {
+    assertThat(senderProvider.provide(context, testProfileConnector).identityIntArrayMethod(INT_ARRAY))
+        .isEqualTo(INT_ARRAY);
+  }
+
+  @Test
+  public void multidimensionalIntArrayReturnTypeAndArgument_bothWork()
+      throws UnavailableProfileException {
+    assertThat(
+            senderProvider
+                .provide(context, testProfileConnector)
+                .identityMultidimensionalIntArrayMethod(MULTIDIMENSIONAL_INT_ARRAY))
+        .isEqualTo(MULTIDIMENSIONAL_INT_ARRAY);
+  }
+
+  @Test
+  public void longArrayReturnTypeAndArgument_bothWork() throws UnavailableProfileException {
+    assertThat(senderProvider.provide(context, testProfileConnector).identityLongArrayMethod(LONG_ARRAY))
+        .isEqualTo(LONG_ARRAY);
+  }
+
+  @Test
+  public void multidimensionalLongArrayReturnTypeAndArgument_bothWork()
+      throws UnavailableProfileException {
+    assertThat(
+            senderProvider
+                .provide(context, testProfileConnector)
+                .identityMultidimensionalLongArrayMethod(MULTIDIMENSIONAL_LONG_ARRAY))
+        .isEqualTo(MULTIDIMENSIONAL_LONG_ARRAY);
+  }
+
+  @Test
+  public void doubleArrayReturnTypeAndArgument_bothWork() throws UnavailableProfileException {
+    assertThat(senderProvider.provide(context, testProfileConnector).identityDoubleArrayMethod(DOUBLE_ARRAY))
+        .isEqualTo(DOUBLE_ARRAY);
+  }
+
+  @Test
+  public void multidimensionalDoubleArrayReturnTypeAndArgument_bothWork()
+      throws UnavailableProfileException {
+    assertThat(
+            senderProvider
+                .provide(context, testProfileConnector)
+                .identityMultidimensionalDoubleArrayMethod(MULTIDIMENSIONAL_DOUBLE_ARRAY))
+        .isEqualTo(MULTIDIMENSIONAL_DOUBLE_ARRAY);
+  }
+
+  @Test
+  public void charArrayReturnTypeAndArgument_bothWork() throws UnavailableProfileException {
+    assertThat(senderProvider.provide(context, testProfileConnector).identityCharArrayMethod(CHAR_ARRAY))
+        .isEqualTo(CHAR_ARRAY);
+  }
+
+  @Test
+  public void multidimensionalCharArrayReturnTypeAndArgument_bothWork()
+      throws UnavailableProfileException {
+    assertThat(
+            senderProvider
+                .provide(context, testProfileConnector)
+                .identityMultidimensionalCharArrayMethod(MULTIDIMENSIONAL_CHAR_ARRAY))
+        .isEqualTo(MULTIDIMENSIONAL_CHAR_ARRAY);
+  }
+
+  @Test
+  public void booleanArrayReturnTypeAndArgument_bothWork() throws UnavailableProfileException {
+    assertThat(senderProvider.provide(context, testProfileConnector).identityBooleanArrayMethod(BOOLEAN_ARRAY))
+        .isEqualTo(BOOLEAN_ARRAY);
+  }
+
+  @Test
+  public void multidimensionalBooleanArrayReturnTypeAndArgument_bothWork()
+      throws UnavailableProfileException {
+    assertThat(
+            senderProvider
+                .provide(context, testProfileConnector)
+                .identityMultidimensionalBooleanArrayMethod(MULTIDIMENSIONAL_BOOLEAN_ARRAY))
+        .isEqualTo(MULTIDIMENSIONAL_BOOLEAN_ARRAY);
+  }
+
+  @Test
+  public void drawableReturnTypeAndArgument_bothWork() throws UnavailableProfileException {
+    Drawable returnDrawable =
+        senderProvider.provide(context, testProfileConnector).identityDrawableMethod(drawable);
+
+    assertThat(returnDrawable.getIntrinsicHeight()).isEqualTo(drawable.getIntrinsicHeight());
+    assertThat(returnDrawable.getIntrinsicWidth()).isEqualTo(drawable.getIntrinsicWidth());
+    assertThat(getDrawablePixels(returnDrawable)).isEqualTo(BITMAP_PIXELS);
+  }
+
+  private static int[] getDrawablePixels(Drawable drawable) {
+    Bitmap bitmap = Bitmap.createBitmap(
+        drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
+    drawable.draw(new Canvas(bitmap));
+
+    int[] pixels = new int[bitmap.getHeight() * bitmap.getWidth()];
+    bitmap.getPixels(pixels, 0, bitmap.getWidth(), 0, 0, bitmap.getWidth(), bitmap.getHeight());
+    return pixels;
+  }
 }
+
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/testing/AbstractFakeProfileConnectorTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/testing/AbstractFakeProfileConnectorTest.java
index f42d89b..cbdf717 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/testing/AbstractFakeProfileConnectorTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/testing/AbstractFakeProfileConnectorTest.java
@@ -22,10 +22,15 @@
 import android.os.Build.VERSION_CODES;
 import androidx.test.core.app.ApplicationProvider;
 import com.google.android.enterprise.connectedapps.ConnectedAppsUtils;
+import com.google.android.enterprise.connectedapps.ProfileConnectionHolder;
 import com.google.android.enterprise.connectedapps.TestAvailabilityListener;
 import com.google.android.enterprise.connectedapps.TestConnectionListener;
 import com.google.android.enterprise.connectedapps.annotations.CustomProfileConnector.ProfileType;
 import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
+import java.util.ArrayDeque;
+import java.util.Queue;
+import java.util.concurrent.Executor;
+import org.junit.After;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
@@ -45,43 +50,101 @@
       new FakeProfileConnector(context, /* primaryProfileType= */ ProfileType.NONE);
   private final TestAvailabilityListener availabilityListener = new TestAvailabilityListener();
   private final TestConnectionListener connectionListener = new TestConnectionListener();
+  private final Object connectionHolder = new Object();
+
+  @After
+  public void teardown() {
+    fakeProfileConnector.clearConnectionHolders();
+  }
 
   @Test
-  public void startConnecting_connectionIsAvailable_isConnected() {
+  public void addConnectionHolder_connectionIsAvailable_isConnected() {
     fakeProfileConnector.turnOnWorkProfile();
 
-    fakeProfileConnector.startConnecting();
+    fakeProfileConnector.addConnectionHolder(this);
 
     assertThat(fakeProfileConnector.isConnected()).isTrue();
   }
 
   @Test
-  public void startConnecting_connectionIsAvailable_notifiesConnectionChanged() {
-    fakeProfileConnector.turnOnWorkProfile();
-    fakeProfileConnector.registerConnectionListener(connectionListener);
+  public void addConnectionHolder_connectionIsNotAvailable_doesNotConnect() {
+    fakeProfileConnector.turnOffWorkProfile();
+    fakeProfileConnector.addConnectionListener(connectionListener);
 
-    fakeProfileConnector.startConnecting();
+    fakeProfileConnector.addConnectionHolder(this);
+
+    assertThat(fakeProfileConnector.isConnected()).isFalse();
+  }
+
+  @Test
+  public void addConnectionHolder_doesNotHavePermission_doesNotConnect() {
+    fakeProfileConnector.setHasPermissionToMakeCrossProfileCalls(false);
+    fakeProfileConnector.addConnectionListener(connectionListener);
+
+    fakeProfileConnector.addConnectionHolder(this);
+
+    assertThat(fakeProfileConnector.isConnected()).isFalse();
+  }
+
+  @Test
+  public void addConnectionHolder_notifiesConnectionChanged() {
+    fakeProfileConnector.turnOnWorkProfile();
+    fakeProfileConnector.addConnectionListener(connectionListener);
+
+    fakeProfileConnector.addConnectionHolder(this);
 
     assertThat(connectionListener.connectionChangedCount()).isEqualTo(1);
   }
 
   @Test
-  public void startConnecting_unregisteredConnectionListener_doesNotNotifyConnectionChanged() {
+  public void addConnectionHolder_doesNotConnectIfConnectionHandlerReturnsFalse() {
     fakeProfileConnector.turnOnWorkProfile();
-    fakeProfileConnector.registerConnectionListener(connectionListener);
-    fakeProfileConnector.unregisterConnectionListener(connectionListener);
+    fakeProfileConnector.setConnectionHandler(() -> false);
+    fakeProfileConnector.addConnectionListener(connectionListener);
 
-    fakeProfileConnector.startConnecting();
+    fakeProfileConnector.addConnectionHolder(this);
+
+    assertThat(connectionListener.connectionChangedCount()).isEqualTo(0);
+    assertThat(fakeProfileConnector.isConnected()).isFalse();
+  }
+
+  @Test
+  public void addConnectionHolder_usesPassedExecutorForAutomaticConnection() {
+    fakeProfileConnector.turnOnWorkProfile();
+    QueueingExecutor fakeExecutor = new QueueingExecutor();
+    fakeProfileConnector.setExecutor(fakeExecutor);
+    fakeProfileConnector.addConnectionListener(connectionListener);
+
+    fakeProfileConnector.addConnectionHolder(this);
+
+    assertThat(connectionListener.connectionChangedCount()).isEqualTo(0);
+    assertThat(fakeProfileConnector.isConnected()).isFalse();
+
+    fakeExecutor.runNext();
+
+    assertThat(connectionListener.connectionChangedCount()).isEqualTo(1);
+    assertThat(fakeProfileConnector.isConnected()).isTrue();
+
+    assertThat(fakeExecutor.isQueueEmpty()).isTrue();
+  }
+
+  @Test
+  public void addConnectionHolder_unregisteredConnectionListener_doesNotNotifyConnectionChanged() {
+    fakeProfileConnector.turnOnWorkProfile();
+    fakeProfileConnector.addConnectionListener(connectionListener);
+    fakeProfileConnector.removeConnectionListener(connectionListener);
+
+    fakeProfileConnector.addConnectionHolder(this);
 
     assertThat(connectionListener.connectionChangedCount()).isEqualTo(0);
   }
 
   @Test
-  public void startConnecting_connectionIsNotAvailable_doesNotNotifyOfConnectionChanged() {
+  public void addConnectionHolder_connectionIsNotAvailable_doesNotNotifyOfConnectionChanged() {
     fakeProfileConnector.removeWorkProfile();
-    fakeProfileConnector.registerConnectionListener(connectionListener);
+    fakeProfileConnector.addConnectionListener(connectionListener);
 
-    fakeProfileConnector.startConnecting();
+    fakeProfileConnector.addConnectionHolder(this);
 
     assertThat(connectionListener.connectionChangedCount()).isEqualTo(0);
   }
@@ -98,7 +161,7 @@
   @Test
   public void connect_connectionIsAvailable_notifiesConnectionChanged() throws Exception {
     fakeProfileConnector.turnOnWorkProfile();
-    fakeProfileConnector.registerConnectionListener(connectionListener);
+    fakeProfileConnector.addConnectionListener(connectionListener);
 
     fakeProfileConnector.connect();
 
@@ -109,8 +172,8 @@
   public void connect_unregisteredConnectionListener_doesNotNotifyConnectionChanged()
       throws Exception {
     fakeProfileConnector.turnOnWorkProfile();
-    fakeProfileConnector.registerConnectionListener(connectionListener);
-    fakeProfileConnector.unregisterConnectionListener(connectionListener);
+    fakeProfileConnector.addConnectionListener(connectionListener);
+    fakeProfileConnector.removeConnectionListener(connectionListener);
 
     fakeProfileConnector.connect();
 
@@ -120,14 +183,45 @@
   @Test
   public void connect_connectionIsNotAvailable_throwsUnavailableProfileException() {
     fakeProfileConnector.removeWorkProfile();
-    fakeProfileConnector.registerConnectionListener(connectionListener);
+    fakeProfileConnector.addConnectionListener(connectionListener);
 
     assertThrows(UnavailableProfileException.class, fakeProfileConnector::connect);
   }
 
   @Test
+  public void connect_doesNotHavePermission_throwsUnavailableProfileException() {
+    fakeProfileConnector.setHasPermissionToMakeCrossProfileCalls(false);
+    fakeProfileConnector.addConnectionListener(connectionListener);
+
+    assertThrows(UnavailableProfileException.class, fakeProfileConnector::connect);
+  }
+
+  @Test
+  public void connect_doesNotConnectIfHandlerReturnsFalse() throws Exception {
+    fakeProfileConnector.turnOnWorkProfile();
+    fakeProfileConnector.setConnectionHandler(() -> false);
+    fakeProfileConnector.addConnectionListener(connectionListener);
+
+    fakeProfileConnector.connect();
+
+    assertThat(connectionListener.connectionChangedCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void connect_doesNotUsePassedExecutor() throws Exception {
+    fakeProfileConnector.turnOnWorkProfile();
+    // Noop executor which we expect won't be used.
+    fakeProfileConnector.setExecutor(task -> {});
+    fakeProfileConnector.addConnectionListener(connectionListener);
+
+    fakeProfileConnector.connect();
+
+    assertThat(connectionListener.connectionChangedCount()).isEqualTo(1);
+  }
+
+  @Test
   public void turnOnWorkProfile_workProfileWasOff_notifiesAvailabilityChange() {
-    fakeProfileConnector.registerAvailabilityListener(availabilityListener);
+    fakeProfileConnector.addAvailabilityListener(availabilityListener);
 
     fakeProfileConnector.turnOnWorkProfile();
 
@@ -137,7 +231,7 @@
   @Test
   public void turnOnWorkProfile_workProfileWasOn_doesNotNotifyAvailabilityChange() {
     fakeProfileConnector.turnOnWorkProfile();
-    fakeProfileConnector.registerAvailabilityListener(availabilityListener);
+    fakeProfileConnector.addAvailabilityListener(availabilityListener);
 
     fakeProfileConnector.turnOnWorkProfile();
 
@@ -147,7 +241,7 @@
   @Test
   public void turnOffWorkProfile_workProfileWasOn_notifiesAvailabilityChange() {
     fakeProfileConnector.turnOnWorkProfile();
-    fakeProfileConnector.registerAvailabilityListener(availabilityListener);
+    fakeProfileConnector.addAvailabilityListener(availabilityListener);
 
     fakeProfileConnector.turnOffWorkProfile();
 
@@ -157,7 +251,7 @@
   @Test
   public void turnOffWorkProfile_workProfileWasOff_doesNotNotifyAvailabilityChange() {
     fakeProfileConnector.turnOffWorkProfile();
-    fakeProfileConnector.registerAvailabilityListener(availabilityListener);
+    fakeProfileConnector.addAvailabilityListener(availabilityListener);
 
     fakeProfileConnector.turnOffWorkProfile();
 
@@ -168,7 +262,7 @@
   public void turnOffWorkProfile_wasConnected_notifiesConnectionChange() throws Exception {
     fakeProfileConnector.turnOnWorkProfile();
     fakeProfileConnector.connect();
-    fakeProfileConnector.registerConnectionListener(connectionListener);
+    fakeProfileConnector.addConnectionListener(connectionListener);
 
     fakeProfileConnector.turnOffWorkProfile();
 
@@ -357,38 +451,28 @@
   }
 
   @Test
-  public void isManuallyManagingConnection_returnsFalse() {
-    assertThat(fakeProfileConnector.isManuallyManagingConnection()).isFalse();
-  }
-
-  @Test
-  public void isManuallyManagingConnection_hasStartedManuallyConnecting_returnsTrue() {
-    fakeProfileConnector.startConnecting();
-
-    assertThat(fakeProfileConnector.isManuallyManagingConnection()).isTrue();
-  }
-
-  @Test
-  public void isManuallyManagingConnection_hasManuallyConnected_returnsTrue() throws Exception {
+  public void timeoutConnection_hasNoConnectionHolders_disconnects() {
     fakeProfileConnector.turnOnWorkProfile();
-    fakeProfileConnector.connect();
+    fakeProfileConnector.clearConnectionHolders();
 
-    assertThat(fakeProfileConnector.isManuallyManagingConnection()).isTrue();
+    fakeProfileConnector.timeoutConnection();
+
+    assertThat(fakeProfileConnector.isConnected()).isFalse();
   }
 
   @Test
-  public void isManuallyManagingConnection_hasCalledStopManualConnectionManagement_returnsFalse() {
-    fakeProfileConnector.startConnecting();
-
-    fakeProfileConnector.stopManualConnectionManagement();
-
-    assertThat(fakeProfileConnector.isManuallyManagingConnection()).isFalse();
-  }
-
-  @Test
-  public void timeoutConnection_isManuallyManagingConnection_doesNotDisconnect() throws Exception {
+  public void addConnectionHolder_connects() {
     fakeProfileConnector.turnOnWorkProfile();
-    fakeProfileConnector.connect();
+
+    fakeProfileConnector.addConnectionHolder(this);
+
+    assertThat(fakeProfileConnector.isConnected()).isTrue();
+  }
+
+  @Test
+  public void timeoutConnection_hasConnectionHolder_doesNotDisconnect() {
+    fakeProfileConnector.turnOnWorkProfile();
+    fakeProfileConnector.addConnectionHolder(this);
 
     fakeProfileConnector.timeoutConnection();
 
@@ -396,12 +480,76 @@
   }
 
   @Test
-  public void timeoutConnection_isNotManuallyManagingConnection_disconnects() {
+  public void removeConnectionHolder_lastConnectionHolder_doesNotDisconnect() {
     fakeProfileConnector.turnOnWorkProfile();
-    fakeProfileConnector.stopManualConnectionManagement();
+    fakeProfileConnector.addConnectionHolder(this);
+
+    fakeProfileConnector.removeConnectionHolder(this);
+
+    assertThat(fakeProfileConnector.isConnected()).isTrue();
+  }
+
+  @Test
+  public void removeConnectionHolder_timeout_disconnects() {
+    fakeProfileConnector.turnOnWorkProfile();
+    fakeProfileConnector.addConnectionHolder(this);
+    fakeProfileConnector.removeConnectionHolder(this);
 
     fakeProfileConnector.timeoutConnection();
 
     assertThat(fakeProfileConnector.isConnected()).isFalse();
   }
+
+  @Test
+  public void removeConnectionHolder_stillAnotherConnectionHolder_timeout_doesNotDisconnect() {
+    fakeProfileConnector.turnOnWorkProfile();
+    fakeProfileConnector.addConnectionHolder(this);
+    fakeProfileConnector.addConnectionHolder(connectionHolder);
+    fakeProfileConnector.removeConnectionHolder(this);
+
+    fakeProfileConnector.timeoutConnection();
+
+    assertThat(fakeProfileConnector.isConnected()).isTrue();
+  }
+
+  @Test
+  public void removeConnectionHolder_removingAlias_timeout_disconnects() {
+    fakeProfileConnector.turnOnWorkProfile();
+    fakeProfileConnector.addConnectionHolder(this);
+    fakeProfileConnector.addConnectionHolderAlias(connectionHolder, this);
+    fakeProfileConnector.removeConnectionHolder(connectionHolder);
+
+    fakeProfileConnector.timeoutConnection();
+
+    assertThat(fakeProfileConnector.isConnected()).isFalse();
+  }
+
+  @Test
+  public void removeConnectionHolder_removingWrapper_timeout_disconnects() {
+    fakeProfileConnector.turnOnWorkProfile();
+    ProfileConnectionHolder connectionHolder = fakeProfileConnector.addConnectionHolder(this);
+    fakeProfileConnector.removeConnectionHolder(connectionHolder);
+
+    fakeProfileConnector.timeoutConnection();
+
+    assertThat(fakeProfileConnector.isConnected()).isFalse();
+  }
+}
+
+class QueueingExecutor implements Executor {
+
+  private final Queue<Runnable> commands = new ArrayDeque<>();
+
+  @Override
+  public void execute(Runnable command) {
+    commands.add(command);
+  }
+
+  public void runNext() {
+    commands.remove().run();
+  }
+
+  public boolean isQueueEmpty() {
+    return commands.isEmpty();
+  }
 }
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/testing/AbstractFakeUserConnectorTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/testing/AbstractFakeUserConnectorTest.java
new file mode 100644
index 0000000..413d00f
--- /dev/null
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/testing/AbstractFakeUserConnectorTest.java
@@ -0,0 +1,347 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.testing;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import android.app.Application;
+import android.content.Context;
+import android.os.Build.VERSION_CODES;
+import android.os.UserHandle;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.android.enterprise.connectedapps.RobolectricTestUtilities;
+import com.google.android.enterprise.connectedapps.TestAvailabilityListener;
+import com.google.android.enterprise.connectedapps.TestConnectionListener;
+import com.google.android.enterprise.connectedapps.TestScheduledExecutorService;
+import com.google.android.enterprise.connectedapps.UserConnectionHolder;
+import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(minSdk = VERSION_CODES.O)
+public class AbstractFakeUserConnectorTest {
+  static class FakeUserConnector extends AbstractFakeUserConnector {
+    FakeUserConnector(Context context) {
+      super(context);
+    }
+  }
+
+  private static final int CURRENT_USER_ID = 4;
+  private static final int TARGET_USER_ID = 5;
+
+  private final Application context = ApplicationProvider.getApplicationContext();
+  private final TestScheduledExecutorService scheduledExecutorService =
+      new TestScheduledExecutorService();
+  private final RobolectricTestUtilities utilities =
+      new RobolectricTestUtilities(context, scheduledExecutorService);
+  private final UserHandle currentUser = utilities.createCustomUser(CURRENT_USER_ID);
+  private final UserHandle targetUser = utilities.createCustomUser(TARGET_USER_ID);
+  private final FakeUserConnector fakeUserConnector = new FakeUserConnector(context);
+  private final TestAvailabilityListener availabilityListener = new TestAvailabilityListener();
+  private final TestConnectionListener connectionListener = new TestConnectionListener();
+  private final Object connectionHolder = new Object();
+
+  @Test
+  public void addConnectionHolder_connectionIsAvailable_isConnected() {
+    fakeUserConnector.setRunningOnUser(currentUser);
+    fakeUserConnector.turnOnUser(targetUser);
+
+    fakeUserConnector.addConnectionHolder(targetUser, this);
+
+    assertThat(fakeUserConnector.isConnected(targetUser)).isTrue();
+  }
+
+  @Test
+  public void addConnectionHolder_connectionIsAvailable_notifiesConnectionChanged() {
+    fakeUserConnector.setRunningOnUser(currentUser);
+    fakeUserConnector.turnOnUser(targetUser);
+    fakeUserConnector.addConnectionListener(targetUser, connectionListener);
+
+    fakeUserConnector.addConnectionHolder(targetUser, this);
+
+    assertThat(connectionListener.connectionChangedCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void addConnectionHolder_unregisteredConnectionListener_doesNotNotifyConnectionChanged() {
+    fakeUserConnector.setRunningOnUser(currentUser);
+    fakeUserConnector.turnOnUser(targetUser);
+    fakeUserConnector.addConnectionListener(targetUser, connectionListener);
+    fakeUserConnector.removeConnectionListener(targetUser, connectionListener);
+
+    fakeUserConnector.addConnectionHolder(targetUser, this);
+
+    assertThat(connectionListener.connectionChangedCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void addConnectionHolder_connectionIsNotAvailable_doesNotNotifyOfConnectionChanged() {
+    fakeUserConnector.setRunningOnUser(currentUser);
+    fakeUserConnector.turnOffUser(targetUser);
+    fakeUserConnector.addConnectionListener(targetUser, connectionListener);
+
+    fakeUserConnector.addConnectionHolder(targetUser, this);
+
+    assertThat(connectionListener.connectionChangedCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void connect_connectionIsAvailable_isConnected() throws Exception {
+    fakeUserConnector.setRunningOnUser(currentUser);
+    fakeUserConnector.turnOnUser(targetUser);
+
+    fakeUserConnector.connect(targetUser);
+
+    assertThat(fakeUserConnector.isConnected(targetUser)).isTrue();
+  }
+
+  @Test
+  public void connect_connectionIsAvailable_notifiesConnectionChanged() throws Exception {
+    fakeUserConnector.setRunningOnUser(currentUser);
+    fakeUserConnector.turnOnUser(targetUser);
+    fakeUserConnector.addConnectionListener(targetUser, connectionListener);
+
+    fakeUserConnector.connect(targetUser);
+
+    assertThat(connectionListener.connectionChangedCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void connect_unregisteredConnectionListener_doesNotNotifyConnectionChanged()
+      throws Exception {
+    fakeUserConnector.setRunningOnUser(currentUser);
+    fakeUserConnector.turnOnUser(targetUser);
+    fakeUserConnector.addConnectionListener(targetUser, connectionListener);
+    fakeUserConnector.removeConnectionListener(targetUser, connectionListener);
+
+    fakeUserConnector.connect(targetUser);
+
+    assertThat(connectionListener.connectionChangedCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void connect_connectionIsNotAvailable_throwsUnavailableProfileException() {
+    fakeUserConnector.setRunningOnUser(currentUser);
+    fakeUserConnector.turnOffUser(targetUser);
+    fakeUserConnector.addConnectionListener(targetUser, connectionListener);
+
+    assertThrows(UnavailableProfileException.class, () -> fakeUserConnector.connect(targetUser));
+  }
+
+  @Test
+  public void turnOnTargetUser_targetUserWasOff_notifiesAvailabilityChange() {
+    fakeUserConnector.turnOffUser(targetUser);
+    fakeUserConnector.addAvailabilityListener(targetUser, availabilityListener);
+
+    fakeUserConnector.turnOnUser(targetUser);
+
+    assertThat(availabilityListener.availabilityChangedCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void turnOnTargetUser_targetUserWasOn_doesNotNotifyAvailabilityChange() {
+    fakeUserConnector.turnOnUser(targetUser);
+    fakeUserConnector.addAvailabilityListener(targetUser, availabilityListener);
+
+    fakeUserConnector.turnOnUser(targetUser);
+
+    assertThat(availabilityListener.availabilityChangedCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void turnOffTargetUser_targetUserWasOn_notifiesAvailabilityChange() {
+    fakeUserConnector.turnOnUser(targetUser);
+    fakeUserConnector.addAvailabilityListener(targetUser, availabilityListener);
+
+    fakeUserConnector.turnOffUser(targetUser);
+
+    assertThat(availabilityListener.availabilityChangedCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void turnOffTargetUser_targetUserWasOff_doesNotNotifyAvailabilityChange() {
+    fakeUserConnector.turnOffUser(targetUser);
+    fakeUserConnector.addAvailabilityListener(targetUser, availabilityListener);
+
+    fakeUserConnector.turnOffUser(targetUser);
+
+    assertThat(availabilityListener.availabilityChangedCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void turnOffTargetUser_wasConnected_notifiesConnectionChange() throws Exception {
+    fakeUserConnector.setRunningOnUser(currentUser);
+    fakeUserConnector.turnOnUser(targetUser);
+    fakeUserConnector.connect(targetUser);
+    fakeUserConnector.addConnectionListener(targetUser, connectionListener);
+
+    fakeUserConnector.turnOffUser(targetUser);
+
+    assertThat(connectionListener.connectionChangedCount()).isEqualTo(1);
+  }
+
+  @Test
+  public void setRunningOnUser_setsRunningOnUser() {
+    fakeUserConnector.setRunningOnUser(targetUser);
+
+    assertThat(fakeUserConnector.runningOnUser()).isEqualTo(targetUser);
+  }
+
+  @Test
+  public void setRunningOnTargetUser_startsTargetUser() {
+    fakeUserConnector.setRunningOnUser(targetUser);
+    fakeUserConnector.setRunningOnUser(currentUser);
+
+    assertThat(fakeUserConnector.isAvailable(targetUser)).isTrue();
+  }
+
+  @Test
+  public void removeTargetUser_targetUserBecomesUnavailable() {
+    fakeUserConnector.turnOnUser(targetUser);
+    fakeUserConnector.removeUser(targetUser);
+
+    assertThat(fakeUserConnector.isAvailable(targetUser)).isFalse();
+  }
+
+  @Test
+  public void isConnected_isConnected_returnsTrue() throws Exception {
+    fakeUserConnector.setRunningOnUser(currentUser);
+    fakeUserConnector.turnOnUser(targetUser);
+
+    fakeUserConnector.connect(targetUser);
+
+    assertThat(fakeUserConnector.isConnected(targetUser)).isTrue();
+  }
+
+  @Test
+  public void isConnected_isNotConnected_returnsFalse() {
+    fakeUserConnector.setRunningOnUser(currentUser);
+    fakeUserConnector.turnOnUser(targetUser);
+
+    fakeUserConnector.disconnect(targetUser);
+
+    assertThat(fakeUserConnector.isConnected(targetUser)).isFalse();
+  }
+
+  @Test
+  public void canMakeCrossProfileCalls_defaultsToTrue() {
+    assertThat(fakeUserConnector.permissions(targetUser).canMakeCrossProfileCalls()).isTrue();
+  }
+
+  @Test
+  public void canMakeCrossProfileCalls_setToFalse_returnsFalse() {
+    fakeUserConnector.setHasPermissionToMakeCrossProfileCalls(targetUser, false);
+
+    assertThat(fakeUserConnector.permissions(targetUser).canMakeCrossProfileCalls()).isFalse();
+  }
+
+  @Test
+  public void canMakeCrossProfileCalls_setToTrue_returnsTrue() {
+    fakeUserConnector.setHasPermissionToMakeCrossProfileCalls(targetUser, false);
+    fakeUserConnector.setHasPermissionToMakeCrossProfileCalls(targetUser, true);
+
+    assertThat(fakeUserConnector.permissions(targetUser).canMakeCrossProfileCalls()).isTrue();
+  }
+
+  @Test
+  public void timeoutConnection_hasNoConnectionHolders_disconnects() {
+    fakeUserConnector.turnOnUser(targetUser);
+    fakeUserConnector.clearConnectionHolders(targetUser);
+
+    fakeUserConnector.timeoutConnection(targetUser);
+
+    assertThat(fakeUserConnector.isConnected(targetUser)).isFalse();
+  }
+
+  @Test
+  public void addConnectionHolder_connects() {
+    fakeUserConnector.turnOnUser(targetUser);
+
+    fakeUserConnector.addConnectionHolder(targetUser, this);
+
+    assertThat(fakeUserConnector.isConnected(targetUser)).isTrue();
+  }
+
+  @Test
+  public void timeoutConnection_hasConnectionHolder_doesNotDisconnect() {
+    fakeUserConnector.turnOnUser(targetUser);
+    fakeUserConnector.addConnectionHolder(targetUser, this);
+
+    fakeUserConnector.timeoutConnection(targetUser);
+
+    assertThat(fakeUserConnector.isConnected(targetUser)).isTrue();
+  }
+
+  @Test
+  public void removeConnectionHolder_lastConnectionHolder_doesNotDisconnect() {
+    fakeUserConnector.turnOnUser(targetUser);
+    fakeUserConnector.addConnectionHolder(targetUser, this);
+
+    fakeUserConnector.removeConnectionHolder(targetUser, this);
+
+    assertThat(fakeUserConnector.isConnected(targetUser)).isTrue();
+  }
+
+  @Test
+  public void removeConnectionHolder_timeout_disconnects() {
+    fakeUserConnector.turnOnUser(targetUser);
+    fakeUserConnector.addConnectionHolder(targetUser, this);
+    fakeUserConnector.removeConnectionHolder(targetUser, this);
+
+    fakeUserConnector.timeoutConnection(targetUser);
+
+    assertThat(fakeUserConnector.isConnected(targetUser)).isFalse();
+  }
+
+  @Test
+  public void removeConnectionHolder_stillAnotherConnectionHolder_timeout_doesNotDisconnect() {
+    fakeUserConnector.turnOnUser(targetUser);
+    fakeUserConnector.addConnectionHolder(targetUser, this);
+    fakeUserConnector.addConnectionHolder(targetUser, connectionHolder);
+    fakeUserConnector.removeConnectionHolder(targetUser, this);
+
+    fakeUserConnector.timeoutConnection(targetUser);
+
+    assertThat(fakeUserConnector.isConnected(targetUser)).isTrue();
+  }
+
+  @Test
+  public void removeConnectionHolder_removingAlias_timeout_disconnects() {
+    fakeUserConnector.turnOnUser(targetUser);
+    fakeUserConnector.addConnectionHolder(targetUser, this);
+    fakeUserConnector.addConnectionHolderAlias(targetUser, connectionHolder, this);
+    fakeUserConnector.removeConnectionHolder(targetUser, connectionHolder);
+
+    fakeUserConnector.timeoutConnection(targetUser);
+
+    assertThat(fakeUserConnector.isConnected(targetUser)).isFalse();
+  }
+
+  @Test
+  public void removeConnectionHolder_removingWrapper_timeout_disconnects() {
+    fakeUserConnector.turnOnUser(targetUser);
+    UserConnectionHolder connectionHolder = fakeUserConnector.addConnectionHolder(targetUser, this);
+    fakeUserConnector.removeConnectionHolder(targetUser, connectionHolder);
+
+    fakeUserConnector.timeoutConnection(targetUser);
+
+    assertThat(fakeUserConnector.isConnected(targetUser)).isFalse();
+  }
+}
diff --git a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/testing/FakeCrossProfileTypeTest.java b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/testing/FakeCrossProfileTypeTest.java
index 3599dd6..821cb63 100644
--- a/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/testing/FakeCrossProfileTypeTest.java
+++ b/tests/robotests/src/test/java/com/google/android/enterprise/connectedapps/testing/FakeCrossProfileTypeTest.java
@@ -34,6 +34,7 @@
 import com.google.android.enterprise.connectedapps.annotations.CustomProfileConnector.ProfileType;
 import com.google.android.enterprise.connectedapps.exceptions.ProfileRuntimeException;
 import com.google.android.enterprise.connectedapps.exceptions.UnavailableProfileException;
+import com.google.android.enterprise.connectedapps.testapp.CustomError;
 import com.google.android.enterprise.connectedapps.testapp.CustomRuntimeException;
 import com.google.android.enterprise.connectedapps.testapp.configuration.TestApplication;
 import com.google.android.enterprise.connectedapps.testapp.connector.FakeTestProfileConnector;
@@ -45,6 +46,7 @@
 import java.sql.SQLException;
 import java.util.Map;
 import java.util.concurrent.ExecutionException;
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -89,8 +91,13 @@
   public void setUp() {
     connector.setRunningOnProfile(ProfileType.PERSONAL);
     connector.turnOnWorkProfile();
-    connector.startConnecting();
-    connector.registerConnectionListener(connectionListener);
+    connector.addConnectionHolder(this);
+    connector.addConnectionListener(connectionListener);
+  }
+
+  @After
+  public void teardown() {
+    connector.clearConnectionHolders();
   }
 
   @Test
@@ -290,9 +297,9 @@
   }
 
   @Test
-  public void blockingCall_notManuallyManagingConnection_throwsUnavailableProfileException()
+  public void blockingCall_noConnectionHolders_throwsUnavailableProfileException()
       throws Exception {
-    connector.stopManualConnectionManagement();
+    connector.clearConnectionHolders();
     connector.turnOnWorkProfile();
     fakeCrossProfileType.other().listenableFutureVoidMethod().get(); // Force connection
 
@@ -324,17 +331,6 @@
   }
 
   @Test
-  public void asyncCall_notConnected_doesNotStartManualConnectionManagement() {
-    connector.turnOnWorkProfile();
-    connector.stopManualConnectionManagement();
-    connector.disconnect();
-
-    fakeCrossProfileType.work().asyncVoidMethod(voidCallbackListener, exceptionCallbackListener);
-
-    assertThat(connector.isManuallyManagingConnection()).isFalse();
-  }
-
-  @Test
   public void asyncCall_workProfileUnavailable_callsWithUnavailableProfileException() {
     connector.removeWorkProfile();
 
@@ -369,17 +365,6 @@
   }
 
   @Test
-  public void futureCall_notConnected_doesNotStartManualConnectionManagement() {
-    connector.turnOnWorkProfile();
-    connector.stopManualConnectionManagement();
-    connector.disconnect();
-
-    ListenableFuture<Void> unusedFuture = fakeCrossProfileType.work().listenableFutureVoidMethod();
-
-    assertThat(connector.isManuallyManagingConnection()).isFalse();
-  }
-
-  @Test
   public void futureCall_workProfileUnavailable_setsUnavailableProfileException() {
     connector.removeWorkProfile();
 
@@ -438,6 +423,11 @@
   }
 
   @Test
+  public void current_synchronous_throwsError_errorIsThrown() {
+    assertThrows(CustomError.class, () -> fakeCrossProfileType.current().methodWhichThrowsError());
+  }
+
+  @Test
   public void other_synchronous_throwsRuntimeException_exceptionIsWrapped()
       throws UnavailableProfileException {
     try {
@@ -449,6 +439,17 @@
   }
 
   @Test
+  public void other_synchronous_throwsError_exceptionIsWrapped()
+      throws UnavailableProfileException {
+    try {
+      fakeCrossProfileType.other().methodWhichThrowsError();
+      fail();
+    } catch (ProfileRuntimeException expected) {
+      assertThat(expected).hasCauseThat().isInstanceOf(CustomError.class);
+    }
+  }
+
+  @Test
   public void current_async_throwsRuntimeException_runtimeExceptionIsThrown() {
     assertThrows(
         CustomRuntimeException.class,
@@ -460,6 +461,14 @@
   }
 
   @Test
+  public void current_async_throwsError_errorIsThrown() {
+    assertThrows(
+        CustomError.class,
+        () ->
+            fakeCrossProfileType.current().asyncStringMethodWhichThrowsError(/* callback= */ null));
+  }
+
+  @Test
   public void other_async_throwsRuntimeException_exceptionIsWrapped() {
     try {
       fakeCrossProfileType
@@ -473,6 +482,19 @@
   }
 
   @Test
+  public void other_async_throwsError_errorIsWrapped() {
+    try {
+      fakeCrossProfileType
+          .other()
+          .asyncStringMethodWhichThrowsError(
+              /* callback= */ null, /* exceptionCallback= */ null);
+      fail();
+    } catch (ProfileRuntimeException expected) {
+      assertThat(expected).hasCauseThat().isInstanceOf(CustomError.class);
+    }
+  }
+
+  @Test
   public void current_future_throwsRuntimeException_runtimeExceptionIsThrown() {
     assertThrows(
         CustomRuntimeException.class,
@@ -482,6 +504,13 @@
   }
 
   @Test
+  public void current_future_throwsError_errorIsThrown() {
+    assertThrows(
+        CustomError.class,
+        () -> fakeCrossProfileType.current().listenableFutureVoidMethodWhichThrowsError());
+  }
+
+  @Test
   public void other_future_throwsRuntimeException_exceptionIsWrapped() {
     try {
       fakeCrossProfileType.other().listenableFutureVoidMethodWhichThrowsRuntimeException();
@@ -492,6 +521,16 @@
   }
 
   @Test
+  public void other_future_throwsError_errorIsWrapped() {
+    try {
+      fakeCrossProfileType.other().listenableFutureVoidMethodWhichThrowsError();
+      fail();
+    } catch (ProfileRuntimeException expected) {
+      assertThat(expected).hasCauseThat().isInstanceOf(CustomError.class);
+    }
+  }
+
+  @Test
   public void both_synchronous_throwsRuntimeException_exceptionIsThrown() {
     // Which one is thrown when both throw exceptions is not specified
     try {
@@ -505,6 +544,19 @@
   }
 
   @Test
+  public void both_synchronous_throwsError_errorIsThrown() {
+    // Which one is thrown when both throw exceptions is not specified
+    try {
+      fakeCrossProfileType.both().methodWhichThrowsError();
+      fail();
+    } catch (CustomError expected) {
+
+    } catch (ProfileRuntimeException expected) {
+      assertThat(expected).hasCauseThat().isInstanceOf(CustomError.class);
+    }
+  }
+
+  @Test
   public void both_async_throwsRuntimeException_exceptionIsThrown() {
     // Which one is thrown when both throw exceptions is not specified
     try {
@@ -520,6 +572,21 @@
   }
 
   @Test
+  public void both_async_throwsError_errorIsThrown() {
+    // Which one is thrown when both throw exceptions is not specified
+    try {
+      fakeCrossProfileType
+          .both()
+          .asyncStringMethodWhichThrowsError(/* callback= */ null);
+      fail();
+    } catch (CustomError expected) {
+
+    } catch (ProfileRuntimeException expected) {
+      assertThat(expected).hasCauseThat().isInstanceOf(CustomError.class);
+    }
+  }
+
+  @Test
   public void both_future_throwsRuntimeException_exceptionIsThrown() {
     // Which one is thrown when both throw exceptions is not specified
     try {
@@ -533,6 +600,19 @@
   }
 
   @Test
+  public void both_future_throwsError_errorIsThrown() {
+    // Which one is thrown when both throw exceptions is not specified
+    try {
+      fakeCrossProfileType.both().listenableFutureVoidMethodWhichThrowsError();
+      fail();
+    } catch (CustomError expected) {
+
+    } catch (ProfileRuntimeException expected) {
+      assertThat(expected).hasCauseThat().isInstanceOf(CustomError.class);
+    }
+  }
+
+  @Test
   public void ifAvailable_synchronous_notConnected_returnsDefaultValue() {
     connector.disconnect();
 
@@ -556,7 +636,7 @@
 
   @Test
   public void ifAvailable_synchronous_connected_returnsCorrectValue() {
-    connector.startConnecting();
+    connector.addConnectionHolder(this);
 
     assertThat(
             fakeCrossProfileType
@@ -568,7 +648,7 @@
 
   @Test
   public void ifAvailable_synchronousVoid_connected_callsMethod() {
-    connector.startConnecting();
+    connector.addConnectionHolder(this);
     connector.setRunningOnProfile(ProfileType.PERSONAL);
     fakeCrossProfileType.other().ifAvailable().voidMethod();
 
diff --git a/tests/shared/additional_types/build.gradle b/tests/shared/additional_types/build.gradle
index 342d739..920a418 100644
--- a/tests/shared/additional_types/build.gradle
+++ b/tests/shared/additional_types/build.gradle
@@ -38,7 +38,7 @@
             java.srcDirs = [file('../src/main/java')]
             java.includes = [
                     "com/google/android/enterprise/connectedapps/testapp/types/TestCrossProfileInterface.java",
-                    "com/google/android/enterprise/connectedapps/testapp/types/TestInterfaceProvider.java",
+                    "com/google/android/enterprise/connectedapps/testapp/TestInterfaceProvider.java",
             ]
             manifest.srcFile 'AndroidManifest.xml'
         }
diff --git a/tests/shared/basictypes/build.gradle b/tests/shared/basictypes/build.gradle
index f00a2ac..cd8b697 100644
--- a/tests/shared/basictypes/build.gradle
+++ b/tests/shared/basictypes/build.gradle
@@ -35,6 +35,7 @@
             java.srcDirs = [file('../src/main/java')]
             java.includes = [
                     "com/google/android/enterprise/connectedapps/testapp/CustomRuntimeException.java",
+                    "com/google/android/enterprise/connectedapps/testapp/CustomError.java",
                     "com/google/android/enterprise/connectedapps/testapp/CustomWrapper.java",
                     "com/google/android/enterprise/connectedapps/testapp/CustomWrapper2.java",
                     "com/google/android/enterprise/connectedapps/testapp/NonSimpleCallbackListener.java",
@@ -48,6 +49,8 @@
                     "com/google/android/enterprise/connectedapps/testapp/TestNotReallySerializableObjectCallbackListener.java",
                     "com/google/android/enterprise/connectedapps/testapp/TestStringCallbackListener.java",
                     "com/google/android/enterprise/connectedapps/testapp/TestVoidCallbackListener.java",
+                    "com/google/android/enterprise/connectedapps/testapp/TestParcelableCallbackListener.java",
+                    "com/google/android/enterprise/connectedapps/testapp/ParcelableContainingBinder.java"
             ]
             manifest.srcFile 'AndroidManifest.xml'
         }
diff --git a/tests/shared/build.gradle b/tests/shared/build.gradle
index 4642f3b..370f8a9 100644
--- a/tests/shared/build.gradle
+++ b/tests/shared/build.gradle
@@ -11,8 +11,9 @@
 dependencies {
     api deps.checkerFramework
     api project(path: ':connectedapps-testapp')
+    api project(path: ':connectedapps-testapp_crossuser')
     implementation project(path: ':connectedapps-annotations')
-    implementation 'org.robolectric:robolectric:4.4'
+    implementation deps.robolectric
     implementation 'junit:junit:4.13.1'
     implementation 'com.google.truth:truth:1.1.2'
     implementation 'androidx.test:core:1.3.0'
diff --git a/tests/shared/crossuser/build.gradle b/tests/shared/crossuser/build.gradle
index 4cceaba..acd19d2 100644
--- a/tests/shared/crossuser/build.gradle
+++ b/tests/shared/crossuser/build.gradle
@@ -13,6 +13,7 @@
     implementation project(path: ':connectedapps-annotations')
     implementation project(path: ':connectedapps-processor')
     annotationProcessor project(path: ':connectedapps-processor')
+    implementation project(path: ':connectedapps-testapp_basictypes')
 }
 
 android {
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/SharedTestUtilities.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/SharedTestUtilities.java
index 5ff3b0b..012c576 100644
--- a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/SharedTestUtilities.java
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/SharedTestUtilities.java
@@ -20,8 +20,10 @@
 
 import android.os.UserHandle;
 import com.google.common.util.concurrent.FluentFuture;
+import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -92,5 +94,23 @@
     assertThat(didThrow.get()).isFalse();
   }
 
+  /**
+   * Repeatedly call {@code a} and {@code b} on different threads to attempt to force a race
+   * condition.
+   */
+  public static void tryForceRaceCondition(int iterations, Runnable a, Runnable b)
+      throws Exception {
+    ExecutorService executorA = Executors.newSingleThreadExecutor();
+    ExecutorService executorB = Executors.newSingleThreadExecutor();
+
+    for (int i = 0; i < iterations; i++) {
+      ListenableFuture<?> aFuture = Futures.submit(a, executorA);
+      ListenableFuture<?> bFuture = Futures.submit(b, executorB);
+
+      aFuture.get();
+      bFuture.get();
+    }
+  }
+
   private SharedTestUtilities() {}
 }
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/TestParcelableCallbackListenerImpl.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/TestParcelableCallbackListenerImpl.java
new file mode 100644
index 0000000..3184dcd
--- /dev/null
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/TestParcelableCallbackListenerImpl.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps;
+
+import android.os.Parcelable;
+import com.google.android.enterprise.connectedapps.testapp.TestParcelableCallbackListener;
+
+public class TestParcelableCallbackListenerImpl implements TestParcelableCallbackListener {
+
+  public int callbackMethodCalls = 0;
+  public Parcelable parcelableCallbackValue;
+
+  @Override
+  public void parcelableCallback(Parcelable s) {
+    callbackMethodCalls++;
+    parcelableCallbackValue = s;
+  }
+}
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/TestStringCallbackListenerMultiImpl.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/TestStringCallbackListenerMultiImpl.java
index 5f3043e..e81f581 100644
--- a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/TestStringCallbackListenerMultiImpl.java
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/TestStringCallbackListenerMultiImpl.java
@@ -19,11 +19,13 @@
 import java.util.Map;
 
 public class TestStringCallbackListenerMultiImpl implements TestStringCallbackListener_Multi {
+  public int numberOfCallbacks = 0;
   public int numberOfResults = 0;
   public Map<Profile, String> stringCallbackValues;
 
   @Override
   public void stringCallback(Map<Profile, String> s) {
+    numberOfCallbacks++;
     numberOfResults = s.size();
     stringCallbackValues = s;
   }
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/CustomError.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/CustomError.java
new file mode 100644
index 0000000..9a1dddd
--- /dev/null
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/CustomError.java
@@ -0,0 +1,5 @@
+package com.google.android.enterprise.connectedapps.testapp;
+
+public class CustomError extends Error {
+
+}
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/ParcelableContainingBinder.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/ParcelableContainingBinder.java
new file mode 100644
index 0000000..57575aa
--- /dev/null
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/ParcelableContainingBinder.java
@@ -0,0 +1,42 @@
+package com.google.android.enterprise.connectedapps.testapp;
+
+import android.os.Binder;
+import android.os.IBinder;
+import android.os.Parcel;
+import android.os.Parcelable;
+
+public class ParcelableContainingBinder implements Parcelable {
+
+  IBinder binder;
+
+  public ParcelableContainingBinder() {
+    binder = new Binder();
+  }
+
+  private ParcelableContainingBinder(Parcel in) {
+    binder = in.readStrongBinder();
+  }
+
+  @Override
+  public void writeToParcel(Parcel dest, int flags) {
+    dest.writeStrongBinder(binder);
+  }
+
+  @Override
+  public int describeContents() {
+    return 0;
+  }
+
+  public static final Creator<ParcelableContainingBinder> CREATOR =
+      new Creator<ParcelableContainingBinder>() {
+        @Override
+        public ParcelableContainingBinder createFromParcel(Parcel in) {
+          return new ParcelableContainingBinder(in);
+        }
+
+        @Override
+        public ParcelableContainingBinder[] newArray(int size) {
+          return new ParcelableContainingBinder[size];
+        }
+      };
+}
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestInterfaceProvider.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/TestInterfaceProvider.java
similarity index 84%
rename from tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestInterfaceProvider.java
rename to tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/TestInterfaceProvider.java
index 06c01ba..e9b7675 100644
--- a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestInterfaceProvider.java
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/TestInterfaceProvider.java
@@ -13,9 +13,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.android.enterprise.connectedapps.testapp.types;
+package com.google.android.enterprise.connectedapps.testapp;
 
 import com.google.android.enterprise.connectedapps.annotations.CrossProfileProvider;
+import com.google.android.enterprise.connectedapps.testapp.types.TestCrossProfileInterface;
 
 public class TestInterfaceProvider {
 
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestInterfaceProvider.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/TestParcelableCallbackListener.java
similarity index 72%
copy from tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestInterfaceProvider.java
copy to tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/TestParcelableCallbackListener.java
index 06c01ba..690c9a0 100644
--- a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestInterfaceProvider.java
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/TestParcelableCallbackListener.java
@@ -13,14 +13,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.android.enterprise.connectedapps.testapp.types;
+package com.google.android.enterprise.connectedapps.testapp;
 
-import com.google.android.enterprise.connectedapps.annotations.CrossProfileProvider;
+import android.os.Parcelable;
+import com.google.android.enterprise.connectedapps.annotations.CrossProfileCallback;
 
-public class TestInterfaceProvider {
-
-  @CrossProfileProvider
-  public TestCrossProfileInterface provideCrossProfileInterface() {
-    return s -> s;
-  }
+@CrossProfileCallback
+public interface TestParcelableCallbackListener {
+  void parcelableCallback(Parcelable value);
 }
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/TestStringCallbackListener.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/TestStringCallbackListener.java
index dc25651..c8531f9 100644
--- a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/TestStringCallbackListener.java
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/TestStringCallbackListener.java
@@ -19,5 +19,5 @@
 
 @CrossProfileCallback
 public interface TestStringCallbackListener {
-  void stringCallback(String s);
+  void stringCallback(String value);
 }
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/configuration/ExceptionsSuppressingApplication.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/configuration/ExceptionsSuppressingApplication.java
new file mode 100644
index 0000000..d1dc8ef
--- /dev/null
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/configuration/ExceptionsSuppressingApplication.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.testapp.configuration;
+
+import com.google.android.enterprise.connectedapps.annotations.CrossProfileConfiguration;
+import com.google.android.enterprise.connectedapps.testapp.connector.ExceptionsSuppressingConnector;
+import com.google.android.enterprise.connectedapps.testapp.types.ExceptionsSuppressingTestProvider;
+
+@CrossProfileConfiguration(
+    providers = {ExceptionsSuppressingTestProvider.class},
+    connector = ExceptionsSuppressingConnector.class)
+public abstract class ExceptionsSuppressingApplication {
+
+  private ExceptionsSuppressingApplication() {}
+}
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/configuration/TestApplication.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/configuration/TestApplication.java
index de1d8f5..6e6d608 100644
--- a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/configuration/TestApplication.java
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/configuration/TestApplication.java
@@ -19,7 +19,7 @@
 import com.google.android.enterprise.connectedapps.annotations.CrossProfileConfiguration;
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector_Service;
 import com.google.android.enterprise.connectedapps.testapp.types.SeparateBuildTargetProvider;
-import com.google.android.enterprise.connectedapps.testapp.types.TestInterfaceProvider;
+import com.google.android.enterprise.connectedapps.testapp.TestInterfaceProvider;
 import com.google.android.enterprise.connectedapps.testapp.types.TestProvider;
 
 @CrossProfileConfiguration(providers = {
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/connector/ExceptionsSuppressingConnector.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/connector/ExceptionsSuppressingConnector.java
new file mode 100644
index 0000000..930579f
--- /dev/null
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/connector/ExceptionsSuppressingConnector.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.testapp.connector;
+
+import android.content.Context;
+import com.google.android.enterprise.connectedapps.ProfileConnector;
+import com.google.android.enterprise.connectedapps.annotations.CustomProfileConnector;
+import com.google.android.enterprise.connectedapps.annotations.CustomProfileConnector.ProfileType;
+import com.google.android.enterprise.connectedapps.annotations.GeneratedProfileConnector;
+import com.google.android.enterprise.connectedapps.annotations.UncaughtExceptionsPolicy;
+import com.google.android.enterprise.connectedapps.testapp.wrappers.ParcelableCustomWrapper;
+import com.google.android.enterprise.connectedapps.testapp.wrappers.SimpleFutureWrapper;
+import java.util.concurrent.ScheduledExecutorService;
+
+@GeneratedProfileConnector
+@CustomProfileConnector(
+    primaryProfile = ProfileType.WORK,
+    uncaughtExceptionsPolicy = UncaughtExceptionsPolicy.NOTIFY_SUPPRESS,
+    parcelableWrappers = {ParcelableCustomWrapper.class},
+    futureWrappers = {SimpleFutureWrapper.class})
+public interface ExceptionsSuppressingConnector extends ProfileConnector {
+  static ExceptionsSuppressingConnector create(Context context) {
+    return GeneratedExceptionsSuppressingConnector.builder(context).build();
+  }
+
+  static ExceptionsSuppressingConnector create(
+      Context context, ScheduledExecutorService scheduledExecutorService) {
+    return GeneratedExceptionsSuppressingConnector.builder(context)
+        .setScheduledExecutorService(scheduledExecutorService)
+        .build();
+  }
+}
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/TestCrossUserConfiguration.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/AppCrossUserConfiguration.java
similarity index 79%
rename from tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/TestCrossUserConfiguration.java
rename to tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/AppCrossUserConfiguration.java
index f8ac4e8..6423923 100644
--- a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/TestCrossUserConfiguration.java
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/AppCrossUserConfiguration.java
@@ -19,13 +19,15 @@
 import com.google.android.enterprise.connectedapps.annotations.CrossUserConfiguration;
 import com.google.android.enterprise.connectedapps.annotations.CrossUserConfigurations;
 
-@CrossUserConfigurations(@CrossUserConfiguration(providers = TestCrossUserProvider.class))
-public abstract class TestCrossUserConfiguration {
+@CrossUserConfigurations({
+  @CrossUserConfiguration(providers = {TestCrossUserProvider.class, NotesManagerProvider.class})
+})
+public abstract class AppCrossUserConfiguration {
 
   // This is available so the test targets can access the generated Service class.
   public static Class<? extends Service> getService() {
-    return TestCrossUserConnector_Service.class;
+    return AppCrossUserConnector_Service.class;
   }
 
-  private TestCrossUserConfiguration() {}
+  private AppCrossUserConfiguration() {}
 }
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/AppCrossUserConnector.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/AppCrossUserConnector.java
new file mode 100644
index 0000000..887f889
--- /dev/null
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/AppCrossUserConnector.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.testapp.crossuser;
+
+import android.content.Context;
+import com.google.android.enterprise.connectedapps.UserBinderFactory;
+import com.google.android.enterprise.connectedapps.UserConnector;
+import com.google.android.enterprise.connectedapps.annotations.CustomUserConnector;
+import com.google.android.enterprise.connectedapps.annotations.GeneratedUserConnector;
+import java.util.concurrent.ScheduledExecutorService;
+
+@GeneratedUserConnector
+@CustomUserConnector
+public interface AppCrossUserConnector extends UserConnector {
+  static AppCrossUserConnector create(Context context) {
+    return GeneratedAppCrossUserConnector.builder(context).build();
+  }
+
+  static AppCrossUserConnector create(
+      Context context, ScheduledExecutorService scheduledExecutorService) {
+    return GeneratedAppCrossUserConnector.builder(context)
+        .setScheduledExecutorService(scheduledExecutorService)
+        .build();
+  }
+
+  static AppCrossUserConnector create(
+      Context context,
+      ScheduledExecutorService scheduledExecutorService,
+      UserBinderFactory binderFactory) {
+    return GeneratedAppCrossUserConnector.builder(context)
+        .setBinderFactory(binderFactory)
+        .setScheduledExecutorService(scheduledExecutorService)
+        .build();
+  }
+}
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/NotesManager.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/NotesManager.java
new file mode 100644
index 0000000..f7f31d2
--- /dev/null
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/NotesManager.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2021 Google LLC
+ *
+ * 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
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.enterprise.connectedapps.testapp.crossuser;
+
+import com.google.android.enterprise.connectedapps.annotations.CrossUser;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import java.util.HashSet;
+import java.util.Set;
+
+public final class NotesManager {
+
+  private final Set<String> notes = new HashSet<>();
+
+  public void addNote(String note) {
+    notes.add(note);
+  }
+
+  @CrossUser
+  public ListenableFuture<Set<String>> getNotesFuture() {
+    return Futures.immediateFuture(notes);
+  }
+}
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestInterfaceProvider.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/NotesManagerProvider.java
similarity index 73%
copy from tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestInterfaceProvider.java
copy to tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/NotesManagerProvider.java
index 06c01ba..60bf65b 100644
--- a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestInterfaceProvider.java
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/NotesManagerProvider.java
@@ -13,14 +13,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.google.android.enterprise.connectedapps.testapp.types;
+package com.google.android.enterprise.connectedapps.testapp.crossuser;
 
-import com.google.android.enterprise.connectedapps.annotations.CrossProfileProvider;
+import com.google.android.enterprise.connectedapps.annotations.CrossUserProvider;
 
-public class TestInterfaceProvider {
+public class NotesManagerProvider {
 
-  @CrossProfileProvider
-  public TestCrossProfileInterface provideCrossProfileInterface() {
-    return s -> s;
+  @CrossUserProvider
+  public NotesManager provideNotesManager() {
+    return new NotesManager();
   }
 }
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/TestCrossUserConnector.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/TestCrossUserConnector.java
deleted file mode 100644
index 72c3a8c..0000000
--- a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/TestCrossUserConnector.java
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * Copyright 2021 Google LLC
- *
- * 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
- *
- *   https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.google.android.enterprise.connectedapps.testapp.crossuser;
-
-import android.content.Context;
-import com.google.android.enterprise.connectedapps.ProfileConnector;
-import com.google.android.enterprise.connectedapps.annotations.CustomProfileConnector;
-import com.google.android.enterprise.connectedapps.annotations.CustomProfileConnector.ProfileType;
-import com.google.android.enterprise.connectedapps.annotations.GeneratedProfileConnector;
-import java.util.concurrent.ScheduledExecutorService;
-
-@GeneratedProfileConnector
-@CustomProfileConnector(primaryProfile = ProfileType.WORK)
-public interface TestCrossUserConnector extends ProfileConnector {
-  static TestCrossUserConnector create(Context context) {
-    return GeneratedTestCrossUserConnector.builder(context).build();
-  }
-
-  static TestCrossUserConnector create(
-      Context context, ScheduledExecutorService scheduledExecutorService) {
-    return GeneratedTestCrossUserConnector.builder(context)
-        .setScheduledExecutorService(scheduledExecutorService)
-        .build();
-  }
-}
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/TestCrossUserType.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/TestCrossUserType.java
index 9be071d..4d8a6c4 100644
--- a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/TestCrossUserType.java
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/crossuser/TestCrossUserType.java
@@ -16,12 +16,26 @@
 package com.google.android.enterprise.connectedapps.testapp.crossuser;
 
 import com.google.android.enterprise.connectedapps.annotations.CrossUser;
+import com.google.android.enterprise.connectedapps.testapp.TestStringCallbackListener;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
 
-@CrossUser(connector = TestCrossUserConnector.class, timeoutMillis = 7000)
+@CrossUser(connector = AppCrossUserConnector.class)
 public class TestCrossUserType {
 
   @CrossUser
-  public void passString(String string, TestCrossUserStringCallbackListener callbackListener) {
+  public String identityStringMethod(String string) {
+    return string;
+  }
+
+  @CrossUser
+  public ListenableFuture<String> listenableFutureIdentityStringMethod(String string) {
+    return Futures.immediateFuture(string);
+  }
+
+  @CrossUser
+  public void asyncIdentityStringMethod(
+      String string, TestStringCallbackListener callbackListener) {
     callbackListener.stringCallback(string);
   }
 }
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/ExceptionsSuppressingCrossProfileType.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/ExceptionsSuppressingCrossProfileType.java
new file mode 100644
index 0000000..618269d
--- /dev/null
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/ExceptionsSuppressingCrossProfileType.java
@@ -0,0 +1,13 @@
+package com.google.android.enterprise.connectedapps.testapp.types;
+
+import com.google.android.enterprise.connectedapps.annotations.CrossProfile;
+import com.google.android.enterprise.connectedapps.testapp.CustomRuntimeException;
+import com.google.android.enterprise.connectedapps.testapp.connector.ExceptionsSuppressingConnector;
+
+@CrossProfile(connector = ExceptionsSuppressingConnector.class)
+public class ExceptionsSuppressingCrossProfileType {
+  @CrossProfile
+  public String methodWhichThrowsRuntimeException() {
+    throw new CustomRuntimeException("Exception");
+  }
+}
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/ExceptionsSuppressingTestProvider.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/ExceptionsSuppressingTestProvider.java
new file mode 100644
index 0000000..30c21d0
--- /dev/null
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/ExceptionsSuppressingTestProvider.java
@@ -0,0 +1,12 @@
+package com.google.android.enterprise.connectedapps.testapp.types;
+
+import com.google.android.enterprise.connectedapps.annotations.CrossProfileProvider;
+
+@CrossProfileProvider
+public class ExceptionsSuppressingTestProvider {
+
+  @CrossProfileProvider
+  public ExceptionsSuppressingCrossProfileType provideExceptionsSuppressingCrossProfileType() {
+    return new ExceptionsSuppressingCrossProfileType();
+  }
+}
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestCrossProfileType.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestCrossProfileType.java
index ddadc81..1466ffd 100644
--- a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestCrossProfileType.java
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestCrossProfileType.java
@@ -20,9 +20,13 @@
 
 import android.content.Context;
 import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
 import android.os.Handler;
+import android.os.Looper;
+import android.os.Parcelable;
 import android.util.Pair;
 import com.google.android.enterprise.connectedapps.annotations.CrossProfile;
+import com.google.android.enterprise.connectedapps.testapp.CustomError;
 import com.google.android.enterprise.connectedapps.testapp.CustomRuntimeException;
 import com.google.android.enterprise.connectedapps.testapp.CustomWrapper;
 import com.google.android.enterprise.connectedapps.testapp.CustomWrapper2;
@@ -35,6 +39,7 @@
 import com.google.android.enterprise.connectedapps.testapp.TestBooleanCallbackListener;
 import com.google.android.enterprise.connectedapps.testapp.TestCustomWrapperCallbackListener;
 import com.google.android.enterprise.connectedapps.testapp.TestNotReallySerializableObjectCallbackListener;
+import com.google.android.enterprise.connectedapps.testapp.TestParcelableCallbackListener;
 import com.google.android.enterprise.connectedapps.testapp.TestStringCallbackListener;
 import com.google.android.enterprise.connectedapps.testapp.TestVoidCallbackListener;
 import com.google.android.enterprise.connectedapps.testapp.connector.TestProfileConnector;
@@ -55,7 +60,6 @@
 
 @CrossProfile(
     connector = TestProfileConnector.class,
-    timeoutMillis = 7000,
     parcelableWrappers = {ParcelableCustomWrapper2.class, ParcelableStringWrapper.class})
 public class TestCrossProfileType {
 
@@ -74,11 +78,21 @@
   }
 
   @CrossProfile
+  public String methodWhichThrowsError() {
+    throw new CustomError();
+  }
+
+  @CrossProfile
   public String methodWhichThrowsRuntimeException() {
     throw new CustomRuntimeException("Exception");
   }
 
   @CrossProfile
+  public String methodWhichThrowsRuntimeExceptionAndDeclaresException() throws IOException {
+    throw new CustomRuntimeException("Exception");
+  }
+
+  @CrossProfile
   public ListenableFuture<Void> listenableFutureVoidMethod() {
     voidMethod();
     return immediateFuture(null);
@@ -89,14 +103,9 @@
     return SettableFuture.create();
   }
 
-  @CrossProfile // Timeout is inherited
-  public ListenableFuture<Void> listenableFutureMethodWhichNeverSetsTheValueWith7SecondTimeout() {
-    return SettableFuture.create();
-  }
-
-  @CrossProfile(timeoutMillis = 5000)
-  public ListenableFuture<Void> listenableFutureMethodWhichNeverSetsTheValueWith5SecondTimeout() {
-    return SettableFuture.create();
+  @CrossProfile
+  public ListenableFuture<Void> listenableFutureVoidMethodWhichThrowsError() {
+    throw new CustomError();
   }
 
   @CrossProfile
@@ -123,7 +132,7 @@
   public ListenableFuture<Void> listenableFutureVoidMethodWithNonBlockingDelay(int secondsDelay) {
     SettableFuture<Void> v = SettableFuture.create();
 
-    new Handler()
+    new Handler(Looper.getMainLooper())
         .postDelayed(
             () -> {
               voidMethod();
@@ -138,18 +147,14 @@
       String s, int secondsDelay) {
     SettableFuture<String> v = SettableFuture.create();
 
-    new Handler().postDelayed(() -> v.set(s), TimeUnit.SECONDS.toMillis(secondsDelay));
+    new Handler(Looper.getMainLooper())
+        .postDelayed(() -> v.set(s), TimeUnit.SECONDS.toMillis(secondsDelay));
     return v;
   }
 
-  @CrossProfile(timeoutMillis = 3000)
-  public ListenableFuture<String>
-      listenableFutureIdentityStringMethodWithNonBlockingDelayWith3SecondTimeout(
-          String s, int secondsDelay) {
-    SettableFuture<String> v = SettableFuture.create();
-
-    new Handler().postDelayed(() -> v.set(s), TimeUnit.SECONDS.toMillis(secondsDelay));
-    return v;
+  @CrossProfile
+  public void asyncStringMethodWhichThrowsError(TestStringCallbackListener callback) {
+    throw new CustomError();
   }
 
   @CrossProfile
@@ -158,13 +163,29 @@
   }
 
   @CrossProfile
-  public void asyncVoidMethodWhichCallsBackTwice(TestVoidCallbackListener callback) {
+  public void asyncIdentityStringMethodWhichCallsBackTwice(
+      String s, TestStringCallbackListener callback) {
     voidMethod();
-    callback.callback();
-    callback.callback();
+    callback.stringCallback(s);
+    callback.stringCallback(s);
   }
 
   @CrossProfile
+  public void asyncIdentityStringMethodWhichCallsBackTwiceWithNonBlockingDelay(
+      String s, TestStringCallbackListener callback, long secondsDelay) {
+    voidMethod();
+    callback.stringCallback(s);
+
+    new Handler(Looper.getMainLooper())
+        .postDelayed(
+            () -> {
+              callback.stringCallback(s);
+            },
+            TimeUnit.SECONDS.toMillis(secondsDelay));
+  }
+
+
+  @CrossProfile
   public void asyncVoidMethod(TestVoidCallbackListener callback) {
     voidMethod();
     callback.callback();
@@ -173,14 +194,6 @@
   @CrossProfile
   public void asyncMethodWhichNeverCallsBack(TestStringCallbackListener callback) {}
 
-  @CrossProfile // Timeout is inherited
-  public void asyncMethodWhichNeverCallsBackWith7SecondTimeout(
-      TestStringCallbackListener callback) {}
-
-  @CrossProfile(timeoutMillis = 5000)
-  public void asyncMethodWhichNeverCallsBackWith5SecondTimeout(
-      TestStringCallbackListener callback) {}
-
   @CrossProfile
   public void asyncVoidMethodWithDelay(TestVoidCallbackListener callback, int secondsDelay) {
     try {
@@ -194,29 +207,14 @@
   @CrossProfile
   public void asyncVoidMethodWithNonBlockingDelay(
       TestVoidCallbackListener callback, int secondsDelay) {
-    new Handler()
+    new Handler(Looper.getMainLooper())
         .postDelayed(() -> asyncVoidMethod(callback), TimeUnit.SECONDS.toMillis(secondsDelay));
   }
 
-  @CrossProfile(timeoutMillis = 50000)
-  public void asyncVoidMethodWithNonBlockingDelayWith50SecondTimeout(
-      TestVoidCallbackListener callback, int secondsDelay) {
-    new Handler()
-        .postDelayed(() -> asyncVoidMethod(callback), TimeUnit.SECONDS.toMillis(secondsDelay));
-  }
-
-  @CrossProfile(timeoutMillis = 3000)
-  public void asyncIdentityStringMethodWithNonBlockingDelayWith3SecondTimeout(
-      String s, TestStringCallbackListener callback, int secondsDelay) {
-    new Handler()
-        .postDelayed(
-            () -> asyncIdentityStringMethod(s, callback), TimeUnit.SECONDS.toMillis(secondsDelay));
-  }
-
   @CrossProfile
   public void asyncIdentityStringMethodWithNonBlockingDelay(
       String s, TestStringCallbackListener callback, int secondsDelay) {
-    new Handler()
+    new Handler(Looper.getMainLooper())
         .postDelayed(
             () -> asyncIdentityStringMethod(s, callback), TimeUnit.SECONDS.toMillis(secondsDelay));
   }
@@ -358,11 +356,31 @@
   }
 
   @CrossProfile
+  public CharSequence identityCharSequenceMethod(CharSequence c) {
+    return c;
+  }
+
+  @CrossProfile
   public ParcelableObject identityParcelableMethod(ParcelableObject p) {
     return p;
   }
 
   @CrossProfile
+  public Parcelable identityParcelableMethod(Parcelable p) {
+    return p;
+  }
+
+  @CrossProfile
+  public void asyncIdentityParcelableMethod(Parcelable p, TestParcelableCallbackListener callback) {
+    callback.parcelableCallback(p);
+  }
+
+  @CrossProfile
+  public ListenableFuture<Parcelable> futureIdentityParcelableMethod(Parcelable p) {
+    return immediateFuture(p);
+  }
+
+  @CrossProfile
   public SerializableObject identitySerializableObjectMethod(SerializableObject s) {
     return s;
   }
@@ -533,7 +551,8 @@
       String s, int secondsDelay) {
     SimpleFuture<String> future = new SimpleFuture<>();
 
-    new Handler().postDelayed(() -> future.set(s), TimeUnit.SECONDS.toMillis(secondsDelay));
+    new Handler(Looper.getMainLooper())
+        .postDelayed(() -> future.set(s), TimeUnit.SECONDS.toMillis(secondsDelay));
 
     return future;
   }
@@ -599,4 +618,89 @@
       NonSimpleCallbackListener callback, String s1, String s2) {
     callback.callback2(s1, s2);
   }
+
+  @CrossProfile
+  public float[] identityFloatArrayMethod(float[] f) {
+    return f;
+  }
+
+  @CrossProfile
+  public float[][][] identityMultidimensionalFloatArrayMethod(float[][][] f) {
+    return f;
+  }
+
+  @CrossProfile
+  public int[] identityIntArrayMethod(int[] i) {
+    return i;
+  }
+
+  @CrossProfile
+  public int[][][] identityMultidimensionalIntArrayMethod(int[][][] i) {
+    return i;
+  }
+
+  @CrossProfile
+  public byte[] identityByteArrayMethod(byte[] b) {
+    return b;
+  }
+
+  @CrossProfile
+  public byte[][][] identityMultidimensionalByteArrayMethod(byte[][][] b) {
+    return b;
+  }
+
+  @CrossProfile
+  public short[] identityShortArrayMethod(short[] s) {
+    return s;
+  }
+
+  @CrossProfile
+  public short[][][] identityMultidimensionalShortArrayMethod(short[][][] s) {
+    return s;
+  }
+
+  @CrossProfile
+  public long[] identityLongArrayMethod(long[] l) {
+    return l;
+  }
+
+  @CrossProfile
+  public long[][][] identityMultidimensionalLongArrayMethod(long[][][] l) {
+    return l;
+  }
+
+  @CrossProfile
+  public double[] identityDoubleArrayMethod(double[] d) {
+    return d;
+  }
+
+  @CrossProfile
+  public double[][][] identityMultidimensionalDoubleArrayMethod(double[][][] d) {
+    return d;
+  }
+
+  @CrossProfile
+  public boolean[] identityBooleanArrayMethod(boolean[] b) {
+    return b;
+  }
+
+  @CrossProfile
+  public boolean[][][] identityMultidimensionalBooleanArrayMethod(boolean[][][] b) {
+    return b;
+  }
+
+  @CrossProfile
+  public char[] identityCharArrayMethod(char[] c) {
+    return c;
+  }
+
+  @CrossProfile
+  public char[][][] identityMultidimensionalCharArrayMethod(char[][][] c) {
+    return c;
+  }
+
+  @CrossProfile
+  public Drawable identityDrawableMethod(Drawable d) {
+    return d;
+  }
 }
diff --git a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestCrossProfileTypeWhichNeedsContext.java b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestCrossProfileTypeWhichNeedsContext.java
index f98a5b1..c63edbd 100644
--- a/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestCrossProfileTypeWhichNeedsContext.java
+++ b/tests/shared/src/main/java/com/google/android/enterprise/connectedapps/testapp/types/TestCrossProfileTypeWhichNeedsContext.java
@@ -45,14 +45,13 @@
         ProfileTestCrossProfileType.create(ConnectorSingleton.getConnector(context));
   }
 
-  @CrossProfile // Timeout is not specified on type or method so will be default
-  public ListenableFuture<Void> listenableFutureMethodWhichNeverSetsTheValueWithDefaultTimeout() {
+  @CrossProfile
+  public ListenableFuture<Void> listenableFutureMethodWhichNeverSetsTheValue() {
     return SettableFuture.create();
   }
 
-  @CrossProfile // Timeout is not specified on type or method so will be default
-  public void asyncMethodWhichNeverCallsBackWithDefaultTimeout(
-      TestStringCallbackListener callback) {}
+  @CrossProfile
+  public void asyncMethodWhichNeverCallsBack(TestStringCallbackListener callback) {}
 
   @CrossProfile
   public void voidMethod() {
@@ -63,7 +62,11 @@
   public void connectToOtherProfile() {
     // This, when called cross-profile, causes the other profile to create a connection back to the
     // original profile
-    ConnectorSingleton.getConnector(context).startConnecting();
+    try {
+      ConnectorSingleton.getConnector(context).connect(this);
+    } catch (UnavailableProfileException e) {
+      throw new IllegalStateException("Error connecting", e);
+    }
   }
 
   @CrossProfile
diff --git a/tests/shared/testapp/build.gradle b/tests/shared/testapp/build.gradle
index 16da3a4..ccbd938 100644
--- a/tests/shared/testapp/build.gradle
+++ b/tests/shared/testapp/build.gradle
@@ -16,7 +16,6 @@
     api project(path: ':connectedapps-testapp_types')
     api project(path: ':connectedapps-testapp_types_providers')
     api project(path: ':connectedapps-testapp_wrappers')
-    api project(path: ':connectedapps-testapp_crossuser')
 }
 
 android {