Skip to content

Commit c65911a

Browse files
committed
Introduce a NameResolver for Android's intent: URIs
1 parent 42e1829 commit c65911a

8 files changed

Lines changed: 930 additions & 9 deletions

File tree

binder/src/androidTest/AndroidManifest.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
<service android:name="io.grpc.binder.HostServices$HostService1" android:exported="false">
1212
<intent-filter>
1313
<action android:name="action1"/>
14+
<data android:scheme="scheme" android:host="authority" android:path="/path"/>
1415
</intent-filter>
1516
</service>
1617
<service android:name="io.grpc.binder.HostServices$HostService2" android:exported="false">
1718
<intent-filter>
1819
<action android:name="action2"/>
20+
<data android:scheme="scheme" android:host="authority" android:path="/path"/>
1921
</intent-filter>
2022
</service>
2123
</application>

binder/src/androidTest/java/io/grpc/binder/BinderChannelSmokeTest.java

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
import android.content.Context;
2525
import android.content.Intent;
26+
import android.net.Uri;
2627
import android.os.Parcel;
2728
import android.os.Parcelable;
2829
import androidx.test.core.app.ApplicationProvider;
@@ -39,7 +40,6 @@
3940
import io.grpc.ManagedChannel;
4041
import io.grpc.Metadata;
4142
import io.grpc.MethodDescriptor;
42-
import io.grpc.NameResolverRegistry;
4343
import io.grpc.ServerCall;
4444
import io.grpc.ServerCall.Listener;
4545
import io.grpc.ServerCallHandler;
@@ -49,7 +49,6 @@
4949
import io.grpc.Status.Code;
5050
import io.grpc.StatusRuntimeException;
5151
import io.grpc.internal.GrpcUtil;
52-
import io.grpc.internal.testing.FakeNameResolverProvider;
5352
import io.grpc.stub.ClientCalls;
5453
import io.grpc.stub.MetadataUtils;
5554
import io.grpc.stub.ServerCalls;
@@ -77,7 +76,6 @@ public final class BinderChannelSmokeTest {
7776

7877
private static final int SLIGHTLY_MORE_THAN_ONE_BLOCK = 16 * 1024 + 100;
7978
private static final String MSG = "Some text which will be repeated many many times";
80-
private static final String SERVER_TARGET_URI = "fake://server";
8179
private static final Metadata.Key<PoisonParcelable> POISON_KEY =
8280
ParcelableUtils.metadataKey("poison-bin", PoisonParcelable.CREATOR);
8381

@@ -99,7 +97,6 @@ public final class BinderChannelSmokeTest {
9997
.setType(MethodDescriptor.MethodType.BIDI_STREAMING)
10098
.build();
10199

102-
FakeNameResolverProvider fakeNameResolverProvider;
103100
ManagedChannel channel;
104101
AtomicReference<Metadata> headersCapture = new AtomicReference<>();
105102
AtomicReference<PeerUid> clientUidCapture = new AtomicReference<>();
@@ -138,8 +135,6 @@ public void setUp() throws Exception {
138135
PeerUids.newPeerIdentifyingServerInterceptor());
139136

140137
AndroidComponentAddress serverAddress = HostServices.allocateService(appContext);
141-
fakeNameResolverProvider = new FakeNameResolverProvider(SERVER_TARGET_URI, serverAddress);
142-
NameResolverRegistry.getDefaultRegistry().register(fakeNameResolverProvider);
143138
HostServices.configureService(
144139
serverAddress,
145140
HostServices.serviceParamsBuilder()
@@ -166,7 +161,6 @@ public void setUp() throws Exception {
166161
@After
167162
public void tearDown() throws Exception {
168163
channel.shutdownNow();
169-
NameResolverRegistry.getDefaultRegistry().deregister(fakeNameResolverProvider);
170164
HostServices.awaitServiceShutdown();
171165
}
172166

@@ -235,7 +229,11 @@ public void testStreamingCallOptionHeaders() throws Exception {
235229

236230
@Test
237231
public void testConnectViaTargetUri() throws Exception {
238-
channel = BinderChannelBuilder.forTarget(SERVER_TARGET_URI, appContext).build();
232+
// Compare with the <intent-filter> mapping in AndroidManifest.xml.
233+
channel =
234+
BinderChannelBuilder.forTarget(
235+
"intent://authority/path#Intent;action=action1;scheme=scheme;end;", appContext)
236+
.build();
239237
assertThat(doCall("Hello").get()).isEqualTo("Hello");
240238
}
241239

@@ -245,7 +243,10 @@ public void testConnectViaIntentFilter() throws Exception {
245243
channel =
246244
BinderChannelBuilder.forAddress(
247245
AndroidComponentAddress.forBindIntent(
248-
new Intent().setAction("action1").setPackage(appContext.getPackageName())),
246+
new Intent()
247+
.setAction("action1")
248+
.setData(Uri.parse("scheme://authority/path"))
249+
.setPackage(appContext.getPackageName())),
249250
appContext)
250251
.build();
251252
assertThat(doCall("Hello").get()).isEqualTo("Hello");

binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import io.grpc.ManagedChannel;
2929
import io.grpc.ManagedChannelBuilder;
3030
import io.grpc.binder.internal.BinderClientTransportFactory;
31+
import io.grpc.binder.internal.IntentNameResolverProvider;
3132
import io.grpc.internal.FixedObjectPool;
3233
import io.grpc.internal.ManagedChannelImplBuilder;
3334
import java.util.concurrent.Executor;
@@ -177,6 +178,9 @@ private BinderChannelBuilder(
177178
new ManagedChannelImplBuilder(
178179
directAddress, directAddress.getAuthority(), transportFactoryBuilder, null);
179180
} else {
181+
if (checkNotNull(target).startsWith("intent:")) {
182+
IntentNameResolverProvider.maybeCreateAndDefaultRegister(sourceContext);
183+
}
180184
managedChannelImplBuilder =
181185
new ManagedChannelImplBuilder(target, transportFactoryBuilder, null);
182186
}
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
package io.grpc.binder.internal;
2+
3+
import static android.content.Intent.URI_INTENT_SCHEME;
4+
import static com.google.common.base.Preconditions.checkNotNull;
5+
import static com.google.common.base.Preconditions.checkState;
6+
import static io.grpc.binder.internal.SystemApis.createContextAsUser;
7+
8+
import android.annotation.SuppressLint;
9+
import android.content.BroadcastReceiver;
10+
import android.content.ComponentName;
11+
import android.content.Context;
12+
import android.content.Intent;
13+
import android.content.IntentFilter;
14+
import android.content.pm.PackageManager;
15+
import android.content.pm.ResolveInfo;
16+
import android.os.Build;
17+
import android.os.UserHandle;
18+
import com.google.common.collect.ImmutableMap;
19+
import com.google.common.util.concurrent.FutureCallback;
20+
import com.google.common.util.concurrent.Futures;
21+
import com.google.common.util.concurrent.ListenableFuture;
22+
import com.google.common.util.concurrent.MoreExecutors;
23+
import io.grpc.Attributes;
24+
import io.grpc.EquivalentAddressGroup;
25+
import io.grpc.NameResolver;
26+
import io.grpc.Status;
27+
import io.grpc.StatusException;
28+
import io.grpc.StatusOr;
29+
import io.grpc.SynchronizationContext;
30+
import io.grpc.binder.AndroidComponentAddress;
31+
import io.grpc.binder.ApiConstants;
32+
import java.net.URI;
33+
import java.net.URISyntaxException;
34+
import java.util.ArrayList;
35+
import java.util.List;
36+
import java.util.Set;
37+
import java.util.concurrent.Executor;
38+
import javax.annotation.Nullable;
39+
40+
/**
41+
* A {@link NameResolver} that resolves Android-standard "intent:" target URIs to the list of {@link
42+
* AndroidComponentAddress} that match it by manifest intent filter.
43+
*/
44+
final class IntentNameResolver extends NameResolver {
45+
private final URI targetUri;
46+
@Nullable private final UserHandle targetUser; // null means same user that hosts this process.
47+
private final Context targetUserContext;
48+
private final Executor offloadExecutor;
49+
private final Executor sequentialExecutor;
50+
private final SynchronizationContext syncContext;
51+
private final ServiceConfigParser serviceConfigParser;
52+
53+
// Accessed only on `sequentialExecutor`
54+
@Nullable private PackageChangeReceiver receiver;
55+
56+
// Accessed only on 'syncContext'.
57+
private boolean shutdown;
58+
private boolean lookupNeeded;
59+
@Nullable private Listener2 listener;
60+
61+
@Nullable
62+
private ListenableFuture<ResolutionResult> lookupResultFuture; // != null when lookup in progress.
63+
64+
// Servers discovered in PackageManager are especially untrusted. After all, an app can declare
65+
// any intent filter it wants. Use pre-auth to avoid giving unauthorized apps a chance to run.
66+
@EquivalentAddressGroup.Attr
67+
private static final Attributes CONSTANT_EAG_ATTRS =
68+
Attributes.newBuilder().set(ApiConstants.PRE_AUTH_SERVER_OVERRIDE, true).build();
69+
70+
IntentNameResolver(Context context, URI targetUri, Args args) {
71+
this.targetUri = targetUri;
72+
this.targetUser = args.getArg(ApiConstants.TARGET_ANDROID_USER);
73+
this.targetUserContext =
74+
targetUser != null
75+
? createContextAsUser(context, targetUser, /* flags= */ 0) // @SystemApi since R.
76+
: context;
77+
// This Executor is nominally optional but all grpc-java Channels provide it since 1.25.
78+
this.offloadExecutor =
79+
checkNotNull(args.getOffloadExecutor(), "NameResolver.Args.getOffloadExecutor()");
80+
// Ensures start()'s work runs before resolve()'s' work, and both run before shutdown()'s.
81+
this.sequentialExecutor = MoreExecutors.newSequentialExecutor(offloadExecutor);
82+
this.syncContext = args.getSynchronizationContext();
83+
this.serviceConfigParser = args.getServiceConfigParser();
84+
}
85+
86+
@Override
87+
public void start(Listener2 listener) {
88+
checkState(this.listener == null, "Already started!");
89+
checkState(!shutdown, "Resolver is shutdown");
90+
this.listener = checkNotNull(listener);
91+
sequentialExecutor.execute(this::registerReceiver);
92+
resolve();
93+
}
94+
95+
@Override
96+
public void refresh() {
97+
checkState(listener != null, "Not started!");
98+
resolve();
99+
}
100+
101+
private void resolve() {
102+
syncContext.throwIfNotInThisSynchronizationContext();
103+
104+
if (shutdown) {
105+
return;
106+
}
107+
108+
// We can't block here in 'syncContext' so we offload PackageManager lookups to an Executor.
109+
// But offloading complicates things a bit because other calls can arrive while we wait for the
110+
// results. We keep 'listener' up-to-date with the latest state in PackageManager by doing:
111+
// 1. Only one lookup-and-report-to-listener operation at a time.
112+
// 2. At least one lookup-and-report-to-listener AFTER every PackageManager state change.
113+
if (lookupResultFuture == null) {
114+
lookupResultFuture = Futures.submit(this::lookupAndroidComponentAddress, sequentialExecutor);
115+
lookupResultFuture.addListener(this::onLookupComplete, syncContext);
116+
} else {
117+
// There's already a lookup in-flight but (2) says we need at least one more. Our sequential
118+
// Executor would be enough to ensure (1) but we also don't want a backlog of work to build up
119+
// if things change rapidly. Just make a note to start a new lookup when this one finishes.
120+
lookupNeeded = true;
121+
}
122+
}
123+
124+
private void onLookupComplete() {
125+
syncContext.throwIfNotInThisSynchronizationContext();
126+
checkState(lookupResultFuture != null);
127+
checkState(lookupResultFuture.isDone());
128+
129+
// Capture non-final `listener` here while we're on 'syncContext'.
130+
Listener2 listener = checkNotNull(this.listener);
131+
Futures.addCallback(
132+
lookupResultFuture, // Already isDone() so this execute()s immediately.
133+
new FutureCallback<ResolutionResult>() {
134+
@Override
135+
public void onSuccess(ResolutionResult result) {
136+
listener.onResult(result);
137+
}
138+
139+
@Override
140+
public void onFailure(Throwable t) {
141+
listener.onError(Status.fromThrowable(t));
142+
}
143+
},
144+
sequentialExecutor);
145+
lookupResultFuture = null;
146+
147+
if (lookupNeeded) {
148+
// One or more resolve() requests arrived while we were working on the last one. Just one
149+
// follow-on lookup can subsume all of them.
150+
lookupNeeded = false;
151+
resolve();
152+
}
153+
}
154+
155+
@Override
156+
public String getServiceAuthority() {
157+
return "localhost";
158+
}
159+
160+
@Override
161+
public void shutdown() {
162+
syncContext.throwIfNotInThisSynchronizationContext();
163+
if (!shutdown) {
164+
shutdown = true;
165+
sequentialExecutor.execute(this::maybeUnregisterReceiver);
166+
}
167+
}
168+
169+
private ResolutionResult lookupAndroidComponentAddress() throws StatusException {
170+
Intent targetIntent = parseUri(targetUri);
171+
172+
// Avoid a spurious UnsafeIntentLaunchViolation later. Since S, Android's StrictMode is very
173+
// conservative, marking any Intent parsed from a string as suspicious and complaining when you
174+
// bind to it. But all this is pointless with grpc-binder, which already goes even further by
175+
// not trusting addresses at all! Instead, we rely on SecurityPolicy, which won't allow a
176+
// connection to an unauthorized server UID no matter how you got there.
177+
targetIntent = sanitize(targetIntent);
178+
179+
List<ResolveInfo> resolveInfoList = lookupServices(targetIntent);
180+
181+
// Model each matching android.app.Service as an individual gRPC server with a single address.
182+
List<EquivalentAddressGroup> servers = new ArrayList<>();
183+
for (ResolveInfo resolveInfo : resolveInfoList) {
184+
targetIntent.setComponent(
185+
new ComponentName(resolveInfo.serviceInfo.packageName, resolveInfo.serviceInfo.name));
186+
servers.add(
187+
new EquivalentAddressGroup(
188+
AndroidComponentAddress.newBuilder()
189+
.setBindIntent(targetIntent) // Makes a copy.
190+
.setTargetUser(targetUser)
191+
.build(),
192+
CONSTANT_EAG_ATTRS));
193+
}
194+
195+
return ResolutionResult.newBuilder()
196+
.setAddressesOrError(StatusOr.fromValue(servers))
197+
// Empty service config means we get the default 'pick_first' load balancing policy.
198+
.setServiceConfig(serviceConfigParser.parseServiceConfig(ImmutableMap.of()))
199+
.build();
200+
}
201+
202+
private List<ResolveInfo> lookupServices(Intent intent) throws StatusException {
203+
int flags = 0;
204+
if (Build.VERSION.SDK_INT >= 29) {
205+
// Don't match direct-boot-unaware Services that can't presently be created. We'll query again
206+
// after the user is unlocked. The MATCH_DIRECT_BOOT_AUTO behavior is actually the default but
207+
// being explicit here avoids an android.os.strictmode.ImplicitDirectBootViolation.
208+
flags |= PackageManager.MATCH_DIRECT_BOOT_AUTO;
209+
}
210+
List<ResolveInfo> intentServices =
211+
targetUserContext.getPackageManager().queryIntentServices(intent, flags);
212+
213+
if (intentServices.isEmpty()) {
214+
throw Status.UNIMPLEMENTED
215+
.withDescription("Service not found for intent " + intent)
216+
.asException();
217+
}
218+
return intentServices;
219+
}
220+
221+
private static Intent parseUri(URI targetUri) throws StatusException {
222+
try {
223+
return Intent.parseUri(targetUri.toString(), URI_INTENT_SCHEME);
224+
} catch (URISyntaxException uriSyntaxException) {
225+
throw Status.INVALID_ARGUMENT
226+
.withCause(uriSyntaxException)
227+
.withDescription("Failed to parse target URI " + targetUri + " as intent")
228+
.asException();
229+
}
230+
}
231+
232+
// Returns a new Intent with the same action, data and categories as 'input'.
233+
private static Intent sanitize(Intent input) {
234+
Intent output = new Intent();
235+
output.setAction(input.getAction());
236+
output.setData(input.getData());
237+
238+
Set<String> categories = input.getCategories();
239+
if (categories != null) {
240+
for (String category : categories) {
241+
output.addCategory(category);
242+
}
243+
}
244+
// Don't bother copying extras and flags since AndroidComponentAddress (rightly) ignores them.
245+
// Don't bother copying package or ComponentName either, since we're about to set that.
246+
return output;
247+
}
248+
249+
final class PackageChangeReceiver extends BroadcastReceiver {
250+
@Override
251+
public void onReceive(Context context, Intent intent) {
252+
// Get off the main thread and into the correct SynchronizationContext.
253+
syncContext.executeLater(IntentNameResolver.this::resolve);
254+
offloadExecutor.execute(syncContext::drain);
255+
}
256+
}
257+
258+
@SuppressLint("UnprotectedReceiver") // All of these are protected system broadcasts.
259+
private void registerReceiver() {
260+
checkState(receiver == null, "Already registered!");
261+
receiver = new PackageChangeReceiver();
262+
IntentFilter filter = new IntentFilter();
263+
filter.addDataScheme("package");
264+
filter.addAction(Intent.ACTION_PACKAGE_ADDED);
265+
filter.addAction(Intent.ACTION_PACKAGE_CHANGED);
266+
filter.addAction(Intent.ACTION_PACKAGE_REMOVED);
267+
filter.addAction(Intent.ACTION_PACKAGE_REPLACED);
268+
269+
targetUserContext.registerReceiver(receiver, filter);
270+
271+
if (Build.VERSION.SDK_INT >= 24) {
272+
// Clients running in direct boot mode must refresh() when the user is unlocked because
273+
// that's when `directBootAware=false` services become visible in queryIntentServices()
274+
// results. ACTION_BOOT_COMPLETED would work too but it's delivered with lower priority.
275+
targetUserContext.registerReceiver(receiver, new IntentFilter(Intent.ACTION_USER_UNLOCKED));
276+
}
277+
}
278+
279+
private void maybeUnregisterReceiver() {
280+
if (receiver != null) { // NameResolver API contract appears to allow shutdown without start().
281+
targetUserContext.unregisterReceiver(receiver);
282+
receiver = null;
283+
}
284+
}
285+
}

0 commit comments

Comments
 (0)