From efcdebb904a7768f1c83ac61a3b114234b647cc9 Mon Sep 17 00:00:00 2001 From: John Cormie Date: Thu, 7 Aug 2025 08:38:44 -0700 Subject: [PATCH] Introduce a NameResolver for Android's `intent:` URIs (#12248) Let grpc-binder clients find on-device services by [implicit Intent](https://developer.android.com/guide/components/intents-filters#Types) target URI, lifting the need to hard code a server's package name. --- .../java/io/grpc/NameResolverRegistry.java | 5 + binder/src/androidTest/AndroidManifest.xml | 2 + .../grpc/binder/BinderChannelSmokeTest.java | 19 +- .../java/io/grpc/binder/ApiConstants.java | 12 + .../io/grpc/binder/BinderChannelBuilder.java | 2 + .../BinderClientTransportFactory.java | 4 + .../binder/internal/IntentNameResolver.java | 299 ++++++++++ .../internal/IntentNameResolverProvider.java | 77 +++ .../io/grpc/binder/internal/SystemApis.java | 60 ++ .../IntentNameResolverProviderTest.java | 115 ++++ .../internal/IntentNameResolverTest.java | 531 ++++++++++++++++++ 11 files changed, 1117 insertions(+), 9 deletions(-) create mode 100644 binder/src/main/java/io/grpc/binder/internal/IntentNameResolver.java create mode 100644 binder/src/main/java/io/grpc/binder/internal/IntentNameResolverProvider.java create mode 100644 binder/src/main/java/io/grpc/binder/internal/SystemApis.java create mode 100644 binder/src/test/java/io/grpc/binder/internal/IntentNameResolverProviderTest.java create mode 100644 binder/src/test/java/io/grpc/binder/internal/IntentNameResolverTest.java diff --git a/api/src/main/java/io/grpc/NameResolverRegistry.java b/api/src/main/java/io/grpc/NameResolverRegistry.java index 2648f8de1a..26eb5552b9 100644 --- a/api/src/main/java/io/grpc/NameResolverRegistry.java +++ b/api/src/main/java/io/grpc/NameResolverRegistry.java @@ -166,6 +166,11 @@ public final class NameResolverRegistry { } catch (ClassNotFoundException e) { logger.log(Level.FINE, "Unable to find DNS NameResolver", e); } + try { + list.add(Class.forName("io.grpc.binder.internal.IntentNameResolverProvider")); + } catch (ClassNotFoundException e) { + logger.log(Level.FINE, "Unable to find IntentNameResolverProvider", e); + } return Collections.unmodifiableList(list); } diff --git a/binder/src/androidTest/AndroidManifest.xml b/binder/src/androidTest/AndroidManifest.xml index b6d7157441..44f21e104d 100644 --- a/binder/src/androidTest/AndroidManifest.xml +++ b/binder/src/androidTest/AndroidManifest.xml @@ -11,11 +11,13 @@ + + diff --git a/binder/src/androidTest/java/io/grpc/binder/BinderChannelSmokeTest.java b/binder/src/androidTest/java/io/grpc/binder/BinderChannelSmokeTest.java index 79f7b98f04..e3a8c58bf8 100644 --- a/binder/src/androidTest/java/io/grpc/binder/BinderChannelSmokeTest.java +++ b/binder/src/androidTest/java/io/grpc/binder/BinderChannelSmokeTest.java @@ -23,6 +23,7 @@ import static java.util.concurrent.TimeUnit.SECONDS; import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; import androidx.test.core.app.ApplicationProvider; @@ -39,7 +40,6 @@ import io.grpc.ForwardingServerCall.SimpleForwardingServerCall; import io.grpc.ManagedChannel; import io.grpc.Metadata; import io.grpc.MethodDescriptor; -import io.grpc.NameResolverRegistry; import io.grpc.ServerCall; import io.grpc.ServerCall.Listener; import io.grpc.ServerCallHandler; @@ -49,7 +49,6 @@ import io.grpc.ServerServiceDefinition; import io.grpc.Status.Code; import io.grpc.StatusRuntimeException; import io.grpc.internal.GrpcUtil; -import io.grpc.internal.testing.FakeNameResolverProvider; import io.grpc.stub.ClientCalls; import io.grpc.stub.MetadataUtils; import io.grpc.stub.ServerCalls; @@ -77,7 +76,6 @@ public final class BinderChannelSmokeTest { private static final int SLIGHTLY_MORE_THAN_ONE_BLOCK = 16 * 1024 + 100; private static final String MSG = "Some text which will be repeated many many times"; - private static final String SERVER_TARGET_URI = "fake://server"; private static final Metadata.Key POISON_KEY = ParcelableUtils.metadataKey("poison-bin", PoisonParcelable.CREATOR); @@ -99,7 +97,6 @@ public final class BinderChannelSmokeTest { .setType(MethodDescriptor.MethodType.BIDI_STREAMING) .build(); - FakeNameResolverProvider fakeNameResolverProvider; ManagedChannel channel; AtomicReference headersCapture = new AtomicReference<>(); AtomicReference clientUidCapture = new AtomicReference<>(); @@ -138,8 +135,6 @@ public final class BinderChannelSmokeTest { PeerUids.newPeerIdentifyingServerInterceptor()); AndroidComponentAddress serverAddress = HostServices.allocateService(appContext); - fakeNameResolverProvider = new FakeNameResolverProvider(SERVER_TARGET_URI, serverAddress); - NameResolverRegistry.getDefaultRegistry().register(fakeNameResolverProvider); HostServices.configureService( serverAddress, HostServices.serviceParamsBuilder() @@ -166,7 +161,6 @@ public final class BinderChannelSmokeTest { @After public void tearDown() throws Exception { channel.shutdownNow(); - NameResolverRegistry.getDefaultRegistry().deregister(fakeNameResolverProvider); HostServices.awaitServiceShutdown(); } @@ -235,7 +229,11 @@ public final class BinderChannelSmokeTest { @Test public void testConnectViaTargetUri() throws Exception { - channel = BinderChannelBuilder.forTarget(SERVER_TARGET_URI, appContext).build(); + // Compare with the 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); + } + } +}