mapping in AndroidManifest.xml.
+ channel =
+ BinderChannelBuilder.forTarget(
+ "intent://authority/path#Intent;action=action1;scheme=scheme;end;", appContext)
+ .build();
assertThat(doCall("Hello").get()).isEqualTo("Hello");
}
@@ -245,7 +243,10 @@ public final class BinderChannelSmokeTest {
channel =
BinderChannelBuilder.forAddress(
AndroidComponentAddress.forBindIntent(
- new Intent().setAction("action1").setPackage(appContext.getPackageName())),
+ new Intent()
+ .setAction("action1")
+ .setData(Uri.parse("scheme://authority/path"))
+ .setPackage(appContext.getPackageName())),
appContext)
.build();
assertThat(doCall("Hello").get()).isEqualTo("Hello");
diff --git a/binder/src/main/java/io/grpc/binder/ApiConstants.java b/binder/src/main/java/io/grpc/binder/ApiConstants.java
index 462586311c..fbf4be6b7c 100644
--- a/binder/src/main/java/io/grpc/binder/ApiConstants.java
+++ b/binder/src/main/java/io/grpc/binder/ApiConstants.java
@@ -34,6 +34,18 @@ public final class ApiConstants {
*/
public static final String ACTION_BIND = "grpc.io.action.BIND";
+ /**
+ * Gives a {@link NameResolver} access to its Channel's "source" {@link android.content.Context},
+ * the entry point to almost every other Android API.
+ *
+ * This argument is set automatically by {@link BinderChannelBuilder}. Any value passed to
+ * {@link io.grpc.ManagedChannelBuilder#setNameResolverArg} will be ignored.
+ *
+ *
See {@link BinderChannelBuilder#forTarget(String, android.content.Context)} for more.
+ */
+ public static final NameResolver.Args.Key SOURCE_ANDROID_CONTEXT =
+ NameResolver.Args.Key.create("source-android-context");
+
/**
* Specifies the Android user in which target URIs should be resolved.
*
diff --git a/binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java b/binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java
index 233ec6eac4..18928339fb 100644
--- a/binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java
+++ b/binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java
@@ -321,6 +321,8 @@ public final class BinderChannelBuilder extends ForwardingChannelBuilder offloadExecutorPool) {
this.offloadExecutorPool = checkNotNull(offloadExecutorPool, "offloadExecutorPool");
return this;
diff --git a/binder/src/main/java/io/grpc/binder/internal/IntentNameResolver.java b/binder/src/main/java/io/grpc/binder/internal/IntentNameResolver.java
new file mode 100644
index 0000000000..ce3e2a96a4
--- /dev/null
+++ b/binder/src/main/java/io/grpc/binder/internal/IntentNameResolver.java
@@ -0,0 +1,299 @@
+/*
+ * Copyright 2025 The gRPC Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.grpc.binder.internal;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static io.grpc.binder.internal.SystemApis.createContextAsUser;
+
+import android.annotation.SuppressLint;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.os.Build;
+import android.os.UserHandle;
+import com.google.common.collect.ImmutableMap;
+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.MoreExecutors;
+import io.grpc.Attributes;
+import io.grpc.EquivalentAddressGroup;
+import io.grpc.NameResolver;
+import io.grpc.Status;
+import io.grpc.StatusException;
+import io.grpc.StatusOr;
+import io.grpc.SynchronizationContext;
+import io.grpc.binder.AndroidComponentAddress;
+import io.grpc.binder.ApiConstants;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import javax.annotation.Nullable;
+
+/**
+ * A {@link NameResolver} that resolves Android-standard "intent:" target URIs to the list of {@link
+ * AndroidComponentAddress} that match it by manifest intent filter.
+ */
+final class IntentNameResolver extends NameResolver {
+ private final Intent targetIntent; // Never mutated.
+ @Nullable private final UserHandle targetUser; // null means same user that hosts this process.
+ private final Context targetUserContext;
+ private final Executor offloadExecutor;
+ private final Executor sequentialExecutor;
+ private final SynchronizationContext syncContext;
+ private final ServiceConfigParser serviceConfigParser;
+
+ // Accessed only on `sequentialExecutor`
+ @Nullable private PackageChangeReceiver receiver; // != null when registered
+
+ // Accessed only on 'syncContext'.
+ private boolean shutdown;
+ private boolean queryNeeded;
+ @Nullable private Listener2 listener; // != null after start().
+ @Nullable private ListenableFuture queryResultFuture; // != null when querying.
+
+ @EquivalentAddressGroup.Attr
+ private static final Attributes CONSTANT_EAG_ATTRS =
+ Attributes.newBuilder()
+ // Servers discovered in PackageManager are especially untrusted. After all, any app can
+ // declare any intent filter it wants! Require pre-authorization so that unauthorized apps
+ // don't even get a chance to run onCreate()/onBind().
+ .set(ApiConstants.PRE_AUTH_SERVER_OVERRIDE, true)
+ .build();
+
+ IntentNameResolver(Intent targetIntent, Args args) {
+ this.targetIntent = targetIntent;
+ this.targetUser = args.getArg(ApiConstants.TARGET_ANDROID_USER);
+ Context context =
+ checkNotNull(args.getArg(ApiConstants.SOURCE_ANDROID_CONTEXT), "SOURCE_ANDROID_CONTEXT")
+ .getApplicationContext();
+ this.targetUserContext =
+ targetUser != null ? createContextForTargetUserOrThrow(context, targetUser) : context;
+ // This Executor is nominally optional but all grpc-java Channels provide it since 1.25.
+ this.offloadExecutor =
+ checkNotNull(args.getOffloadExecutor(), "NameResolver.Args.getOffloadExecutor()");
+ // Ensures start()'s work runs before resolve()'s' work, and both run before shutdown()'s.
+ this.sequentialExecutor = MoreExecutors.newSequentialExecutor(offloadExecutor);
+ this.syncContext = args.getSynchronizationContext();
+ this.serviceConfigParser = args.getServiceConfigParser();
+ }
+
+ private static Context createContextForTargetUserOrThrow(Context context, UserHandle targetUser) {
+ try {
+ return createContextAsUser(context, targetUser, /* flags= */ 0); // @SystemApi since R.
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalArgumentException(
+ "TARGET_ANDROID_USER NameResolver.Arg requires SDK_INT >= R and @SystemApi visibility");
+ }
+ }
+
+ @Override
+ public void start(Listener2 listener) {
+ checkState(this.listener == null, "Already started!");
+ checkState(!shutdown, "Resolver is shutdown");
+ this.listener = checkNotNull(listener);
+ sequentialExecutor.execute(this::registerReceiver);
+ resolve();
+ }
+
+ @Override
+ public void refresh() {
+ checkState(listener != null, "Not started!");
+ resolve();
+ }
+
+ private void resolve() {
+ syncContext.throwIfNotInThisSynchronizationContext();
+
+ if (shutdown) {
+ return;
+ }
+
+ // We can't block here in 'syncContext' so we offload PackageManager queries to an Executor.
+ // But offloading complicates things a bit because other calls can arrive while we wait for the
+ // results. We keep 'listener' up-to-date with the latest state in PackageManager by doing:
+ // 1. Only one query-and-report-to-listener operation at a time.
+ // 2. At least one query-and-report-to-listener AFTER every PackageManager state change.
+ if (queryResultFuture == null) {
+ queryResultFuture = Futures.submit(this::queryPackageManager, sequentialExecutor);
+ queryResultFuture.addListener(this::onQueryComplete, syncContext);
+ } else {
+ // There's already a query in-flight but (2) says we need at least one more. Our sequential
+ // Executor would be enough to ensure (1) but we also don't want a backlog of work to build up
+ // if things change rapidly. Just make a note to start a new query when this one finishes.
+ queryNeeded = true;
+ }
+ }
+
+ private void onQueryComplete() {
+ syncContext.throwIfNotInThisSynchronizationContext();
+ checkState(queryResultFuture != null);
+ checkState(queryResultFuture.isDone());
+
+ // Capture non-final `listener` here while we're on 'syncContext'.
+ Listener2 listener = checkNotNull(this.listener);
+ Futures.addCallback(
+ queryResultFuture, // Already isDone() so this execute()s immediately.
+ new FutureCallback() {
+ @Override
+ public void onSuccess(ResolutionResult result) {
+ listener.onResult2(result);
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ listener.onResult2(
+ ResolutionResult.newBuilder()
+ .setAddressesOrError(StatusOr.fromStatus(Status.fromThrowable(t)))
+ .build());
+ }
+ },
+ syncContext); // Already on 'syncContext' but addCallback() is faster than try/get/catch.
+ queryResultFuture = null;
+
+ if (queryNeeded) {
+ // One or more resolve() requests arrived while we were working on the last one. Just one
+ // follow-on query can subsume all of them.
+ queryNeeded = false;
+ resolve();
+ }
+ }
+
+ @Override
+ public String getServiceAuthority() {
+ return "localhost";
+ }
+
+ @Override
+ public void shutdown() {
+ syncContext.throwIfNotInThisSynchronizationContext();
+ if (!shutdown) {
+ shutdown = true;
+ sequentialExecutor.execute(this::maybeUnregisterReceiver);
+ }
+ }
+
+ private ResolutionResult queryPackageManager() throws StatusException {
+ List queryResults = queryIntentServices(targetIntent);
+
+ // Avoid a spurious UnsafeIntentLaunchViolation later. Since S, Android's StrictMode is very
+ // conservative, marking any Intent parsed from a string as suspicious and complaining when you
+ // bind to it. But all this is pointless with grpc-binder, which already goes even further by
+ // not trusting addresses at all! Instead, we rely on SecurityPolicy, which won't allow a
+ // connection to an unauthorized server UID no matter how you got there.
+ Intent prototypeBindIntent = sanitize(targetIntent);
+
+ // Model each matching android.app.Service as an EAG (server) with a single address.
+ List addresses = new ArrayList<>();
+ for (ResolveInfo resolveInfo : queryResults) {
+ prototypeBindIntent.setComponent(
+ new ComponentName(resolveInfo.serviceInfo.packageName, resolveInfo.serviceInfo.name));
+ addresses.add(
+ new EquivalentAddressGroup(
+ AndroidComponentAddress.newBuilder()
+ .setBindIntent(prototypeBindIntent) // Makes a copy.
+ .setTargetUser(targetUser)
+ .build(),
+ CONSTANT_EAG_ATTRS));
+ }
+
+ return ResolutionResult.newBuilder()
+ .setAddressesOrError(StatusOr.fromValue(addresses))
+ // Empty service config means we get the default 'pick_first' load balancing policy.
+ .setServiceConfig(serviceConfigParser.parseServiceConfig(ImmutableMap.of()))
+ .build();
+ }
+
+ private List queryIntentServices(Intent intent) throws StatusException {
+ int flags = 0;
+ if (Build.VERSION.SDK_INT >= 29) {
+ // Don't match direct-boot-unaware Services that can't presently be created. We'll query again
+ // after the user is unlocked. The MATCH_DIRECT_BOOT_AUTO behavior is actually the default but
+ // being explicit here avoids an android.os.strictmode.ImplicitDirectBootViolation.
+ flags |= PackageManager.MATCH_DIRECT_BOOT_AUTO;
+ }
+
+ List intentServices =
+ targetUserContext.getPackageManager().queryIntentServices(intent, flags);
+ if (intentServices == null || intentServices.isEmpty()) {
+ // Must be the same as when ServiceBinding's call to bindService() returns false.
+ throw Status.UNIMPLEMENTED
+ .withDescription("Service not found for intent " + intent)
+ .asException();
+ }
+ return intentServices;
+ }
+
+ // Returns a new Intent with the same action, data and categories as 'input'.
+ private static Intent sanitize(Intent input) {
+ Intent output = new Intent();
+ output.setAction(input.getAction());
+ output.setData(input.getData());
+
+ Set categories = input.getCategories();
+ if (categories != null) {
+ for (String category : categories) {
+ output.addCategory(category);
+ }
+ }
+ // Don't bother copying extras and flags since AndroidComponentAddress (rightly) ignores them.
+ // Don't bother copying package or ComponentName either, since we're about to set that.
+ return output;
+ }
+
+ final class PackageChangeReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ // Get off the main thread and into the correct SynchronizationContext.
+ syncContext.executeLater(IntentNameResolver.this::resolve);
+ offloadExecutor.execute(syncContext::drain);
+ }
+ }
+
+ @SuppressLint("UnprotectedReceiver") // All of these are protected system broadcasts.
+ private void registerReceiver() {
+ checkState(receiver == null, "Already registered!");
+ receiver = new PackageChangeReceiver();
+ IntentFilter filter = new IntentFilter();
+ filter.addDataScheme("package");
+ filter.addAction(Intent.ACTION_PACKAGE_ADDED);
+ filter.addAction(Intent.ACTION_PACKAGE_CHANGED);
+ filter.addAction(Intent.ACTION_PACKAGE_REMOVED);
+ filter.addAction(Intent.ACTION_PACKAGE_REPLACED);
+
+ targetUserContext.registerReceiver(receiver, filter);
+
+ if (Build.VERSION.SDK_INT >= 24) {
+ // Clients running in direct boot mode must refresh() when the user is unlocked because
+ // that's when `directBootAware=false` services become visible in queryIntentServices()
+ // results. ACTION_BOOT_COMPLETED would work too but it's delivered with lower priority.
+ targetUserContext.registerReceiver(receiver, new IntentFilter(Intent.ACTION_USER_UNLOCKED));
+ }
+ }
+
+ private void maybeUnregisterReceiver() {
+ if (receiver != null) { // NameResolver API contract appears to allow shutdown without start().
+ targetUserContext.unregisterReceiver(receiver);
+ receiver = null;
+ }
+ }
+}
diff --git a/binder/src/main/java/io/grpc/binder/internal/IntentNameResolverProvider.java b/binder/src/main/java/io/grpc/binder/internal/IntentNameResolverProvider.java
new file mode 100644
index 0000000000..67859045db
--- /dev/null
+++ b/binder/src/main/java/io/grpc/binder/internal/IntentNameResolverProvider.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2025 The gRPC Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.grpc.binder.internal;
+
+import static android.content.Intent.URI_INTENT_SCHEME;
+
+import android.content.Intent;
+import com.google.common.collect.ImmutableSet;
+import io.grpc.NameResolver;
+import io.grpc.NameResolver.Args;
+import io.grpc.NameResolverProvider;
+import io.grpc.binder.AndroidComponentAddress;
+import java.net.SocketAddress;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Objects;
+import javax.annotation.Nullable;
+
+/**
+ * A {@link NameResolverProvider} that handles Android-standard "intent:" target URIs, resolving
+ * them to the list of {@link AndroidComponentAddress} that match by manifest intent filter.
+ */
+public final class IntentNameResolverProvider extends NameResolverProvider {
+
+ static final String ANDROID_INTENT_SCHEME = "intent";
+
+ @Override
+ public String getDefaultScheme() {
+ return ANDROID_INTENT_SCHEME;
+ }
+
+ @Nullable
+ @Override
+ public NameResolver newNameResolver(URI targetUri, final Args args) {
+ if (Objects.equals(targetUri.getScheme(), ANDROID_INTENT_SCHEME)) {
+ return new IntentNameResolver(parseUriArg(targetUri), args);
+ } else {
+ return null;
+ }
+ }
+
+ @Override
+ public boolean isAvailable() {
+ return true;
+ }
+
+ @Override
+ public int priority() {
+ return 3; // Lower than DNS so we don't accidentally become the default scheme for a registry.
+ }
+
+ @Override
+ public ImmutableSet> getProducedSocketAddressTypes() {
+ return ImmutableSet.of(AndroidComponentAddress.class);
+ }
+
+ private static Intent parseUriArg(URI targetUri) {
+ try {
+ return Intent.parseUri(targetUri.toString(), URI_INTENT_SCHEME);
+ } catch (URISyntaxException e) {
+ throw new IllegalArgumentException(e);
+ }
+ }
+}
diff --git a/binder/src/main/java/io/grpc/binder/internal/SystemApis.java b/binder/src/main/java/io/grpc/binder/internal/SystemApis.java
new file mode 100644
index 0000000000..a4feec86a1
--- /dev/null
+++ b/binder/src/main/java/io/grpc/binder/internal/SystemApis.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2025 The gRPC Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.grpc.binder.internal;
+
+import android.content.Context;
+import android.os.UserHandle;
+import java.lang.reflect.Method;
+
+/**
+ * A collection of static methods that wrap hidden Android "System APIs."
+ *
+ * grpc-java can't call Android methods marked @SystemApi directly, even though many of our users
+ * are "system apps" entitled to do so. Being a library built outside the Android source tree, these
+ * "non-SDK" elements simply don't exist from our compiler's perspective. Instead we resort to
+ * reflection but use the static wrappers found here to keep call sites readable and type safe.
+ *
+ *
Modern Android's JRE also limits the visibility of these methods at *runtime*. Only certain
+ * privileged apps installed on the system image app can call them, even using reflection, and this
+ * wrapper doesn't change that. Callers are responsible for ensuring that the host app actually has
+ * the ability to call @SystemApis and all methods throw {@link ReflectiveOperationException} as a
+ * reminder to do that. See
+ * https://developer.android.com/guide/app-compatibility/restrictions-non-sdk-interfaces for more.
+ */
+final class SystemApis {
+ private static volatile Method createContextAsUserMethod;
+
+ // Not to be instantiated.
+ private SystemApis() {}
+
+ /**
+ * Returns a new Context object whose methods act as if they were running in the given user.
+ *
+ * @throws ReflectiveOperationException if SDK_INT < R or host app lacks @SystemApi visibility
+ */
+ public static Context createContextAsUser(Context context, UserHandle userHandle, int flags)
+ throws ReflectiveOperationException {
+ if (createContextAsUserMethod == null) {
+ synchronized (SystemApis.class) {
+ if (createContextAsUserMethod == null) {
+ createContextAsUserMethod =
+ Context.class.getMethod("createContextAsUser", UserHandle.class, int.class);
+ }
+ }
+ }
+ return (Context) createContextAsUserMethod.invoke(context, userHandle, flags);
+ }
+}
diff --git a/binder/src/test/java/io/grpc/binder/internal/IntentNameResolverProviderTest.java b/binder/src/test/java/io/grpc/binder/internal/IntentNameResolverProviderTest.java
new file mode 100644
index 0000000000..aa75ba84b0
--- /dev/null
+++ b/binder/src/test/java/io/grpc/binder/internal/IntentNameResolverProviderTest.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2025 The gRPC Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.grpc.binder.internal;
+
+import static android.os.Looper.getMainLooper;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import androidx.core.content.ContextCompat;
+import androidx.test.core.app.ApplicationProvider;
+import io.grpc.NameResolver;
+import io.grpc.NameResolver.ResolutionResult;
+import io.grpc.NameResolver.ServiceConfigParser;
+import io.grpc.NameResolverProvider;
+import io.grpc.SynchronizationContext;
+import io.grpc.binder.ApiConstants;
+import java.net.URI;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoTestRule;
+import org.robolectric.RobolectricTestRunner;
+
+/** A test for IntentNameResolverProvider. */
+@RunWith(RobolectricTestRunner.class)
+public final class IntentNameResolverProviderTest {
+
+ private final Application appContext = ApplicationProvider.getApplicationContext();
+ private final SynchronizationContext syncContext = newSynchronizationContext();
+ private final NameResolver.Args args = newNameResolverArgs();
+
+ private NameResolverProvider provider;
+
+ @Rule public MockitoTestRule mockitoTestRule = MockitoJUnit.testRule(this);
+ @Mock public NameResolver.Listener2 mockListener;
+ @Captor public ArgumentCaptor resultCaptor;
+
+ @Before
+ public void setUp() {
+ provider = new IntentNameResolverProvider();
+ }
+
+ @Test
+ public void testProviderScheme_returnsIntentScheme() throws Exception {
+ assertThat(provider.getDefaultScheme())
+ .isEqualTo(IntentNameResolverProvider.ANDROID_INTENT_SCHEME);
+ }
+
+ @Test
+ public void testNoResolverForUnknownScheme_returnsNull() throws Exception {
+ assertThat(provider.newNameResolver(new URI("random://uri"), args)).isNull();
+ }
+
+ @Test
+ public void testResolutionWithBadUri_throwsIllegalArg() throws Exception {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> provider.newNameResolver(new URI("intent:xxx#Intent;e.x=1;end;"), args));
+ }
+
+ @Test
+ public void testResolverForIntentScheme_returnsResolver() throws Exception {
+ URI uri = new URI("intent://authority/path#Intent;action=action;scheme=scheme;end");
+ NameResolver resolver = provider.newNameResolver(uri, args);
+ assertThat(resolver).isNotNull();
+ assertThat(resolver.getServiceAuthority()).isEqualTo("localhost");
+ syncContext.execute(() -> resolver.start(mockListener));
+ shadowOf(getMainLooper()).idle();
+ verify(mockListener).onResult2(resultCaptor.capture());
+ assertThat(resultCaptor.getValue().getAddressesOrError()).isNotNull();
+ syncContext.execute(resolver::shutdown);
+ shadowOf(getMainLooper()).idle();
+ }
+
+ /** Returns a new test-specific {@link NameResolver.Args} instance. */
+ private NameResolver.Args newNameResolverArgs() {
+ return NameResolver.Args.newBuilder()
+ .setDefaultPort(-1)
+ .setProxyDetector((target) -> null) // No proxies here.
+ .setSynchronizationContext(syncContext)
+ .setOffloadExecutor(ContextCompat.getMainExecutor(appContext))
+ .setServiceConfigParser(mock(ServiceConfigParser.class))
+ .setArg(ApiConstants.SOURCE_ANDROID_CONTEXT, appContext)
+ .build();
+ }
+
+ private static SynchronizationContext newSynchronizationContext() {
+ return new SynchronizationContext(
+ (thread, exception) -> {
+ throw new AssertionError(exception);
+ });
+ }
+}
diff --git a/binder/src/test/java/io/grpc/binder/internal/IntentNameResolverTest.java b/binder/src/test/java/io/grpc/binder/internal/IntentNameResolverTest.java
new file mode 100644
index 0000000000..b1bfcd4fd5
--- /dev/null
+++ b/binder/src/test/java/io/grpc/binder/internal/IntentNameResolverTest.java
@@ -0,0 +1,531 @@
+/*
+ * Copyright 2025 The gRPC Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.grpc.binder.internal;
+
+import static android.content.Intent.ACTION_PACKAGE_ADDED;
+import static android.content.Intent.ACTION_PACKAGE_REPLACED;
+import static android.os.Looper.getMainLooper;
+import static android.os.Process.myUserHandle;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.Application;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.ServiceInfo;
+import android.net.Uri;
+import android.os.UserHandle;
+import android.os.UserManager;
+import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.common.collect.ImmutableList;
+import io.grpc.EquivalentAddressGroup;
+import io.grpc.NameResolver;
+import io.grpc.NameResolver.ResolutionResult;
+import io.grpc.NameResolver.ServiceConfigParser;
+import io.grpc.Status;
+import io.grpc.StatusOr;
+import io.grpc.SynchronizationContext;
+import io.grpc.binder.AndroidComponentAddress;
+import io.grpc.binder.ApiConstants;
+import java.lang.Thread.UncaughtExceptionHandler;
+import java.net.SocketAddress;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoTestRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowPackageManager;
+
+/** A test for IntentNameResolverProvider. */
+@RunWith(RobolectricTestRunner.class)
+public final class IntentNameResolverTest {
+
+ private static final ComponentName SOME_COMPONENT_NAME =
+ new ComponentName("com.foo.bar", "SomeComponent");
+ private static final ComponentName ANOTHER_COMPONENT_NAME =
+ new ComponentName("org.blah", "AnotherComponent");
+ private final Application appContext = ApplicationProvider.getApplicationContext();
+ private final SynchronizationContext syncContext = newSynchronizationContext();
+ private final NameResolver.Args args = newNameResolverArgs().build();
+
+ private final ShadowPackageManager shadowPackageManager =
+ shadowOf(appContext.getPackageManager());
+
+ @Rule public MockitoTestRule mockitoTestRule = MockitoJUnit.testRule(this);
+ @Mock public NameResolver.Listener2 mockListener;
+ @Captor public ArgumentCaptor resultCaptor;
+
+ @Test
+ public void testResolverForIntentScheme_returnsResolverWithLocalHostAuthority() throws Exception {
+ NameResolver resolver = newNameResolver(newIntent());
+ assertThat(resolver).isNotNull();
+ assertThat(resolver.getServiceAuthority()).isEqualTo("localhost");
+ }
+
+ @Test
+ public void testResolutionWithoutServicesAvailable_returnsUnimplemented() throws Exception {
+ NameResolver nameResolver = newNameResolver(newIntent());
+ syncContext.execute(() -> nameResolver.start(mockListener));
+ shadowOf(getMainLooper()).idle();
+ verify(mockListener).onResult2(resultCaptor.capture());
+ assertThat(resultCaptor.getValue().getAddressesOrError().getStatus().getCode())
+ .isEqualTo(Status.UNIMPLEMENTED.getCode());
+ }
+
+ @Test
+ public void testResolutionWithMultipleServicesAvailable_returnsAndroidComponentAddresses()
+ throws Exception {
+ Intent intent = newIntent();
+ IntentFilter serviceIntentFilter = newFilterMatching(intent);
+
+ shadowPackageManager.addServiceIfNotPresent(SOME_COMPONENT_NAME);
+ shadowPackageManager.addIntentFilterForService(SOME_COMPONENT_NAME, serviceIntentFilter);
+
+ // Adds another valid Service
+ shadowPackageManager.addServiceIfNotPresent(ANOTHER_COMPONENT_NAME);
+ shadowPackageManager.addIntentFilterForService(ANOTHER_COMPONENT_NAME, serviceIntentFilter);
+
+ NameResolver nameResolver = newNameResolver(intent);
+ syncContext.execute(() -> nameResolver.start(mockListener));
+ shadowOf(getMainLooper()).idle();
+
+ verify(mockListener, never()).onError(any());
+ verify(mockListener).onResult2(resultCaptor.capture());
+ assertThat(getAddressesOrThrow(resultCaptor.getValue()))
+ .containsExactly(
+ toAddressList(intent.cloneFilter().setComponent(SOME_COMPONENT_NAME)),
+ toAddressList(intent.cloneFilter().setComponent(ANOTHER_COMPONENT_NAME)));
+
+ syncContext.execute(nameResolver::shutdown);
+ shadowOf(getMainLooper()).idle();
+ }
+
+ @Test
+ public void testExplicitResolutionByComponent_returnsRestrictedResults() throws Exception {
+ Intent intent = newIntent();
+ IntentFilter serviceIntentFilter = newFilterMatching(intent);
+
+ shadowPackageManager.addServiceIfNotPresent(SOME_COMPONENT_NAME);
+ shadowPackageManager.addIntentFilterForService(SOME_COMPONENT_NAME, serviceIntentFilter);
+ shadowPackageManager.addServiceIfNotPresent(ANOTHER_COMPONENT_NAME);
+ shadowPackageManager.addIntentFilterForService(ANOTHER_COMPONENT_NAME, serviceIntentFilter);
+
+ NameResolver nameResolver =
+ newNameResolver(intent.cloneFilter().setComponent(ANOTHER_COMPONENT_NAME));
+ syncContext.execute(() -> nameResolver.start(mockListener));
+ shadowOf(getMainLooper()).idle();
+
+ verify(mockListener, never()).onError(any());
+ verify(mockListener).onResult2(resultCaptor.capture());
+ assertThat(getAddressesOrThrow(resultCaptor.getValue()))
+ .containsExactly(toAddressList(intent.cloneFilter().setComponent(ANOTHER_COMPONENT_NAME)));
+
+ syncContext.execute(nameResolver::shutdown);
+ shadowOf(getMainLooper()).idle();
+ }
+
+ @Test
+ public void testExplicitResolutionByPackage_returnsRestrictedResults() throws Exception {
+ Intent intent = newIntent();
+ IntentFilter serviceIntentFilter = newFilterMatching(intent);
+
+ shadowPackageManager.addServiceIfNotPresent(SOME_COMPONENT_NAME);
+ shadowPackageManager.addIntentFilterForService(SOME_COMPONENT_NAME, serviceIntentFilter);
+ shadowPackageManager.addServiceIfNotPresent(ANOTHER_COMPONENT_NAME);
+ shadowPackageManager.addIntentFilterForService(ANOTHER_COMPONENT_NAME, serviceIntentFilter);
+
+ NameResolver nameResolver =
+ newNameResolver(intent.cloneFilter().setPackage(ANOTHER_COMPONENT_NAME.getPackageName()));
+ syncContext.execute(() -> nameResolver.start(mockListener));
+ shadowOf(getMainLooper()).idle();
+
+ verify(mockListener, never()).onError(any());
+ verify(mockListener).onResult2(resultCaptor.capture());
+ assertThat(getAddressesOrThrow(resultCaptor.getValue()))
+ .containsExactly(toAddressList(intent.cloneFilter().setComponent(ANOTHER_COMPONENT_NAME)));
+
+ syncContext.execute(nameResolver::shutdown);
+ shadowOf(getMainLooper()).idle();
+ }
+
+ @Test
+ public void testResolution_setsPreAuthEagAttribute() throws Exception {
+ Intent intent = newIntent();
+ IntentFilter serviceIntentFilter = newFilterMatching(intent);
+
+ shadowPackageManager.addServiceIfNotPresent(SOME_COMPONENT_NAME);
+ shadowPackageManager.addIntentFilterForService(SOME_COMPONENT_NAME, serviceIntentFilter);
+
+ NameResolver nameResolver = newNameResolver(intent);
+ syncContext.execute(() -> nameResolver.start(mockListener));
+ shadowOf(getMainLooper()).idle();
+
+ verify(mockListener).onResult2(resultCaptor.capture());
+ assertThat(getAddressesOrThrow(resultCaptor.getValue()))
+ .containsExactly(toAddressList(intent.cloneFilter().setComponent(SOME_COMPONENT_NAME)));
+ assertThat(
+ getEagsOrThrow(resultCaptor.getValue()).stream()
+ .map(EquivalentAddressGroup::getAttributes)
+ .collect(toImmutableList())
+ .get(0)
+ .get(ApiConstants.PRE_AUTH_SERVER_OVERRIDE))
+ .isTrue();
+
+ syncContext.execute(nameResolver::shutdown);
+ shadowOf(getMainLooper()).idle();
+ }
+
+ @Test
+ public void testServiceRemoved_pushesUpdatedAndroidComponentAddresses() throws Exception {
+ Intent intent = newIntent();
+ IntentFilter serviceIntentFilter = newFilterMatching(intent);
+
+ shadowPackageManager.addServiceIfNotPresent(SOME_COMPONENT_NAME);
+ shadowPackageManager.addIntentFilterForService(SOME_COMPONENT_NAME, serviceIntentFilter);
+ shadowPackageManager.addServiceIfNotPresent(ANOTHER_COMPONENT_NAME);
+ shadowPackageManager.addIntentFilterForService(ANOTHER_COMPONENT_NAME, serviceIntentFilter);
+
+ NameResolver nameResolver = newNameResolver(intent);
+ syncContext.execute(() -> nameResolver.start(mockListener));
+ shadowOf(getMainLooper()).idle();
+
+ verify(mockListener, never()).onError(any());
+ verify(mockListener).onResult2(resultCaptor.capture());
+ assertThat(getAddressesOrThrow(resultCaptor.getValue()))
+ .containsExactly(
+ toAddressList(intent.cloneFilter().setComponent(SOME_COMPONENT_NAME)),
+ toAddressList(intent.cloneFilter().setComponent(ANOTHER_COMPONENT_NAME)));
+
+ shadowPackageManager.removeService(ANOTHER_COMPONENT_NAME);
+ broadcastPackageChange(ACTION_PACKAGE_REPLACED, ANOTHER_COMPONENT_NAME.getPackageName());
+ shadowOf(getMainLooper()).idle();
+
+ verify(mockListener, never()).onError(any());
+ verify(mockListener, times(2)).onResult2(resultCaptor.capture());
+ assertThat(getAddressesOrThrow(resultCaptor.getValue()))
+ .containsExactly(toAddressList(intent.cloneFilter().setComponent(SOME_COMPONENT_NAME)));
+
+ syncContext.execute(nameResolver::shutdown);
+ shadowOf(getMainLooper()).idle();
+
+ verifyNoMoreInteractions(mockListener);
+ assertThat(shadowOf(appContext).getRegisteredReceivers()).isEmpty();
+ }
+
+ @Test
+ @Config(sdk = 30)
+ public void testTargetAndroidUser_pushesUpdatedAddresses() throws Exception {
+ Intent intent = newIntent();
+ IntentFilter serviceIntentFilter = newFilterMatching(intent);
+
+ NameResolver nameResolver =
+ newNameResolver(
+ intent,
+ newNameResolverArgs().setArg(ApiConstants.TARGET_ANDROID_USER, myUserHandle()).build());
+ syncContext.execute(() -> nameResolver.start(mockListener));
+ shadowOf(getMainLooper()).idle();
+ verify(mockListener).onResult2(resultCaptor.capture());
+ assertThat(resultCaptor.getValue().getAddressesOrError().getStatus().getCode())
+ .isEqualTo(Status.UNIMPLEMENTED.getCode());
+
+ shadowPackageManager.addServiceIfNotPresent(SOME_COMPONENT_NAME);
+ shadowPackageManager.addIntentFilterForService(SOME_COMPONENT_NAME, serviceIntentFilter);
+ broadcastPackageChange(ACTION_PACKAGE_ADDED, SOME_COMPONENT_NAME.getPackageName());
+ shadowOf(getMainLooper()).idle();
+
+ verify(mockListener, never()).onError(any());
+ verify(mockListener, times(2)).onResult2(resultCaptor.capture());
+ assertThat(getAddressesOrThrow(resultCaptor.getValue()))
+ .containsExactly(
+ ImmutableList.of(
+ AndroidComponentAddress.newBuilder()
+ .setTargetUser(myUserHandle())
+ .setBindIntent(intent.cloneFilter().setComponent(SOME_COMPONENT_NAME))
+ .build()));
+
+ syncContext.execute(nameResolver::shutdown);
+ shadowOf(getMainLooper()).idle();
+
+ verifyNoMoreInteractions(mockListener);
+ assertThat(shadowOf(appContext).getRegisteredReceivers()).isEmpty();
+ }
+
+ @Test
+ @Config(sdk = 29)
+ public void testTargetAndroidUser_notSupported_throwsWithHelpfulMessage() throws Exception {
+ NameResolver.Args args =
+ newNameResolverArgs().setArg(ApiConstants.TARGET_ANDROID_USER, myUserHandle()).build();
+ IllegalArgumentException iae =
+ assertThrows(IllegalArgumentException.class, () -> newNameResolver(newIntent(), args));
+ assertThat(iae.getMessage()).contains("TARGET_ANDROID_USER");
+ assertThat(iae.getMessage()).contains("SDK_INT >= R");
+ }
+
+ @Test
+ @Config(sdk = 29)
+ public void testServiceAppearsUponBootComplete_pushesUpdatedAndroidComponentAddresses()
+ throws Exception {
+ Intent intent = newIntent();
+ IntentFilter serviceIntentFilter = newFilterMatching(intent);
+
+ // Suppose this directBootAware=true Service appears in PackageManager before a user unlock.
+ shadowOf(appContext.getSystemService(UserManager.class)).setUserUnlocked(false);
+ ServiceInfo someServiceInfo = shadowPackageManager.addServiceIfNotPresent(SOME_COMPONENT_NAME);
+ someServiceInfo.directBootAware = true;
+ shadowPackageManager.addIntentFilterForService(SOME_COMPONENT_NAME, serviceIntentFilter);
+
+ NameResolver nameResolver = newNameResolver(intent);
+ syncContext.execute(() -> nameResolver.start(mockListener));
+ shadowOf(getMainLooper()).idle();
+
+ verify(mockListener, never()).onError(any());
+ verify(mockListener).onResult2(resultCaptor.capture());
+ assertThat(getAddressesOrThrow(resultCaptor.getValue()))
+ .containsExactly(toAddressList(intent.cloneFilter().setComponent(SOME_COMPONENT_NAME)));
+
+ // TODO(b/331618070): Robolectric doesn't yet support ServiceInfo.directBootAware filtering.
+ // Simulate support by waiting for a user unlock to add this !directBootAware Service.
+ ServiceInfo anotherServiceInfo =
+ shadowPackageManager.addServiceIfNotPresent(ANOTHER_COMPONENT_NAME);
+ anotherServiceInfo.directBootAware = false;
+ shadowPackageManager.addIntentFilterForService(ANOTHER_COMPONENT_NAME, serviceIntentFilter);
+
+ shadowOf(appContext.getSystemService(UserManager.class)).setUserUnlocked(true);
+ broadcastUserUnlocked(myUserHandle());
+ shadowOf(getMainLooper()).idle();
+
+ verify(mockListener, never()).onError(any());
+ verify(mockListener, times(2)).onResult2(resultCaptor.capture());
+ assertThat(getAddressesOrThrow(resultCaptor.getValue()))
+ .containsExactly(
+ toAddressList(intent.cloneFilter().setComponent(SOME_COMPONENT_NAME)),
+ toAddressList(intent.cloneFilter().setComponent(ANOTHER_COMPONENT_NAME)));
+
+ syncContext.execute(nameResolver::shutdown);
+ shadowOf(getMainLooper()).idle();
+ verifyNoMoreInteractions(mockListener);
+ }
+
+ @Test
+ public void testRefresh_returnsSameAndroidComponentAddresses() throws Exception {
+ Intent intent = newIntent();
+ IntentFilter serviceIntentFilter = newFilterMatching(intent);
+
+ shadowPackageManager.addServiceIfNotPresent(SOME_COMPONENT_NAME);
+ shadowPackageManager.addIntentFilterForService(SOME_COMPONENT_NAME, serviceIntentFilter);
+ shadowPackageManager.addServiceIfNotPresent(ANOTHER_COMPONENT_NAME);
+ shadowPackageManager.addIntentFilterForService(ANOTHER_COMPONENT_NAME, serviceIntentFilter);
+
+ NameResolver nameResolver = newNameResolver(intent);
+ syncContext.execute(() -> nameResolver.start(mockListener));
+ shadowOf(getMainLooper()).idle();
+
+ verify(mockListener, never()).onError(any());
+ verify(mockListener).onResult2(resultCaptor.capture());
+ assertThat(getAddressesOrThrow(resultCaptor.getValue()))
+ .containsExactly(
+ toAddressList(intent.cloneFilter().setComponent(SOME_COMPONENT_NAME)),
+ toAddressList(intent.cloneFilter().setComponent(ANOTHER_COMPONENT_NAME)));
+
+ syncContext.execute(nameResolver::refresh);
+ shadowOf(getMainLooper()).idle();
+ verify(mockListener, never()).onError(any());
+ verify(mockListener, times(2)).onResult2(resultCaptor.capture());
+ assertThat(getAddressesOrThrow(resultCaptor.getValue()))
+ .containsExactly(
+ toAddressList(intent.cloneFilter().setComponent(SOME_COMPONENT_NAME)),
+ toAddressList(intent.cloneFilter().setComponent(ANOTHER_COMPONENT_NAME)));
+
+ syncContext.execute(nameResolver::shutdown);
+ shadowOf(getMainLooper()).idle();
+ assertThat(shadowOf(appContext).getRegisteredReceivers()).isEmpty();
+ }
+
+ @Test
+ public void testRefresh_collapsesMultipleRequestsIntoOneLookup() throws Exception {
+ Intent intent = newIntent();
+ IntentFilter serviceIntentFilter = newFilterMatching(intent);
+
+ shadowPackageManager.addServiceIfNotPresent(SOME_COMPONENT_NAME);
+ shadowPackageManager.addIntentFilterForService(SOME_COMPONENT_NAME, serviceIntentFilter);
+
+ NameResolver nameResolver = newNameResolver(intent);
+ syncContext.execute(() -> nameResolver.start(mockListener)); // Should kick off the 1st lookup.
+ syncContext.execute(nameResolver::refresh); // Should queue a lookup to run when 1st finishes.
+ syncContext.execute(nameResolver::refresh); // Should be ignored since a lookup is already Q'd.
+ syncContext.execute(nameResolver::refresh); // Also ignored.
+ shadowOf(getMainLooper()).idle();
+
+ verify(mockListener, never()).onError(any());
+ verify(mockListener, times(2)).onResult2(resultCaptor.capture());
+ assertThat(getAddressesOrThrow(resultCaptor.getValue()))
+ .containsExactly(toAddressList(intent.cloneFilter().setComponent(SOME_COMPONENT_NAME)));
+
+ syncContext.execute(nameResolver::shutdown);
+ shadowOf(getMainLooper()).idle();
+ }
+
+ private void broadcastPackageChange(String action, String pkgName) {
+ Intent broadcast = new Intent();
+ broadcast.setAction(action);
+ broadcast.setData(Uri.parse("package:" + pkgName));
+ appContext.sendBroadcast(broadcast);
+ }
+
+ private void broadcastUserUnlocked(UserHandle userHandle) {
+ Intent unlockedBroadcast = new Intent(Intent.ACTION_USER_UNLOCKED);
+ unlockedBroadcast.putExtra(Intent.EXTRA_USER, userHandle);
+ appContext.sendBroadcast(unlockedBroadcast);
+ }
+
+ @Test
+ public void testResolutionOnResultThrows_onErrorNotCalled() throws Exception {
+ RetainingUncaughtExceptionHandler exceptionHandler = new RetainingUncaughtExceptionHandler();
+ SynchronizationContext syncContext = new SynchronizationContext(exceptionHandler);
+ Intent intent = newIntent();
+ shadowPackageManager.addServiceIfNotPresent(SOME_COMPONENT_NAME);
+ shadowPackageManager.addIntentFilterForService(SOME_COMPONENT_NAME, newFilterMatching(intent));
+
+ @SuppressWarnings("serial")
+ class SomeRuntimeException extends RuntimeException {}
+ doThrow(SomeRuntimeException.class).when(mockListener).onResult2(any());
+
+ NameResolver nameResolver =
+ newNameResolver(
+ intent, newNameResolverArgs().setSynchronizationContext(syncContext).build());
+ syncContext.execute(() -> nameResolver.start(mockListener));
+ shadowOf(getMainLooper()).idle();
+
+ verify(mockListener).onResult2(any());
+ verify(mockListener, never()).onError(any());
+ assertThat(exceptionHandler.uncaught).hasSize(1);
+ assertThat(exceptionHandler.uncaught.get(0)).isInstanceOf(SomeRuntimeException.class);
+ }
+
+ private static Intent newIntent() {
+ Intent intent = new Intent();
+ intent.setAction("test.action");
+ intent.setData(Uri.parse("grpc:ServiceName"));
+ return intent;
+ }
+
+ private static IntentFilter newFilterMatching(Intent intent) {
+ IntentFilter filter = new IntentFilter();
+ if (intent.getAction() != null) {
+ filter.addAction(intent.getAction());
+ }
+ Uri data = intent.getData();
+ if (data != null) {
+ if (data.getScheme() != null) {
+ filter.addDataScheme(data.getScheme());
+ }
+ if (data.getSchemeSpecificPart() != null) {
+ filter.addDataSchemeSpecificPart(data.getSchemeSpecificPart(), 0);
+ }
+ }
+ Set categories = intent.getCategories();
+ if (categories != null) {
+ for (String category : categories) {
+ filter.addCategory(category);
+ }
+ }
+ return filter;
+ }
+
+ private static List getEagsOrThrow(ResolutionResult result) {
+ StatusOr> eags = result.getAddressesOrError();
+ if (!eags.hasValue()) {
+ throw eags.getStatus().asRuntimeException();
+ }
+ return eags.getValue();
+ }
+
+ // Extracts just the addresses from 'result's EquivalentAddressGroups.
+ private static ImmutableList> getAddressesOrThrow(ResolutionResult result) {
+ return getEagsOrThrow(result).stream()
+ .map(EquivalentAddressGroup::getAddresses)
+ .collect(toImmutableList());
+ }
+
+ // Converts given Intents to a list of ACAs, for convenient comparison with getAddressesOrThrow().
+ private static ImmutableList toAddressList(Intent... bindIntents) {
+ ImmutableList.Builder builder = ImmutableList.builder();
+ for (Intent bindIntent : bindIntents) {
+ builder.add(AndroidComponentAddress.forBindIntent(bindIntent));
+ }
+ return builder.build();
+ }
+
+ private NameResolver newNameResolver(Intent targetIntent) {
+ return newNameResolver(targetIntent, args);
+ }
+
+ private NameResolver newNameResolver(Intent targetIntent, NameResolver.Args args) {
+ return new IntentNameResolver(targetIntent, args);
+ }
+
+ /** Returns a new test-specific {@link NameResolver.Args} instance. */
+ private NameResolver.Args.Builder newNameResolverArgs() {
+ return NameResolver.Args.newBuilder()
+ .setDefaultPort(-1)
+ .setProxyDetector((target) -> null) // No proxies here.
+ .setSynchronizationContext(syncContext)
+ .setOffloadExecutor(ContextCompat.getMainExecutor(appContext))
+ .setArg(ApiConstants.SOURCE_ANDROID_CONTEXT, appContext)
+ .setServiceConfigParser(mock(ServiceConfigParser.class));
+ }
+
+ /**
+ * Returns a test {@link SynchronizationContext}.
+ *
+ * Exceptions will cause the test to fail with {@link AssertionError}.
+ */
+ private static SynchronizationContext newSynchronizationContext() {
+ return new SynchronizationContext(
+ (thread, exception) -> {
+ throw new AssertionError(exception);
+ });
+ }
+
+ static final class RetainingUncaughtExceptionHandler implements UncaughtExceptionHandler {
+ final ArrayList uncaught = new ArrayList<>();
+
+ @Override
+ public void uncaughtException(@NonNull Thread t, @NonNull Throwable e) {
+ uncaught.add(e);
+ }
+ }
+}