mirror of https://github.com/grpc/grpc-java.git
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.
This commit is contained in:
parent
f30964ab82
commit
efcdebb904
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@
|
|||
<service android:name="io.grpc.binder.HostServices$HostService1" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="action1"/>
|
||||
<data android:scheme="scheme" android:host="authority" android:path="/path"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service android:name="io.grpc.binder.HostServices$HostService2" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="action2"/>
|
||||
<data android:scheme="scheme" android:host="authority" android:path="/path"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
|
|
|||
|
|
@ -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<PoisonParcelable> 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<Metadata> headersCapture = new AtomicReference<>();
|
||||
AtomicReference<PeerUid> 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 <intent-filter> 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");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
* <p>This argument is set automatically by {@link BinderChannelBuilder}. Any value passed to
|
||||
* {@link io.grpc.ManagedChannelBuilder#setNameResolverArg} will be ignored.
|
||||
*
|
||||
* <p>See {@link BinderChannelBuilder#forTarget(String, android.content.Context)} for more.
|
||||
*/
|
||||
public static final NameResolver.Args.Key<android.content.Context> SOURCE_ANDROID_CONTEXT =
|
||||
NameResolver.Args.Key.create("source-android-context");
|
||||
|
||||
/**
|
||||
* Specifies the Android user in which target URIs should be resolved.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -321,6 +321,8 @@ public final class BinderChannelBuilder extends ForwardingChannelBuilder<BinderC
|
|||
public ManagedChannel build() {
|
||||
transportFactoryBuilder.setOffloadExecutorPool(
|
||||
managedChannelImplBuilder.getOffloadExecutorPool());
|
||||
setNameResolverArg(
|
||||
ApiConstants.SOURCE_ANDROID_CONTEXT, transportFactoryBuilder.getSourceContext());
|
||||
return super.build();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,10 @@ public final class BinderClientTransportFactory implements ClientTransportFactor
|
|||
return this;
|
||||
}
|
||||
|
||||
public Context getSourceContext() {
|
||||
return sourceContext;
|
||||
}
|
||||
|
||||
public Builder setOffloadExecutorPool(ObjectPool<? extends Executor> offloadExecutorPool) {
|
||||
this.offloadExecutorPool = checkNotNull(offloadExecutorPool, "offloadExecutorPool");
|
||||
return this;
|
||||
|
|
|
|||
|
|
@ -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<ResolutionResult> 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<ResolutionResult>() {
|
||||
@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<ResolveInfo> 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<EquivalentAddressGroup> 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<ResolveInfo> 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<ResolveInfo> 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<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Class<? extends SocketAddress>> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ResolutionResult> 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ResolutionResult> 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<String> categories = intent.getCategories();
|
||||
if (categories != null) {
|
||||
for (String category : categories) {
|
||||
filter.addCategory(category);
|
||||
}
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
private static List<EquivalentAddressGroup> getEagsOrThrow(ResolutionResult result) {
|
||||
StatusOr<List<EquivalentAddressGroup>> eags = result.getAddressesOrError();
|
||||
if (!eags.hasValue()) {
|
||||
throw eags.getStatus().asRuntimeException();
|
||||
}
|
||||
return eags.getValue();
|
||||
}
|
||||
|
||||
// Extracts just the addresses from 'result's EquivalentAddressGroups.
|
||||
private static ImmutableList<List<SocketAddress>> 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<AndroidComponentAddress> toAddressList(Intent... bindIntents) {
|
||||
ImmutableList.Builder<AndroidComponentAddress> 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}.
|
||||
*
|
||||
* <p>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<Throwable> uncaught = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void uncaughtException(@NonNull Thread t, @NonNull Throwable e) {
|
||||
uncaught.add(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue