Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions api/src/main/java/io/grpc/NameResolverRegistry.java
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,11 @@ static List<Class<?>> getHardCodedClasses() {
} catch (ClassNotFoundException e) {
logger.log(Level.FINE, "Unable to find IntentNameResolverProvider", e);
}
try {
list.add(Class.forName("io.grpc.binder.internal.AndroidAppNameResolverProvider"));
} catch (ClassNotFoundException e) {
logger.log(Level.FINE, "Unable to find AndroidAppNameResolverProvider", e);
}
return Collections.unmodifiableList(list);
}

Expand Down
3 changes: 3 additions & 0 deletions binder/src/androidTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
<action android:name="action1"/>
<data android:scheme="scheme" android:host="authority" android:path="/path"/>
</intent-filter>
<intent-filter>
<action android:name="action3"/>
</intent-filter>
</service>
<service android:name="io.grpc.binder.HostServices$HostService2" android:exported="false">
<intent-filter>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ private ListenableFuture<String> doCall(
@Test
public void testBasicCall() throws Exception {
assertThat(doCall("Hello").get()).isEqualTo("Hello");
assertThat(channel.authority()).isEqualTo("io.grpc.binder.test");
}

@Test
Expand Down Expand Up @@ -228,7 +229,16 @@ public void testStreamingCallOptionHeaders() throws Exception {
}

@Test
public void testConnectViaTargetUri() throws Exception {
public void testConnectViaAndroidAppTargetUri() throws Exception {
// Compare with the <intent-filter> mapping in AndroidManifest.xml.
channel =
BinderChannelBuilder.forTarget("android-app:///#Intent;action=action3;end;", appContext)
.build();
assertThat(doCall("Hello").get()).isEqualTo("Hello");
}

@Test
public void testConnectViaIntentTargetUri() throws Exception {
// Compare with the <intent-filter> mapping in AndroidManifest.xml.
channel =
BinderChannelBuilder.forTarget(
Expand Down
36 changes: 27 additions & 9 deletions binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,30 @@ public static BinderChannelBuilder forAddress(
* Creates a channel builder that will bind to a remote Android service, via a string target name
* which will be resolved.
*
* <p>'target' will be resolved by way of a {@link io.grpc.NameResolverRegistry} in the usual way.
* However, the Android-standard Intent URI schemes are supported out of the box (at {@link
* io.grpc.NameResolverProvider} priority 3) without any setup or registration needed. See {@link
* android.content.Intent#URI_INTENT_SCHEME} and {@link
* android.content.Intent#URI_ANDROID_APP_SCHEME} for the syntax.
*
* <p>In both cases, the decoded Intent is resolved using {@link
* android.content.pm.PackageManager#queryIntentServices} and the resulting addresses are tried in
* `android:priority` order using the `pick_first` load balancer policy. Unlike ordinary Android
* bindings, target Intents need not specify the Service's package or ComponentName explicitly.
* However, on-device servers discovered in this way are always pre-authorized (see {@link
* #preAuthorizeServers} for details).
*
* <p>`android-app:` target URIs are parsed in a slightly non-standard way to permit service
* discovery. This scheme normally uses the URI's authority component to encoded a package
* restriction for the Intent. But if 'target's authority is either "localhost" or the empty
* string, the resulting Intent will have no package restriction at all. In other words, a URI
* like `android-app:///...` can resolve to a Service in any package (not a package named "").
*
* <p>grpc-java only supports target URIs that can be parsed under java.net.URI's interpretation
* of RFC 2396. This unfortunately does not include the 'intent:` URI encoding of many common
* Intents, including ones without an embedded "data" URI. Use the 'android-app' scheme to work
* around this limitation.
*
* <p>The underlying Android binding will be torn down when the channel becomes idle. This happens
* after 30 minutes without use by default but can be configured via {@link
* ManagedChannelBuilder#idleTimeout(long, TimeUnit)} or triggered manually with {@link
Expand All @@ -118,16 +142,10 @@ public static BinderChannelBuilder forTarget(String target, Context sourceContex
}

/**
* Creates a channel builder that will bind to a remote Android service, via a string target name
* which will be resolved.
* Creates a channel builder that will bind to a remote Android service resolved by target URI and
* using the specified credentials.
*
* <p>The underlying Android binding will be torn down when the channel becomes idle. This happens
* after 30 minutes without use by default but can be configured via {@link
* ManagedChannelBuilder#idleTimeout(long, TimeUnit)} or triggered manually with {@link
* ManagedChannel#enterIdle()}.
*
* <p>You the caller are responsible for managing the lifecycle of any channels built by the
* resulting builder. They will not be shut down automatically.
* <p>See {@link #forTarget(String, Context)} for details.
*
* @param target A target uri which should resolve into an {@link AndroidComponentAddress}
* referencing the service to bind to.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* 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_ANDROID_APP_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 `android-app:` target URIs,
* resolving them to the list of matching {@link AndroidComponentAddress}es by manifest
* &lt;intent-filter&gt;.
*/
public final class AndroidAppNameResolverProvider extends NameResolverProvider {

static final String ANDROID_APP_SCHEME = "android-app";

@Override
public String getDefaultScheme() {
return ANDROID_APP_SCHEME;
}

@Nullable
@Override
public NameResolver newNameResolver(URI targetUri, final Args args) {
if (Objects.equals(targetUri.getScheme(), ANDROID_APP_SCHEME)) {
return new IntentNameResolver(parseUriArg(targetUri.toString()), args);
} else {
return null;
}
}

@Override
public boolean isAvailable() {
return true; // minSdkVersion >= 22 for Intent#URI_ANDROID_APP_SCHEME.
}

@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);
}

static Intent parseUriArg(String target) {
try {
Intent result = Intent.parseUri(target, URI_ANDROID_APP_SCHEME);

// Special interpretation of `android-app:///scheme/authority/path` as a non-explicit Intent
// for service discovery. No big loss since these strings are never useful as package names.
if (Objects.equals(result.getPackage(), "")
|| Objects.equals(result.getPackage(), "localhost")) {
result.setPackage(null);
}
return result;
} catch (URISyntaxException e) {
throw new IllegalArgumentException(e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
* 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 android.content.Intent;
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.SynchronizationContext;
import io.grpc.binder.ApiConstants;
import java.net.URI;
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;

/** A test for AndroidAppNameResolverProvider. */
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 31)
public final class AndroidAppNameResolverProviderTest {

private final Application appContext = ApplicationProvider.getApplicationContext();
private final SynchronizationContext syncContext = newSynchronizationContext();
private final NameResolver.Args args = newNameResolverArgs();
private final AndroidAppNameResolverProvider provider = new AndroidAppNameResolverProvider();

@Rule public MockitoTestRule mockitoTestRule = MockitoJUnit.testRule(this);
@Mock public NameResolver.Listener2 mockListener;
@Captor public ArgumentCaptor<ResolutionResult> resultCaptor;

@Test
public void testProviderScheme_returnsIntentScheme() throws Exception {
assertThat(provider.getDefaultScheme())
.isEqualTo(AndroidAppNameResolverProvider.ANDROID_APP_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("android-app://xxx/yy#Intent;e.x=1;end;"), args));
}

@Test
public void testIsAvailableSdk22() throws Exception {
assertThat(provider.isAvailable()).isTrue();
}

@Test
public void testResolverForIntentScheme_returnsResolver() throws Exception {
URI uri = new URI("android-app:///scheme/authority/path#Intent;action=action;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();
}

@Test
public void testResolverForIntentScheme_supportsEmptySchemeSpecificPart() throws Exception {
Intent intent =
AndroidAppNameResolverProvider.parseUriArg(
"android-app://com.example.app#Intent;action=action1;end;");
assertThat(new Intent.FilterComparison(intent))
.isEqualTo(
new Intent.FilterComparison(
new Intent().setAction("action1").setPackage("com.example.app")));
}

@Test
public void testResolverForIntentScheme_supportsEmptyPackage() throws Exception {
Intent intent =
AndroidAppNameResolverProvider.parseUriArg("android-app:///#Intent;action=action1;end;");
assertThat(new Intent.FilterComparison(intent))
.isEqualTo(new Intent.FilterComparison(new Intent().setAction("action1")));
}

/** 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);
});
}
}