diff --git a/api/src/main/java/io/grpc/NameResolverRegistry.java b/api/src/main/java/io/grpc/NameResolverRegistry.java index 26eb5552b9b..3d9f31f4b54 100644 --- a/api/src/main/java/io/grpc/NameResolverRegistry.java +++ b/api/src/main/java/io/grpc/NameResolverRegistry.java @@ -171,6 +171,11 @@ static List> 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); } diff --git a/binder/src/androidTest/AndroidManifest.xml b/binder/src/androidTest/AndroidManifest.xml index 44f21e104d9..4382802613a 100644 --- a/binder/src/androidTest/AndroidManifest.xml +++ b/binder/src/androidTest/AndroidManifest.xml @@ -13,6 +13,9 @@ + + + diff --git a/binder/src/androidTest/java/io/grpc/binder/BinderChannelSmokeTest.java b/binder/src/androidTest/java/io/grpc/binder/BinderChannelSmokeTest.java index e3a8c58bf88..8f4b10c165a 100644 --- a/binder/src/androidTest/java/io/grpc/binder/BinderChannelSmokeTest.java +++ b/binder/src/androidTest/java/io/grpc/binder/BinderChannelSmokeTest.java @@ -183,6 +183,7 @@ private ListenableFuture doCall( @Test public void testBasicCall() throws Exception { assertThat(doCall("Hello").get()).isEqualTo("Hello"); + assertThat(channel.authority()).isEqualTo("io.grpc.binder.test"); } @Test @@ -228,7 +229,16 @@ public void testStreamingCallOptionHeaders() throws Exception { } @Test - public void testConnectViaTargetUri() throws Exception { + public void testConnectViaAndroidAppTargetUri() throws Exception { + // Compare with the 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 mapping in AndroidManifest.xml. channel = BinderChannelBuilder.forTarget( diff --git a/binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java b/binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java index 18928339fbd..18b1f53b159 100644 --- a/binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java +++ b/binder/src/main/java/io/grpc/binder/BinderChannelBuilder.java @@ -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. * + *

'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. + * + *

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). + * + *

`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 ""). + * + *

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. + * *

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 @@ -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. * - *

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()}. - * - *

You the caller are responsible for managing the lifecycle of any channels built by the - * resulting builder. They will not be shut down automatically. + *

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. diff --git a/binder/src/main/java/io/grpc/binder/internal/AndroidAppNameResolverProvider.java b/binder/src/main/java/io/grpc/binder/internal/AndroidAppNameResolverProvider.java new file mode 100644 index 00000000000..0c3e5d301ac --- /dev/null +++ b/binder/src/main/java/io/grpc/binder/internal/AndroidAppNameResolverProvider.java @@ -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 + * <intent-filter>. + */ +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> 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); + } + } +} diff --git a/binder/src/test/java/io/grpc/binder/internal/AndroidAppNameResolverProviderTest.java b/binder/src/test/java/io/grpc/binder/internal/AndroidAppNameResolverProviderTest.java new file mode 100644 index 00000000000..7779bd83330 --- /dev/null +++ b/binder/src/test/java/io/grpc/binder/internal/AndroidAppNameResolverProviderTest.java @@ -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 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); + }); + } +}