asList() {
+ return entries;
+ }
+
+ /**
+ * Returns the "raw" query string representation of these parameters, suitable for passing to the
+ * {@link io.grpc.Uri.Builder#setRawQuery} method.
+ *
+ * @return the raw query string
+ */
+ @Nullable
+ public String toRawQuery() {
+ if (entries.isEmpty()) {
+ return null;
+ }
+ StringBuilder resultBuilder = new StringBuilder();
+ boolean first = true;
+ for (Entry entry : entries) {
+ if (!first) {
+ resultBuilder.append('&');
+ }
+ entry.appendToRawQueryStringBuilder(resultBuilder);
+ first = false;
+ }
+ return resultBuilder.toString();
+ }
+
+ @Override
+ public String toString() {
+ return entries.toString();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof QueryParams)) {
+ return false;
+ }
+ QueryParams other = (QueryParams) o;
+ return entries.equals(other.entries);
+ }
+
+ @Override
+ public int hashCode() {
+ return entries.hashCode();
+ }
+
+ /** A single query parameter entry. */
+ public static final class Entry {
+ private final String rawKey;
+ @Nullable private final String rawValue;
+ private final String key;
+ @Nullable private final String value;
+
+ private Entry(String rawKey, @Nullable String rawValue, String key, @Nullable String value) {
+ this.rawKey = checkNotNull(rawKey, "rawKey");
+ this.rawValue = rawValue;
+ this.key = checkNotNull(key, "key");
+ this.value = value;
+ }
+
+ /**
+ * Returns the key.
+ *
+ * Any characters that needed URL encoding have already been decoded.
+ */
+ public String getKey() {
+ return key;
+ }
+
+ /**
+ * Returns the value, or {@code null} if this is a "lone" key.
+ *
+ *
Any characters that needed URL encoding have already been decoded.
+ */
+ @Nullable
+ public String getValue() {
+ return value;
+ }
+
+ /** Returns {@code true} if this entry has a value, {@code false} if it is a "lone" key. */
+ public boolean hasValue() {
+ return value != null;
+ }
+
+ /**
+ * Creates a new key/value pair entry.
+ *
+ *
Both key and value can contain any character. They will be URL encoded for you if
+ * necessary.
+ */
+ public static Entry forKeyValue(String key, String value) {
+ checkNotNull(key, "key");
+ checkNotNull(value, "value");
+ return new Entry(encode(key), encode(value), key, value);
+ }
+
+ /**
+ * Creates a new query parameter with a "lone" key.
+ *
+ *
'key' can contain any character. It will be URL encoded for you later, as necessary.
+ *
+ * @param key the decoded key, must not be null
+ * @return a new {@code Entry}
+ */
+ public static Entry forLoneKey(String key) {
+ checkNotNull(key, "key");
+ return new Entry(encode(key), null, key, null);
+ }
+
+ static Entry forRawKeyValue(String rawKey, String rawValue) {
+ checkNotNull(rawKey, "rawKey");
+ checkNotNull(rawValue, "rawValue");
+ return new Entry(rawKey, rawValue, decode(rawKey), decode(rawValue));
+ }
+
+ static Entry forRawLoneKey(String rawKey) {
+ checkNotNull(rawKey, "rawKey");
+ return new Entry(rawKey, null, decode(rawKey), null);
+ }
+
+ void appendToRawQueryStringBuilder(StringBuilder sb) {
+ sb.append(rawKey);
+ if (rawValue != null) {
+ sb.append('=').append(rawValue);
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof Entry)) {
+ return false;
+ }
+ Entry entry = (Entry) o;
+ return Objects.equals(rawKey, entry.rawKey) && Objects.equals(rawValue, entry.rawValue);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(rawKey, rawValue);
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ appendToRawQueryStringBuilder(sb);
+ return sb.toString();
+ }
+ }
+
+ private static String decode(String s) {
+ try {
+ // TODO: Use URLDecoder.decode(String, Charset) when available
+ return URLDecoder.decode(s, UTF_8);
+ } catch (UnsupportedEncodingException impossible) {
+ throw new AssertionError("UTF-8 is not supported", impossible);
+ }
+ }
+
+ private static String encode(String s) {
+ try {
+ // TODO: Use URLEncoder.encode(String, Charset) when available
+ return URLEncoder.encode(s, UTF_8);
+ } catch (UnsupportedEncodingException impossible) {
+ throw new AssertionError("UTF-8 is not supported", impossible);
+ }
+ }
+}
diff --git a/api/src/main/java/io/grpc/Uri.java b/api/src/main/java/io/grpc/Uri.java
index 9f8a5a87848..42cc48044e9 100644
--- a/api/src/main/java/io/grpc/Uri.java
+++ b/api/src/main/java/io/grpc/Uri.java
@@ -546,24 +546,18 @@ public String getRawPath() {
return path;
}
- /**
- * Returns the percent-decoded "query" component of this URI, or null if not present.
- *
- *
NB: This method assumes the query was encoded as UTF-8, although RFC 3986 doesn't specify an
- * encoding.
- *
- *
Decoding errors are indicated by a {@code '\u005CuFFFD'} unicode replacement character in
- * the output. Callers who want to detect and handle errors in some other way should call {@link
- * #getRawQuery()}, {@link #percentDecode(CharSequence)}, then decode the bytes for themselves.
- */
- @Nullable
- public String getQuery() {
- return percentDecodeAssumedUtf8(query);
- }
-
/**
* Returns the query component of this URI in its originally parsed, possibly percent-encoded
- * form, without any leading '?' character.
+ * form, without any leading '?' character, or null if not present.
+ *
+ *
The query component can only be read in its raw form. That’s because virtually everyone uses
+ * query as a container for structured data, with some additional layer of encoding not present in
+ * RFC-3986. Like 'application/x-www-form-urlencoded', which encodes key/value pairs like so:
+ * ?k1=v1&k2=v+2. The encoding of these containers always has characters that take on
+ * a special delimiter meaning when not percent-encoded and a literal meaning when they are (like
+ * '&', '=' and '+' above). Since it matters whether a character was percent encoded or not,
+ * offering a '#getQuery()' method that percent-decodes everything like we do for other components
+ * would be error-prone.
*/
@Nullable
public String getRawQuery() {
@@ -776,10 +770,20 @@ public Builder setRawPath(String path) {
}
/**
- * Specifies the query component of the new URI (not including the leading '?').
+ * Specifies the query component of the new URI, possibly percent-encoded, exactly as it will
+ * appear in the string form of the built URI.
*
- *
Query can contain any string of codepoints. Codepoints that can't be encoded literally
- * will be percent-encoded for you as UTF-8.
+ *
'query' must only contain codepoints from RFC 3986's "query" character class. Any other
+ * characters must be percent-encoded using UTF-8. Do not include the leading '?' delimiter.
+ *
+ *
The query component can only be provided in its raw form. That’s because virtually
+ * everyone uses query as a container for structured data, with some additional layer of
+ * encoding not present in RFC-3986. Like 'application/x-www-form-urlencoded', which encodes
+ * key/value pairs like so: ?k1=v1&k2=v+2. The encoding of these containers always
+ * has characters that take on a special delimiter meaning when not percent-encoded and a
+ * literal meaning when they are (like '&', '=' and '+' above). Since 'query' must have already
+ * been carefully percent-encoded externally, a '#setQuery(String)' method that percent-encodes
+ * an assumed-cooked string would be error-prone.
*
*
This field is optional.
*
@@ -787,14 +791,10 @@ public Builder setRawPath(String path) {
* @return this, for fluent building
*/
@CanIgnoreReturnValue
- public Builder setQuery(@Nullable String query) {
- this.query = percentEncode(query, queryChars);
- return this;
- }
-
- @CanIgnoreReturnValue
- Builder setRawQuery(String query) {
- checkPercentEncodedArg(query, "query", queryChars);
+ public Builder setRawQuery(@Nullable String query) {
+ if (query != null) {
+ checkPercentEncodedArg(query, "query", queryChars);
+ }
this.query = query;
return this;
}
diff --git a/api/src/test/java/io/grpc/QueryParamsTest.java b/api/src/test/java/io/grpc/QueryParamsTest.java
new file mode 100644
index 00000000000..2def165a170
--- /dev/null
+++ b/api/src/test/java/io/grpc/QueryParamsTest.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright 2026 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;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import io.grpc.QueryParams.Entry;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link QueryParams}. */
+@RunWith(JUnit4.class)
+public class QueryParamsTest {
+
+ @Test
+ public void emptyInstance() {
+ QueryParams params = new QueryParams();
+ assertThat(params.asList()).isEmpty();
+ assertThat(params.toRawQuery()).isNull();
+ }
+
+ @Test
+ public void parseNull_yieldsEmptyInstance() {
+ QueryParams params = QueryParams.fromRawQuery(null);
+ assertThat(params.asList()).isEmpty();
+ assertThat(params.toRawQuery()).isNull();
+ }
+
+ @Test
+ public void parseEmptyString_yieldsSingleLoneKey() {
+ QueryParams params = QueryParams.fromRawQuery("");
+ assertThat(params.toRawQuery()).isEmpty();
+ assertThat(params.asList()).isNotEmpty();
+ Entry entry = params.asList().get(0);
+ assertThat(entry).isNotNull();
+ assertThat(entry.getKey()).isEmpty();
+ assertThat(entry.hasValue()).isFalse();
+ assertThat(entry.getValue()).isNull();
+ }
+
+ @Test
+ public void parseNormalPairs() {
+ QueryParams params = QueryParams.fromRawQuery("a=b&c=d");
+ assertThat(params.toRawQuery()).isEqualTo("a=b&c=d");
+
+ QueryParams.Entry a = params.asList().get(0);
+ assertThat(a.getKey()).isEqualTo("a");
+ assertThat(a.hasValue()).isTrue();
+ assertThat(a.getValue()).isEqualTo("b");
+
+ QueryParams.Entry c = params.asList().get(1);
+ assertThat(c.getKey()).isEqualTo("c");
+ assertThat(c.getValue()).isEqualTo("d");
+ }
+
+ @Test
+ public void parseLoneKey() {
+ QueryParams params = QueryParams.fromRawQuery("a&b");
+ assertThat(params.toRawQuery()).isEqualTo("a&b");
+
+ QueryParams.Entry a = params.asList().get(0);
+ assertThat(a.getKey()).isEqualTo("a");
+ assertThat(a.hasValue()).isFalse();
+
+ QueryParams.Entry b = params.asList().get(1);
+ assertThat(b.getKey()).isEqualTo("b");
+ assertThat(b.hasValue()).isFalse();
+ }
+
+ @Test
+ public void parseEmptyKeysAndValues() {
+ QueryParams params = QueryParams.fromRawQuery("=&=");
+ assertThat(params.toRawQuery()).isEqualTo("=&=");
+
+ assertThat(params.asList()).hasSize(2);
+ assertThat(params.asList().get(0).getKey()).isEmpty();
+ assertThat(params.asList().get(0).hasValue()).isTrue();
+ assertThat(params.asList().get(0).getValue()).isEmpty();
+ assertThat(params.asList().get(1).getKey()).isEmpty();
+ assertThat(params.asList().get(1).hasValue()).isTrue();
+ assertThat(params.asList().get(1).getValue()).isEmpty();
+ }
+
+ @Test
+ public void roundTripPreservesEncodingOfSpaces() {
+ // Spaces can be encoded as + or %20.
+ QueryParams params = QueryParams.fromRawQuery("a+b=c%20d");
+ assertThat(params.asList().get(0).getKey()).isEqualTo("a b");
+ assertThat(params.asList().get(0).getValue()).isEqualTo("c d");
+ assertThat(params.toRawQuery()).isEqualTo("a+b=c%20d");
+ }
+
+ @Test
+ public void roundTripPreservesCaseOfHexDigits() {
+ // Percent encoding can use upper or lower case.
+ QueryParams params = QueryParams.fromRawQuery("%4A%4a=%4B%4b");
+ assertThat(params.asList().get(0).getKey()).isEqualTo("JJ");
+ assertThat(params.asList().get(0).getValue()).isEqualTo("KK");
+ assertThat(params.toRawQuery()).isEqualTo("%4A%4a=%4B%4b");
+ }
+
+ @Test
+ public void asListMethod() {
+ QueryParams params = new QueryParams();
+ params.asList().add(QueryParams.Entry.forKeyValue("a b", "c d"));
+ params.asList().add(QueryParams.Entry.forLoneKey("e f"));
+
+ // URLEncoder encodes spaces as +
+ assertThat(params.toRawQuery()).isEqualTo("a+b=c+d&e+f");
+ }
+
+ @Test
+ public void parseInvalidPercentEncodingThrows() {
+ assertThrows(IllegalArgumentException.class, () -> QueryParams.fromRawQuery("a=%GH"));
+ }
+
+ @Test
+ public void parseInvalidKeyValueEncodingSucceeds() {
+ QueryParams params = QueryParams.fromRawQuery("====");
+ assertThat(params.asList())
+ .containsExactly(Entry.forRawKeyValue("", "==="))
+ .inOrder();
+ assertThat(params.toRawQuery()).isEqualTo("====");
+ }
+
+ @Test
+ public void uriIntegration_canBuild() {
+ QueryParams params = new QueryParams();
+ params.asList().add(Entry.forKeyValue("a", "b"));
+ params.asList().add(Entry.forKeyValue("c", "d"));
+
+ Uri uri =
+ Uri.newBuilder()
+ .setScheme("http")
+ .setHost("example.com")
+ .setRawQuery(params.toRawQuery())
+ .build();
+
+ assertThat(uri.toString()).isEqualTo("http://example.com?a=b&c=d");
+ assertThat(uri.getRawQuery()).isEqualTo("a=b&c=d");
+ }
+
+ @Test
+ public void uriIntegration_canBuildEmpty() {
+ QueryParams params = new QueryParams();
+ Uri uri =
+ Uri.newBuilder()
+ .setScheme("http")
+ .setHost("example.com")
+ .setRawQuery(params.toRawQuery())
+ .build();
+
+ assertThat(uri.toString()).isEqualTo("http://example.com");
+ assertThat(uri.getRawQuery()).isNull();
+ }
+
+ @Test
+ public void uriIntegration_canParse() {
+ Uri uri = Uri.create("http://example.com?a=b&c=d&e");
+ QueryParams params = QueryParams.fromRawQuery(uri.getRawQuery());
+
+ assertThat(params.asList())
+ .containsExactly(
+ Entry.forKeyValue("a", "b"), Entry.forKeyValue("c", "d"), Entry.forLoneKey("e"))
+ .inOrder();
+ }
+
+ @Test
+ public void keysAndValuesWithCharactersNeedingUrlEncoding() {
+ QueryParams params = new QueryParams();
+ params.asList().add(Entry.forKeyValue("a=b", "c&d"));
+ params.asList().add(Entry.forKeyValue("e+f", "g h"));
+
+ assertThat(params.toRawQuery()).isEqualTo("a%3Db=c%26d&e%2Bf=g+h");
+
+ QueryParams roundTripped = QueryParams.fromRawQuery(params.toRawQuery());
+ assertThat(roundTripped).isEqualTo(params);
+ }
+
+ @Test
+ public void keysAndValuesWithCodePointsOutsideAsciiRange() {
+ QueryParams params = new QueryParams();
+ params.asList().add(Entry.forKeyValue("€", "𐐷"));
+
+ assertThat(params.toRawQuery()).isEqualTo("%E2%82%AC=%F0%90%90%B7");
+
+ QueryParams roundTripped = QueryParams.fromRawQuery(params.toRawQuery());
+ assertThat(roundTripped).isEqualTo(params);
+ }
+
+ @Test
+ public void toStringMethod() {
+ QueryParams params = new QueryParams();
+ assertThat(params.toString()).isEqualTo("[]");
+
+ params.asList().add(Entry.forKeyValue("a", "b"));
+ assertThat(params.toString()).isEqualTo("[a=b]");
+
+ params.asList().add(Entry.forLoneKey("c"));
+ assertThat(params.toString()).isEqualTo("[a=b, c]");
+
+ params.asList().add(Entry.forKeyValue("d=e", "f&g"));
+ assertThat(params.toString()).isEqualTo("[a=b, c, d%3De=f%26g]");
+ }
+
+ @Test
+ public void entryProperties() {
+ Entry keyValue = Entry.forKeyValue("key", "val");
+ assertThat(keyValue.getKey()).isEqualTo("key");
+ assertThat(keyValue.getValue()).isEqualTo("val");
+ assertThat(keyValue.hasValue()).isTrue();
+
+ Entry loneKey = Entry.forLoneKey("key");
+ assertThat(loneKey.getKey()).isEqualTo("key");
+ assertThat(loneKey.getValue()).isNull();
+ assertThat(loneKey.hasValue()).isFalse();
+ }
+
+ @Test
+ public void equalsAndHashCode_container() {
+ QueryParams params1 = new QueryParams();
+ QueryParams params2 = new QueryParams();
+
+ // Empty instances are equal
+ assertThat(params1).isEqualTo(params2);
+ assertThat(params1.hashCode()).isEqualTo(params2.hashCode());
+
+ params1.asList().add(Entry.forKeyValue("a", "b"));
+ params1.asList().add(Entry.forLoneKey("c"));
+
+ params2.asList().add(Entry.forKeyValue("a", "b"));
+ params2.asList().add(Entry.forLoneKey("c"));
+
+ // Identical parameters in identical order are equal
+ assertThat(params1).isEqualTo(params2);
+ assertThat(params1.hashCode()).isEqualTo(params2.hashCode());
+
+ // Order matters.
+ QueryParams params3 = new QueryParams();
+ params3.asList().add(Entry.forLoneKey("c"));
+ params3.asList().add(Entry.forKeyValue("a", "b"));
+ assertThat(params1).isNotEqualTo(params3);
+ }
+
+ @Test
+ public void equalsAndHashCode_entry() {
+ // Raw matches are equal.
+ assertThat(Entry.forRawKeyValue("a+b", "c")).isEqualTo(Entry.forRawKeyValue("a+b", "c"));
+ assertThat(Entry.forRawKeyValue("a+b", "c").hashCode())
+ .isEqualTo(Entry.forRawKeyValue("a+b", "c").hashCode());
+
+ // Spaces encoding matters. + and %20 are not equal.
+ assertThat(Entry.forRawKeyValue("a+b", "c")).isNotEqualTo(Entry.forRawKeyValue("a%20b", "c"));
+
+ // Case of hex digits matter: %4A vs %4a are not equal raw keys.
+ assertThat(Entry.forRawKeyValue("a", "%4A")).isNotEqualTo(Entry.forRawKeyValue("a", "%4a"));
+ }
+}
diff --git a/api/src/test/java/io/grpc/UriTest.java b/api/src/test/java/io/grpc/UriTest.java
index a1bd550696f..71ec1749b7d 100644
--- a/api/src/test/java/io/grpc/UriTest.java
+++ b/api/src/test/java/io/grpc/UriTest.java
@@ -42,7 +42,7 @@ public void parse_allComponents() throws URISyntaxException {
assertThat(uri.getPort()).isEqualTo(443);
assertThat(uri.getRawPort()).isEqualTo("0443");
assertThat(uri.getPath()).isEqualTo("/path");
- assertThat(uri.getQuery()).isEqualTo("query");
+ assertThat(uri.getRawQuery()).isEqualTo("query");
assertThat(uri.getFragment()).isEqualTo("fragment");
assertThat(uri.toString()).isEqualTo("scheme://user@host:0443/path?query#fragment");
assertThat(uri.isAbsolute()).isFalse(); // Has a fragment.
@@ -56,7 +56,7 @@ public void parse_noAuthority() throws URISyntaxException {
assertThat(uri.getScheme()).isEqualTo("scheme");
assertThat(uri.getAuthority()).isNull();
assertThat(uri.getPath()).isEqualTo("/path");
- assertThat(uri.getQuery()).isEqualTo("query");
+ assertThat(uri.getRawQuery()).isEqualTo("query");
assertThat(uri.getFragment()).isEqualTo("fragment");
assertThat(uri.toString()).isEqualTo("scheme:/path?query#fragment");
assertThat(uri.isAbsolute()).isFalse(); // Has a fragment.
@@ -102,7 +102,7 @@ public void parse_noQuery() throws URISyntaxException {
assertThat(uri.getScheme()).isEqualTo("scheme");
assertThat(uri.getAuthority()).isEqualTo("authority");
assertThat(uri.getPath()).isEqualTo("/path");
- assertThat(uri.getQuery()).isNull();
+ assertThat(uri.getRawQuery()).isNull();
assertThat(uri.getFragment()).isEqualTo("fragment");
assertThat(uri.toString()).isEqualTo("scheme://authority/path#fragment");
}
@@ -113,7 +113,7 @@ public void parse_noFragment() throws URISyntaxException {
assertThat(uri.getScheme()).isEqualTo("scheme");
assertThat(uri.getAuthority()).isEqualTo("authority");
assertThat(uri.getPath()).isEqualTo("/path");
- assertThat(uri.getQuery()).isEqualTo("query");
+ assertThat(uri.getRawQuery()).isEqualTo("query");
assertThat(uri.getFragment()).isNull();
assertThat(uri.toString()).isEqualTo("scheme://authority/path?query");
assertThat(uri.isAbsolute()).isTrue();
@@ -125,7 +125,7 @@ public void parse_emptyPathWithAuthority() throws URISyntaxException {
assertThat(uri.getScheme()).isEqualTo("scheme");
assertThat(uri.getAuthority()).isEqualTo("authority");
assertThat(uri.getPath()).isEmpty();
- assertThat(uri.getQuery()).isNull();
+ assertThat(uri.getRawQuery()).isNull();
assertThat(uri.getFragment()).isNull();
assertThat(uri.toString()).isEqualTo("scheme://authority");
assertThat(uri.isAbsolute()).isTrue();
@@ -139,7 +139,7 @@ public void parse_rootless() throws URISyntaxException {
assertThat(uri.getScheme()).isEqualTo("mailto");
assertThat(uri.getAuthority()).isNull();
assertThat(uri.getPath()).isEqualTo("ceo@company.com");
- assertThat(uri.getQuery()).isEqualTo("subject=raise");
+ assertThat(uri.getRawQuery()).isEqualTo("subject=raise");
assertThat(uri.getFragment()).isNull();
assertThat(uri.toString()).isEqualTo("mailto:ceo@company.com?subject=raise");
assertThat(uri.isAbsolute()).isTrue();
@@ -153,7 +153,7 @@ public void parse_emptyPath() throws URISyntaxException {
assertThat(uri.getScheme()).isEqualTo("scheme");
assertThat(uri.getAuthority()).isNull();
assertThat(uri.getPath()).isEmpty();
- assertThat(uri.getQuery()).isNull();
+ assertThat(uri.getRawQuery()).isNull();
assertThat(uri.getFragment()).isNull();
assertThat(uri.toString()).isEqualTo("scheme:");
assertThat(uri.isAbsolute()).isTrue();
@@ -165,7 +165,7 @@ public void parse_emptyPath() throws URISyntaxException {
public void parse_emptyQuery() {
Uri uri = Uri.create("scheme:?");
assertThat(uri.getScheme()).isEqualTo("scheme");
- assertThat(uri.getQuery()).isEmpty();
+ assertThat(uri.getRawQuery()).isEmpty();
}
@Test
@@ -322,7 +322,6 @@ public void parse_decoding() throws URISyntaxException {
assertThat(uri.getPort()).isEqualTo(1234);
assertThat(uri.getPath()).isEqualTo("/p ath");
assertThat(uri.getRawPath()).isEqualTo("/p%20ath");
- assertThat(uri.getQuery()).isEqualTo("q uery");
assertThat(uri.getRawQuery()).isEqualTo("q%20uery");
assertThat(uri.getFragment()).isEqualTo("f ragment");
assertThat(uri.getRawFragment()).isEqualTo("f%20ragment");
@@ -336,9 +335,8 @@ public void parse_decodingNonAscii() throws URISyntaxException {
@Test
public void parse_decodingPercent() throws URISyntaxException {
- Uri uri = Uri.parse("s://a/p%2520ath?q%25uery#f%25ragment");
+ Uri uri = Uri.parse("s://a/p%2520ath#f%25ragment");
assertThat(uri.getPath()).isEqualTo("/p%20ath");
- assertThat(uri.getQuery()).isEqualTo("q%uery");
assertThat(uri.getFragment()).isEqualTo("f%ragment");
}
@@ -420,7 +418,7 @@ public void toString_percentEncoding() throws URISyntaxException {
.setScheme("s")
.setHost("a b")
.setPath("/p ath")
- .setQuery("q uery")
+ .setRawQuery("q%20uery")
.setFragment("f ragment")
.build();
assertThat(uri.toString()).isEqualTo("s://a%20b/p%20ath?q%20uery#f%20ragment");
@@ -440,7 +438,6 @@ public void parse_transparentRoundTrip_ipLiteral() {
assertThat(uri.getRawPath()).isEqualTo("/%4a%4B%2f%2F");
assertThat(uri.getPathSegments()).containsExactly("JK//");
assertThat(uri.getRawQuery()).isEqualTo("%4c%4D");
- assertThat(uri.getQuery()).isEqualTo("LM");
assertThat(uri.getRawFragment()).isEqualTo("%4e%4F");
assertThat(uri.getFragment()).isEqualTo("NO");
}
@@ -459,7 +456,6 @@ public void parse_transparentRoundTrip_regName() {
assertThat(uri.getRawPath()).isEqualTo("/%4a%4B%2f%2F");
assertThat(uri.getPathSegments()).containsExactly("JK//");
assertThat(uri.getRawQuery()).isEqualTo("%4c%4D");
- assertThat(uri.getQuery()).isEqualTo("LM");
assertThat(uri.getRawFragment()).isEqualTo("%4e%4F");
assertThat(uri.getFragment()).isEqualTo("NO");
}
@@ -529,7 +525,7 @@ public void builder_encodingWithAllowedReservedChars() throws URISyntaxException
.setUserInfo("u@")
.setHost("a[]")
.setPath("/p:/@")
- .setQuery("q/?")
+ .setRawQuery("q/?")
.setFragment("f/?")
.build();
assertThat(uri.toString()).isEqualTo("s://u%40@a%5B%5D/p:/@?q/?#f/?");
@@ -600,7 +596,7 @@ public void builder_normalizesCaseWhereAppropriate() {
.setScheme("hTtP") // #section-3.1 says producers (Builder) should normalize to lower.
.setHost("aBc") // #section-3.2.2 says producers (Builder) should normalize to lower.
.setPath("/CdE") // #section-6.2.2.1 says the rest are assumed to be case-sensitive
- .setQuery("fGh")
+ .setRawQuery("fGh")
.setFragment("IjK")
.build();
assertThat(uri.toString()).isEqualTo("http://abc/CdE?fGh#IjK");
@@ -621,12 +617,32 @@ public void builder_canClearAllOptionalFields() {
.setPath("")
.setUserInfo(null)
.setPort(-1)
- .setQuery(null)
+ .setRawQuery(null)
.setFragment(null)
.build();
assertThat(uri.toString()).isEqualTo("http:");
}
+ @Test
+ public void builder_setRawQuery() {
+ Uri uri = Uri.newBuilder().setScheme("http").setHost("host").setRawQuery("%61=b&c=%64").build();
+ assertThat(uri.getRawQuery()).isEqualTo("%61=b&c=%64");
+ assertThat(uri.toString()).isEqualTo("http://host?%61=b&c=%64");
+ }
+
+ @Test
+ public void builder_setRawQuery_null() {
+ Uri uri =
+ Uri.newBuilder()
+ .setScheme("http")
+ .setHost("host")
+ .setRawQuery("a=b")
+ .setRawQuery(null)
+ .build();
+ assertThat(uri.getRawQuery()).isNull();
+ assertThat(uri.toString()).isEqualTo("http://host");
+ }
+
@Test
public void builder_canClearAuthorityComponents() {
Uri uri = Uri.create("s://user@host:80/path").toBuilder().setRawAuthority(null).build();
@@ -692,7 +708,7 @@ public void toString_percentEncodingLiteralPercent() throws URISyntaxException {
.setScheme("s")
.setHost("a")
.setPath("/p%20ath")
- .setQuery("q%uery")
+ .setRawQuery("q%25uery")
.setFragment("f%ragment")
.build();
assertThat(uri.toString()).isEqualTo("s://a/p%2520ath?q%25uery#f%25ragment");
diff --git a/api/src/testFixtures/java/io/grpc/StatusMatcher.java b/api/src/testFixtures/java/io/grpc/StatusMatcher.java
index f464b2d709d..08e9fffb013 100644
--- a/api/src/testFixtures/java/io/grpc/StatusMatcher.java
+++ b/api/src/testFixtures/java/io/grpc/StatusMatcher.java
@@ -26,7 +26,7 @@
*/
public final class StatusMatcher implements ArgumentMatcher {
public static StatusMatcher statusHasCode(ArgumentMatcher codeMatcher) {
- return new StatusMatcher(codeMatcher, null);
+ return new StatusMatcher(codeMatcher, null, null);
}
public static StatusMatcher statusHasCode(Status.Code code) {
@@ -35,17 +35,20 @@ public static StatusMatcher statusHasCode(Status.Code code) {
private final ArgumentMatcher codeMatcher;
private final ArgumentMatcher descriptionMatcher;
+ private final ArgumentMatcher causeMatcher;
private StatusMatcher(
ArgumentMatcher codeMatcher,
- ArgumentMatcher descriptionMatcher) {
+ ArgumentMatcher descriptionMatcher,
+ ArgumentMatcher causeMatcher) {
this.codeMatcher = checkNotNull(codeMatcher, "codeMatcher");
this.descriptionMatcher = descriptionMatcher;
+ this.causeMatcher = causeMatcher;
}
public StatusMatcher andDescription(ArgumentMatcher descriptionMatcher) {
checkState(this.descriptionMatcher == null, "Already has a description matcher");
- return new StatusMatcher(codeMatcher, descriptionMatcher);
+ return new StatusMatcher(codeMatcher, descriptionMatcher, causeMatcher);
}
public StatusMatcher andDescription(String description) {
@@ -56,11 +59,21 @@ public StatusMatcher andDescriptionContains(String substring) {
return andDescription(new StringContainsMatcher(substring));
}
+ public StatusMatcher andCause(ArgumentMatcher causeMatcher) {
+ checkState(this.causeMatcher == null, "Already has a cause matcher");
+ return new StatusMatcher(codeMatcher, descriptionMatcher, causeMatcher);
+ }
+
+ public StatusMatcher andCause(Throwable cause) {
+ return andCause(new EqualsMatcher<>(cause));
+ }
+
@Override
public boolean matches(Status status) {
return status != null
&& codeMatcher.matches(status.getCode())
- && (descriptionMatcher == null || descriptionMatcher.matches(status.getDescription()));
+ && (descriptionMatcher == null || descriptionMatcher.matches(status.getDescription()))
+ && (causeMatcher == null || causeMatcher.matches(status.getCause()));
}
@Override
@@ -72,6 +85,10 @@ public String toString() {
sb.append(", description=");
sb.append(descriptionMatcher);
}
+ if (causeMatcher != null) {
+ sb.append(", cause=");
+ sb.append(causeMatcher);
+ }
sb.append("}");
return sb.toString();
}
diff --git a/api/src/testFixtures/java/io/grpc/StatusSubject.java b/api/src/testFixtures/java/io/grpc/StatusSubject.java
new file mode 100644
index 00000000000..0b00df96140
--- /dev/null
+++ b/api/src/testFixtures/java/io/grpc/StatusSubject.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2026 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;
+
+import static com.google.common.truth.Fact.fact;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import javax.annotation.Nullable;
+
+/** Propositions for {@link Status} subjects. */
+public final class StatusSubject extends Subject {
+
+ private static final Subject.Factory statusFactory = new Factory();
+
+ public static Subject.Factory status() {
+ return statusFactory;
+ }
+
+ private final Status actual;
+
+ private StatusSubject(FailureMetadata metadata, @Nullable Status subject) {
+ super(metadata, subject);
+ this.actual = subject;
+ }
+
+ /** Fails if the subject is not OK. */
+ public void isOk() {
+ if (actual == null) {
+ failWithActual("expected to be OK but was", "null");
+ } else if (!actual.isOk()) {
+ failWithoutActual(
+ fact("expected to be OK but was", actual.getCode()),
+ fact("description", actual.getDescription()),
+ fact("cause", actual.getCause()));
+ }
+ }
+
+ /** Fails if the subject does not have the given code. */
+ public void hasCode(Status.Code expectedCode) {
+ if (actual == null) {
+ failWithActual("expected to have code " + expectedCode + " but was", "null");
+ } else {
+ check("getCode()").that(actual.getCode()).isEqualTo(expectedCode);
+ }
+ }
+
+ private static final class Factory implements Subject.Factory {
+ @Override
+ public StatusSubject createSubject(FailureMetadata metadata, @Nullable Status that) {
+ return new StatusSubject(metadata, that);
+ }
+ }
+}
diff --git a/binder/build.gradle b/binder/build.gradle
index 0da3f97ceee..7e7d4810e98 100644
--- a/binder/build.gradle
+++ b/binder/build.gradle
@@ -20,6 +20,13 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
lintOptions { abortOnError = false }
+ buildTypes {
+ debug {
+ testCoverageEnabled true // For robolectric unit tests.
+ enableUnitTestCoverage true // For tests that run on an emulator.
+ }
+ }
+
publishing {
singleVariant('release') {
withSourcesJar()
@@ -54,6 +61,7 @@ dependencies {
testImplementation project(':grpc-testing')
testImplementation project(':grpc-inprocess')
testImplementation testFixtures(project(':grpc-core'))
+ testImplementation testFixtures(project(':grpc-api'))
androidTestAnnotationProcessor libraries.auto.value
androidTestImplementation project(':grpc-testing')
@@ -133,3 +141,31 @@ afterEvaluate {
components.release.withVariantsFromConfiguration(configurations.releaseTestFixturesVariantReleaseApiPublication) { skip() }
components.release.withVariantsFromConfiguration(configurations.releaseTestFixturesVariantReleaseRuntimePublication) { skip() }
}
+
+tasks.withType(Test) {
+ // Robolectric modifies classes in memory at runtime, so they lack a java.security.CodeSource
+ // URL to their on-disk location. By default, JaCoCo ignores classes without this property.
+ // Overriding this allows Robolectric tests to be instrumented.
+ jacoco.includeNoLocationClasses = true
+ // Don't instrument certain JDK internals protected from modification by JEP 403's "strong
+ // encapsulation." Avoids IllegalAccessError, InvalidClassException and similar at runtime.
+ jacoco.excludes = ["jdk.internal.**"]
+}
+
+// Android projects don't automatically get a coverage report task. We must
+// register one manually here and wire it up to AGP's test tasks.
+tasks.register("jacocoTestReport", JacocoReport) {
+ dependsOn "testDebugUnitTest"
+
+ reports {
+ // For codecov.io and coveralls.
+ xml.required = true
+ // Use the same output location as the other subprojects.
+ html.outputLocation = layout.buildDirectory.dir("reports/jacoco/test/html")
+ }
+
+ sourceDirectories.from = android.sourceSets.main.java.srcDirs
+ classDirectories.from = fileTree(dir: layout.buildDirectory.dir("intermediates/javac/debug/classes"),
+ excludes: ['**/R.class', '**/R$*.class', '**/BuildConfig.class', '**/Manifest*.*', '**/*Test*.*', 'android/**/*.*'])
+ executionData.from = tasks.named("testDebugUnitTest").map { it.jacoco.destinationFile }
+}
diff --git a/binder/src/main/java/io/grpc/binder/internal/BinderClientTransport.java b/binder/src/main/java/io/grpc/binder/internal/BinderClientTransport.java
index bef1eefd43e..58e7d7e2b31 100644
--- a/binder/src/main/java/io/grpc/binder/internal/BinderClientTransport.java
+++ b/binder/src/main/java/io/grpc/binder/internal/BinderClientTransport.java
@@ -279,7 +279,7 @@ public synchronized ClientStream newStream(
}
@Override
- protected void unregisterInbound(Inbound> inbound) {
+ protected void unregisterInbound(Inbound, ?> inbound) {
if (inbound.countsForInUse() && numInUseStreams.decrementAndGet() == 0) {
clientTransportListener.transportInUse(false);
}
diff --git a/binder/src/main/java/io/grpc/binder/internal/BinderServer.java b/binder/src/main/java/io/grpc/binder/internal/BinderServer.java
index 96685a2f8bd..f913775fcbe 100644
--- a/binder/src/main/java/io/grpc/binder/internal/BinderServer.java
+++ b/binder/src/main/java/io/grpc/binder/internal/BinderServer.java
@@ -70,6 +70,7 @@ public final class BinderServer implements InternalServer, LeakSafeOneWayBinder.
private final LeakSafeOneWayBinder hostServiceBinder;
private final BinderTransportSecurity.ServerPolicyChecker serverPolicyChecker;
private final InboundParcelablePolicy inboundParcelablePolicy;
+ private final OneWayBinderProxy.Decorator clientBinderDecorator;
@GuardedBy("this")
private ServerListener listener;
@@ -92,6 +93,7 @@ private BinderServer(Builder builder) {
ImmutableList.copyOf(checkNotNull(builder.streamTracerFactories, "streamTracerFactories"));
this.serverPolicyChecker = BinderInternal.createPolicyChecker(builder.serverSecurityPolicy);
this.inboundParcelablePolicy = builder.inboundParcelablePolicy;
+ this.clientBinderDecorator = builder.clientBinderDecorator;
hostServiceBinder = new LeakSafeOneWayBinder(this);
}
@@ -183,7 +185,7 @@ public synchronized boolean handleTransaction(int code, Parcel parcel) {
executorServicePool,
attrsBuilder.build(),
streamTracerFactories,
- OneWayBinderProxy.IDENTITY_DECORATOR,
+ clientBinderDecorator,
callbackBinder);
transport.start(listener.transportCreated(transport));
return true;
@@ -225,6 +227,7 @@ public static class Builder {
SharedResourcePool.forResource(GrpcUtil.TIMER_SERVICE);
ServerSecurityPolicy serverSecurityPolicy = SecurityPolicies.serverInternalOnly();
InboundParcelablePolicy inboundParcelablePolicy = InboundParcelablePolicy.DEFAULT;
+ OneWayBinderProxy.Decorator clientBinderDecorator = OneWayBinderProxy.IDENTITY_DECORATOR;
public BinderServer build() {
return new BinderServer(this);
@@ -295,5 +298,19 @@ public Builder setInboundParcelablePolicy(InboundParcelablePolicy inboundParcela
checkNotNull(inboundParcelablePolicy, "inboundParcelablePolicy");
return this;
}
+
+ /**
+ * Sets the {@link OneWayBinderProxy.Decorator} to be applied to this server's "client Binders".
+ *
+ * Tests can use this to capture post-setup transactions from server to client. The specified
+ * decorator will be applied every time a client connects. The decorated result will be used for
+ * all subsequent transactions to this client from the new ServerTransport.
+ *
+ *
Optional, {@link OneWayBinderProxy#IDENTITY_DECORATOR} is the default.
+ */
+ public Builder setClientBinderDecorator(OneWayBinderProxy.Decorator clientBinderDecorator) {
+ this.clientBinderDecorator = checkNotNull(clientBinderDecorator);
+ return this;
+ }
}
}
diff --git a/binder/src/main/java/io/grpc/binder/internal/BinderServerTransport.java b/binder/src/main/java/io/grpc/binder/internal/BinderServerTransport.java
index b8ab5e9f843..784d833bdf5 100644
--- a/binder/src/main/java/io/grpc/binder/internal/BinderServerTransport.java
+++ b/binder/src/main/java/io/grpc/binder/internal/BinderServerTransport.java
@@ -146,7 +146,7 @@ public synchronized void shutdownNow(Status reason) {
@Override
@Nullable
@GuardedBy("this")
- protected Inbound> createInbound(int callId) {
+ protected Inbound, ?> createInbound(int callId) {
return new Inbound.ServerInbound(this, attributes, callId);
}
diff --git a/binder/src/main/java/io/grpc/binder/internal/BinderTransport.java b/binder/src/main/java/io/grpc/binder/internal/BinderTransport.java
index 1592f6977df..30b8735ac68 100644
--- a/binder/src/main/java/io/grpc/binder/internal/BinderTransport.java
+++ b/binder/src/main/java/io/grpc/binder/internal/BinderTransport.java
@@ -163,7 +163,7 @@ protected enum TransportState {
@GuardedBy("this")
private final LeakSafeOneWayBinder incomingBinder;
- protected final ConcurrentHashMap> ongoingCalls;
+ protected final ConcurrentHashMap> ongoingCalls;
protected final OneWayBinderProxy.Decorator binderDecorator;
@GuardedBy("this")
@@ -318,13 +318,13 @@ final void shutdownInternal(Status shutdownStatus, boolean forceTerminate) {
incomingBinder.detach();
setState(TransportState.SHUTDOWN_TERMINATED);
sendShutdownTransaction();
- ArrayList> calls = new ArrayList<>(ongoingCalls.values());
+ ArrayList> calls = new ArrayList<>(ongoingCalls.values());
ongoingCalls.clear();
ArrayList> futuresToCancel = new ArrayList<>(ownedFutures);
ownedFutures.clear();
scheduledExecutorService.execute(
() -> {
- for (Inbound> inbound : calls) {
+ for (Inbound, ?> inbound : calls) {
synchronized (inbound) {
inbound.closeAbnormal(shutdownStatus);
}
@@ -392,7 +392,7 @@ protected synchronized void sendPing(int id) throws StatusException {
}
}
- protected void unregisterInbound(Inbound> inbound) {
+ protected void unregisterInbound(Inbound, ?> inbound) {
unregisterCall(inbound.callId);
}
@@ -481,13 +481,13 @@ private boolean handleTransactionInternal(int code, Parcel parcel) {
}
} else {
int size = parcel.dataSize();
- Inbound> inbound = ongoingCalls.get(code);
+ Inbound, ?> inbound = ongoingCalls.get(code);
if (inbound == null) {
synchronized (this) {
if (!isShutdown()) {
inbound = createInbound(code);
if (inbound != null) {
- Inbound> existing = ongoingCalls.put(code, inbound);
+ Inbound, ?> existing = ongoingCalls.put(code, inbound);
// Can't happen as only one invocation of handleTransaction() is running at a time.
Verify.verify(existing == null, "impossible appearance of %s", existing);
}
@@ -519,7 +519,7 @@ protected void restrictIncomingBinderToCallsFrom(int allowedCallingUid) {
@Nullable
@GuardedBy("this")
- protected Inbound> createInbound(int callId) {
+ protected Inbound, ?> createInbound(int callId) {
return null;
}
@@ -566,7 +566,7 @@ final void handleAcknowledgedBytes(long numBytes) {
Iterator i = callIdsToNotifyWhenReady.iterator();
while (isReady() && i.hasNext()) {
- Inbound> inbound = ongoingCalls.get(i.next());
+ Inbound, ?> inbound = ongoingCalls.get(i.next());
i.remove();
if (inbound != null) { // Calls can be removed out from under us.
inbound.onTransportReady();
@@ -598,7 +598,7 @@ private static void checkTransition(TransportState current, TransportState next)
}
@VisibleForTesting
- Map> getOngoingCalls() {
+ Map> getOngoingCalls() {
return ongoingCalls;
}
diff --git a/binder/src/main/java/io/grpc/binder/internal/BlockPool.java b/binder/src/main/java/io/grpc/binder/internal/BlockPool.java
index 3c58abdd80b..985e465ab4b 100644
--- a/binder/src/main/java/io/grpc/binder/internal/BlockPool.java
+++ b/binder/src/main/java/io/grpc/binder/internal/BlockPool.java
@@ -40,7 +40,7 @@ final class BlockPool {
* The size of each standard block. (Currently 16k) The block size must be at least as large as
* the maximum header list size.
*/
- private static final int BLOCK_SIZE = Math.max(16 * 1024, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE);
+ static final int BLOCK_SIZE = Math.max(16 * 1024, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE);
/**
* Maximum number of blocks to keep around. (Max 128k). This limit is a judgement call. 128k is
diff --git a/binder/src/main/java/io/grpc/binder/internal/Inbound.java b/binder/src/main/java/io/grpc/binder/internal/Inbound.java
index 9b9dfeef5ce..83fc8273d6f 100644
--- a/binder/src/main/java/io/grpc/binder/internal/Inbound.java
+++ b/binder/src/main/java/io/grpc/binder/internal/Inbound.java
@@ -42,9 +42,10 @@
*
* Out-of-order messages are reassembled into their correct order.
*/
-abstract class Inbound implements StreamListener.MessageProducer {
+abstract class Inbound
+ implements StreamListener.MessageProducer {
- protected final BinderTransport transport;
+ protected final T transport;
protected final Attributes attributes;
final int callId;
@@ -145,7 +146,7 @@ enum State {
@GuardedBy("this")
private boolean producingMessages;
- private Inbound(BinderTransport transport, Attributes attributes, int callId) {
+ private Inbound(T transport, Attributes attributes, int callId) {
this.transport = transport;
this.attributes = attributes;
this.callId = callId;
@@ -399,6 +400,13 @@ private void handleMessageData(int flags, int index, Parcel parcel) throws Statu
numBytes = parcel.dataPosition() - startPos;
} else {
numBytes = parcel.readInt();
+ if (numBytes > parcel.dataAvail()) {
+ throw Status.INTERNAL
+ .withDescription(
+ "Message size is larger than remaining parcel size: "
+ + numBytes + " > " + parcel.dataAvail())
+ .asException();
+ }
block = BlockPool.acquireBlock(numBytes);
if (numBytes > 0) {
parcel.readByteArray(block);
@@ -551,7 +559,7 @@ public synchronized String toString() {
// ======================================
// Client-side inbound transactions.
- static final class ClientInbound extends Inbound {
+ static final class ClientInbound extends Inbound {
private final boolean countsForInUse;
@@ -564,7 +572,10 @@ static final class ClientInbound extends Inbound {
private Metadata trailers;
ClientInbound(
- BinderTransport transport, Attributes attributes, int callId, boolean countsForInUse) {
+ BinderClientTransport transport,
+ Attributes attributes,
+ int callId,
+ boolean countsForInUse) {
super(transport, attributes, callId);
this.countsForInUse = countsForInUse;
}
@@ -608,13 +619,9 @@ protected void deliverCloseAbnormal(Status status) {
// ======================================
// Server-side inbound transactions.
- static final class ServerInbound extends Inbound {
-
- private final BinderServerTransport serverTransport;
-
+ static final class ServerInbound extends Inbound {
ServerInbound(BinderServerTransport transport, Attributes attributes, int callId) {
super(transport, attributes, callId);
- this.serverTransport = transport;
}
@GuardedBy("this")
@@ -623,17 +630,16 @@ protected void handlePrefix(int flags, Parcel parcel) throws StatusException {
String methodName = parcel.readString();
Metadata headers = MetadataHelper.readMetadata(parcel, attributes);
- StatsTraceContext statsTraceContext =
- serverTransport.createStatsTraceContext(methodName, headers);
+ StatsTraceContext statsTraceContext = transport.createStatsTraceContext(methodName, headers);
Outbound.ServerOutbound outbound =
- new Outbound.ServerOutbound(serverTransport, callId, statsTraceContext);
+ new Outbound.ServerOutbound(transport, callId, statsTraceContext);
ServerStream stream;
if ((flags & TransactionUtils.FLAG_EXPECT_SINGLE_MESSAGE) != 0) {
stream = new SingleMessageServerStream(this, outbound, attributes);
} else {
stream = new MultiMessageServerStream(this, outbound, attributes);
}
- Status status = serverTransport.startStream(stream, methodName, headers);
+ Status status = transport.startStream(stream, methodName, headers);
if (status.isOk()) {
checkNotNull(listener); // Is it ok to assume this will happen synchronously?
if (transport.isReady()) {
diff --git a/binder/src/test/java/io/grpc/binder/internal/RobolectricBinderTransportTest.java b/binder/src/test/java/io/grpc/binder/internal/RobolectricBinderTransportTest.java
index 8282f5e1025..63c47bf4f19 100644
--- a/binder/src/test/java/io/grpc/binder/internal/RobolectricBinderTransportTest.java
+++ b/binder/src/test/java/io/grpc/binder/internal/RobolectricBinderTransportTest.java
@@ -18,8 +18,10 @@
import static android.os.IBinder.FLAG_ONEWAY;
import static android.os.Process.myUid;
+import static com.google.common.truth.Truth.assertAbout;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+import static io.grpc.StatusSubject.status;
import static io.grpc.binder.internal.BinderTransport.REMOTE_UID;
import static io.grpc.binder.internal.BinderTransport.SETUP_TRANSPORT;
import static io.grpc.binder.internal.BinderTransport.SHUTDOWN_TRANSPORT;
@@ -47,15 +49,20 @@
import com.google.common.collect.ImmutableList;
import com.google.common.truth.TruthJUnit;
import io.grpc.Attributes;
+import io.grpc.CallOptions;
import io.grpc.InternalChannelz.SocketStats;
+import io.grpc.Metadata;
import io.grpc.ServerStreamTracer;
import io.grpc.Status;
import io.grpc.binder.AndroidComponentAddress;
import io.grpc.binder.ApiConstants;
import io.grpc.binder.AsyncSecurityPolicy;
import io.grpc.binder.SecurityPolicies;
+import io.grpc.binder.internal.OneWayBinderProxies.*;
import io.grpc.binder.internal.SettableAsyncSecurityPolicy.AuthRequest;
import io.grpc.internal.AbstractTransportTest;
+import io.grpc.internal.ClientStream;
+import io.grpc.internal.ClientStreamListenerBase;
import io.grpc.internal.ClientTransport;
import io.grpc.internal.ClientTransportFactory.ClientTransportOptions;
import io.grpc.internal.ConnectionClientTransport;
@@ -66,7 +73,9 @@
import io.grpc.internal.MockServerTransportListener;
import io.grpc.internal.ObjectPool;
import io.grpc.internal.SharedResourcePool;
+import java.io.InputStream;
import java.util.List;
+import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledExecutorService;
import org.junit.Before;
@@ -124,6 +133,8 @@ public final class RobolectricBinderTransportTest extends AbstractTransportTest
ServiceInfo serviceInfo;
private int nextServerAddress;
+ private BlockingBinderDecorator blockingDecorator =
+ new BlockingBinderDecorator<>();
@Parameter(value = 0)
public boolean preAuthServersParam;
@@ -167,27 +178,34 @@ public void requestRealisticBindServiceBehavior() {
shadowOf(application).setUnbindServiceCallsOnServiceDisconnected(false);
}
- @Override
- protected InternalServer newServer(List streamTracerFactories) {
+ BinderServer.Builder newServerBuilder() {
AndroidComponentAddress listenAddr =
AndroidComponentAddress.forBindIntent(
new Intent()
.setClassName(serviceInfo.packageName, serviceInfo.name)
.setAction("io.grpc.action.BIND." + nextServerAddress++));
- BinderServer binderServer =
- new BinderServer.Builder()
- .setListenAddress(listenAddr)
- .setExecutorPool(serverExecutorPool)
- .setExecutorServicePool(executorServicePool)
- .setStreamTracerFactories(streamTracerFactories)
- .build();
+ return new BinderServer.Builder()
+ .setListenAddress(listenAddr)
+ .setExecutorPool(serverExecutorPool)
+ .setExecutorServicePool(executorServicePool)
+ .setStreamTracerFactories(List.of());
+ }
+ void registerServerWithRobolectric(BinderServer server) {
+ AndroidComponentAddress listenAddr = (AndroidComponentAddress) server.getListenSocketAddress();
shadowOf(application.getPackageManager()).addServiceIfNotPresent(listenAddr.getComponent());
shadowOf(application)
.setComponentNameAndServiceForBindServiceForIntent(
- listenAddr.asBindIntent(), listenAddr.getComponent(), binderServer.getHostBinder());
- return binderServer;
+ listenAddr.asBindIntent(), listenAddr.getComponent(), server.getHostBinder());
+ }
+
+ @Override
+ protected InternalServer newServer(List streamTracerFactories) {
+ BinderServer server =
+ newServerBuilder().setStreamTracerFactories(streamTracerFactories).build();
+ registerServerWithRobolectric(server);
+ return server;
}
@Override
@@ -433,4 +451,248 @@ public void flowControlPushBack() {}
@Ignore("See BinderTransportTest#serverAlreadyListening")
@Override
public void serverAlreadyListening() {}
+
+ @Test
+ public void singleTxnMsgsDeliveredToServerOutOfOrder() throws Exception {
+ server.start(serverListener);
+ client =
+ newClientTransportBuilder()
+ .setFactory(
+ newClientTransportFactoryBuilder()
+ .setBinderDecorator(blockingDecorator)
+ .buildClientTransportFactory())
+ .build();
+ runIfNotNull(client.start(mockClientTransportListener));
+ blockingDecorator.putNextResult(takeNextBinder(blockingDecorator)); // Endpoint binder.
+ QueueingOneWayBinderProxy queueingServerProxy =
+ new QueueingOneWayBinderProxy(takeNextBinder(blockingDecorator)); // Server binder.
+ blockingDecorator.putNextResult(queueingServerProxy);
+
+ verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportReady();
+
+ ClientStream stream =
+ client.newStream(methodDescriptor, new Metadata(), CallOptions.DEFAULT, noopTracers);
+ ClientStreamListenerBase clientStreamListener = new ClientStreamListenerBase();
+ stream.start(clientStreamListener);
+ stream.writeMessage(methodDescriptor.streamRequest("one"));
+ stream.writeMessage(methodDescriptor.streamRequest("two"));
+ stream.halfClose();
+
+ // Expect one transaction for headers, one for each message, and one for half-close.
+ QueueingOneWayBinderProxy.Transaction txHeaders = takeNextTransaction(queueingServerProxy);
+ QueueingOneWayBinderProxy.Transaction tx1 = takeNextTransaction(queueingServerProxy);
+ QueueingOneWayBinderProxy.Transaction tx2 = takeNextTransaction(queueingServerProxy);
+ QueueingOneWayBinderProxy.Transaction txHalfClose = takeNextTransaction(queueingServerProxy);
+
+ // Deliver messages out of order!
+ queueingServerProxy.deliver(txHeaders);
+ queueingServerProxy.deliver(tx2);
+ queueingServerProxy.deliver(tx1);
+ queueingServerProxy.deliver(txHalfClose);
+
+ MockServerTransportListener serverTransportListener =
+ serverListener.takeListenerOrFail(TIMEOUT_MS, MILLISECONDS);
+ MockServerTransportListener.StreamCreation serverStreamCreation =
+ serverTransportListener.takeStreamOrFail(TIMEOUT_MS, MILLISECONDS);
+ serverStreamCreation.stream.request(2);
+
+ // Expect the server to deliver the messages in the order they were originally sent.
+ InputStream msg1 = takeNextMessage(serverStreamCreation.listener.messageQueue);
+ assertThat(methodDescriptor.parseResponse(msg1)).isEqualTo("one");
+
+ InputStream msg2 = takeNextMessage(serverStreamCreation.listener.messageQueue);
+ assertThat(methodDescriptor.parseResponse(msg2)).isEqualTo("two");
+
+ assertThat(serverStreamCreation.listener.awaitHalfClosed(TIMEOUT_MS, MILLISECONDS)).isTrue();
+ serverStreamCreation.stream.close(Status.OK, new Metadata());
+
+ assertAbout(status()).that(clientStreamListener.awaitClose(TIMEOUT_MS, MILLISECONDS)).isOk();
+ assertAbout(status())
+ .that(serverStreamCreation.listener.awaitClose(TIMEOUT_MS, MILLISECONDS))
+ .isOk();
+ }
+
+ @Test
+ public void msgFragmentsDeliveredToServerOutOfOrder() throws Exception {
+ server.start(serverListener);
+ client =
+ newClientTransportBuilder()
+ .setFactory(
+ newClientTransportFactoryBuilder()
+ .setBinderDecorator(blockingDecorator)
+ .buildClientTransportFactory())
+ .build();
+ runIfNotNull(client.start(mockClientTransportListener));
+ blockingDecorator.putNextResult(takeNextBinder(blockingDecorator)); // Endpoint binder.
+ QueueingOneWayBinderProxy queueingServerProxy =
+ new QueueingOneWayBinderProxy(takeNextBinder(blockingDecorator)); // Server binder.
+ blockingDecorator.putNextResult(queueingServerProxy);
+
+ verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportReady();
+
+ ClientStream stream =
+ client.newStream(methodDescriptor, new Metadata(), CallOptions.DEFAULT, noopTracers);
+ ClientStreamListenerBase clientStreamListener = new ClientStreamListenerBase();
+ stream.start(clientStreamListener);
+
+ String largeMessage = newStringOfLength(BlockPool.BLOCK_SIZE + 1);
+ stream.writeMessage(methodDescriptor.streamRequest(largeMessage));
+ stream.halfClose();
+
+ // Expect the client to split largeMessage into two transactions, plus headers and half-close.
+ QueueingOneWayBinderProxy.Transaction txHeaders = takeNextTransaction(queueingServerProxy);
+ QueueingOneWayBinderProxy.Transaction tx1 = takeNextTransaction(queueingServerProxy);
+ QueueingOneWayBinderProxy.Transaction tx2 = takeNextTransaction(queueingServerProxy);
+ QueueingOneWayBinderProxy.Transaction txHalfClose = takeNextTransaction(queueingServerProxy);
+
+ // Deliver fragments out of order!
+ queueingServerProxy.deliver(txHeaders);
+ queueingServerProxy.deliver(tx2);
+ queueingServerProxy.deliver(tx1);
+ queueingServerProxy.deliver(txHalfClose);
+
+ // Verify that the server reassembles the transactions correctly.
+ MockServerTransportListener serverTransportListener =
+ serverListener.takeListenerOrFail(TIMEOUT_MS, MILLISECONDS);
+ MockServerTransportListener.StreamCreation serverStreamCreation =
+ serverTransportListener.takeStreamOrFail(TIMEOUT_MS, MILLISECONDS);
+ serverStreamCreation.stream.request(1);
+ InputStream msg = takeNextMessage(serverStreamCreation.listener.messageQueue);
+ assertThat(methodDescriptor.parseResponse(msg)).isEqualTo(largeMessage);
+
+ assertThat(serverStreamCreation.listener.awaitHalfClosed(TIMEOUT_MS, MILLISECONDS)).isTrue();
+ serverStreamCreation.stream.close(Status.OK, new Metadata());
+
+ assertAbout(status()).that(clientStreamListener.awaitClose(TIMEOUT_MS, MILLISECONDS)).isOk();
+ assertAbout(status())
+ .that(serverStreamCreation.listener.awaitClose(TIMEOUT_MS, MILLISECONDS))
+ .isOk();
+ }
+
+ @Test
+ public void singleTxnMsgsDeliveredToClientOutOfOrder() throws Exception {
+ server = newServerBuilder().setClientBinderDecorator(blockingDecorator).build();
+ registerServerWithRobolectric((BinderServer) server);
+ server.start(serverListener);
+
+ client = newClientTransport(server);
+ runIfNotNull(client.start(mockClientTransportListener));
+
+ QueueingOneWayBinderProxy queueingClientProxy =
+ new QueueingOneWayBinderProxy(takeNextBinder(blockingDecorator));
+ blockingDecorator.putNextResult(queueingClientProxy);
+
+ // Deliver the setup transaction without interference.
+ queueingClientProxy.deliver(takeNextTransaction(queueingClientProxy));
+ verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportReady();
+
+ ClientStreamListenerBase clientStreamListener = new ClientStreamListenerBase();
+ ClientStream stream =
+ client.newStream(methodDescriptor, new Metadata(), CallOptions.DEFAULT, noopTracers);
+ stream.start(clientStreamListener);
+ stream.halfClose();
+ stream.request(2);
+
+ MockServerTransportListener serverTransportListener =
+ serverListener.takeListenerOrFail(TIMEOUT_MS, MILLISECONDS);
+ MockServerTransportListener.StreamCreation serverStreamCreation =
+ serverTransportListener.takeStreamOrFail(TIMEOUT_MS, MILLISECONDS);
+
+ serverStreamCreation.stream.writeMessage(methodDescriptor.streamResponse("one"));
+ serverStreamCreation.stream.writeMessage(methodDescriptor.streamResponse("two"));
+ serverStreamCreation.stream.close(Status.OK, new Metadata());
+
+ // Expect one transaction from the server for each message.
+ QueueingOneWayBinderProxy.Transaction tx1 = takeNextTransaction(queueingClientProxy);
+ QueueingOneWayBinderProxy.Transaction tx2 = takeNextTransaction(queueingClientProxy);
+ QueueingOneWayBinderProxy.Transaction txClose = takeNextTransaction(queueingClientProxy);
+
+ // Deliver messages to the client out of order!
+ queueingClientProxy.deliver(tx2);
+ queueingClientProxy.deliver(tx1);
+ queueingClientProxy.deliver(txClose);
+
+ // Client should deliver messages to the application in the order sent.
+ InputStream msg1 = takeNextMessage(clientStreamListener.messageQueue);
+ assertThat(methodDescriptor.parseResponse(msg1)).isEqualTo("one");
+ InputStream msg2 = takeNextMessage(clientStreamListener.messageQueue);
+ assertThat(methodDescriptor.parseResponse(msg2)).isEqualTo("two");
+
+ assertAbout(status()).that(clientStreamListener.awaitClose(TIMEOUT_MS, MILLISECONDS)).isOk();
+ assertAbout(status())
+ .that(serverStreamCreation.listener.awaitClose(TIMEOUT_MS, MILLISECONDS))
+ .isOk();
+ }
+
+ @Test
+ public void msgFragmentsDeliveredToClientOutOfOrder() throws Exception {
+ server = newServerBuilder().setClientBinderDecorator(blockingDecorator).build();
+ registerServerWithRobolectric((BinderServer) server);
+ server.start(serverListener);
+
+ client = newClientTransport(server);
+ runIfNotNull(client.start(mockClientTransportListener));
+
+ QueueingOneWayBinderProxy queueingClientProxy =
+ new QueueingOneWayBinderProxy(takeNextBinder(blockingDecorator));
+ blockingDecorator.putNextResult(queueingClientProxy);
+
+ // Deliver the setup transaction without interference.
+ queueingClientProxy.deliver(takeNextTransaction(queueingClientProxy));
+ verify(mockClientTransportListener, timeout(TIMEOUT_MS)).transportReady();
+
+ ClientStreamListenerBase clientStreamListener = new ClientStreamListenerBase();
+ ClientStream stream =
+ client.newStream(methodDescriptor, new Metadata(), CallOptions.DEFAULT, noopTracers);
+ stream.start(clientStreamListener);
+ stream.request(1);
+
+ MockServerTransportListener serverTransportListener =
+ serverListener.takeListenerOrFail(TIMEOUT_MS, MILLISECONDS);
+ MockServerTransportListener.StreamCreation serverStreamCreation =
+ serverTransportListener.takeStreamOrFail(TIMEOUT_MS, MILLISECONDS);
+
+ String largeMessage = newStringOfLength(BlockPool.BLOCK_SIZE + 1);
+ serverStreamCreation.stream.writeMessage(methodDescriptor.streamResponse(largeMessage));
+ serverStreamCreation.stream.flush();
+
+ // Expect the client to split largeMessage into two transactions.
+ QueueingOneWayBinderProxy.Transaction tx1 = takeNextTransaction(queueingClientProxy);
+ QueueingOneWayBinderProxy.Transaction tx2 = takeNextTransaction(queueingClientProxy);
+
+ // Deliver them to the client out of order!
+ queueingClientProxy.deliver(tx2);
+ queueingClientProxy.deliver(tx1);
+
+ // Client should reassemble the message correctly.
+ InputStream msg = takeNextMessage(clientStreamListener.messageQueue);
+ assertThat(methodDescriptor.parseResponse(msg)).isEqualTo(largeMessage);
+ }
+
+ private static OneWayBinderProxy takeNextBinder(
+ BlockingBinderDecorator decorator) throws InterruptedException {
+ OneWayBinderProxy proxy = decorator.takeNextRequest(TIMEOUT_MS, MILLISECONDS);
+ assertThat(proxy).isNotNull();
+ return proxy;
+ }
+
+ private static QueueingOneWayBinderProxy.Transaction takeNextTransaction(
+ QueueingOneWayBinderProxy proxy) throws InterruptedException {
+ QueueingOneWayBinderProxy.Transaction tx = proxy.pollNextTransaction(TIMEOUT_MS, MILLISECONDS);
+ assertThat(tx).isNotNull();
+ return tx;
+ }
+
+ private static InputStream takeNextMessage(BlockingQueue messageQueue)
+ throws InterruptedException {
+ InputStream msg = messageQueue.poll(TIMEOUT_MS, MILLISECONDS);
+ assertThat(msg).isNotNull();
+ return msg;
+ }
+
+ private static String newStringOfLength(int numChars) {
+ char[] chars = new char[numChars];
+ java.util.Arrays.fill(chars, 'x');
+ return new String(chars);
+ }
}
diff --git a/binder/src/androidTest/java/io/grpc/binder/internal/OneWayBinderProxies.java b/binder/src/testFixtures/java/io/grpc/binder/internal/OneWayBinderProxies.java
similarity index 67%
rename from binder/src/androidTest/java/io/grpc/binder/internal/OneWayBinderProxies.java
rename to binder/src/testFixtures/java/io/grpc/binder/internal/OneWayBinderProxies.java
index 4abdb2c03dd..c7eee06e73a 100644
--- a/binder/src/androidTest/java/io/grpc/binder/internal/OneWayBinderProxies.java
+++ b/binder/src/testFixtures/java/io/grpc/binder/internal/OneWayBinderProxies.java
@@ -18,6 +18,7 @@
import android.os.RemoteException;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
import javax.annotation.Nullable;
/** A collection of {@link OneWayBinderProxy}-related test helpers. */
@@ -42,6 +43,18 @@ public OneWayBinderProxy takeNextRequest() throws InterruptedException {
return requests.take();
}
+ /**
+ * Returns the next {@link OneWayBinderProxy} that needs decorating, blocking for up to the
+ * specified timeout if it hasn't yet been provided to {@link #decorate}.
+ *
+ * Follow this with a call to {@link #putNextResult(OneWayBinderProxy)} to provide the result
+ * of {@link #decorate} and unblock the waiting caller.
+ */
+ public OneWayBinderProxy takeNextRequest(long timeout, TimeUnit unit)
+ throws InterruptedException {
+ return requests.poll(timeout, unit);
+ }
+
/** Provides the next value to return from {@link #decorate}. */
public void putNextResult(T next) throws InterruptedException {
results.put(next);
@@ -119,6 +132,49 @@ public void transact(int code, ParcelHolder data) throws RemoteException {
}
}
+ /** A {@link OneWayBinderProxy} that queues transactions for a test to deliver manually later. */
+ public static final class QueueingOneWayBinderProxy extends OneWayBinderProxy {
+ public static final class Transaction {
+ public final int code;
+ private final ParcelHolder parcel;
+
+ public Transaction(int code, ParcelHolder parcel) {
+ this.code = code;
+ this.parcel = parcel;
+ }
+ }
+
+ private final BlockingQueue queue = new LinkedBlockingQueue<>();
+ private final OneWayBinderProxy wrapped;
+
+ public QueueingOneWayBinderProxy(OneWayBinderProxy wrapped) {
+ super(wrapped.getDelegate());
+ this.wrapped = wrapped;
+ }
+
+ @Override
+ public void transact(int code, ParcelHolder data) throws RemoteException {
+ queue.add(new Transaction(code, new ParcelHolder(data.release())));
+ }
+
+ /**
+ * Returns the next transaction that was queued in order, waiting up to the specified timeout.
+ */
+ public Transaction pollNextTransaction(long timeout, TimeUnit unit)
+ throws InterruptedException {
+ return queue.poll(timeout, unit);
+ }
+
+ /**
+ * Delivers a previously queued transaction to its original destination.
+ *
+ * @throws IllegalStateException if transaction was already delivered once before
+ */
+ public void deliver(Transaction transaction) throws RemoteException {
+ wrapped.transact(transaction.code, transaction.parcel);
+ }
+ }
+
// Cannot be instantiated.
private OneWayBinderProxies() {}
;
diff --git a/build.gradle b/build.gradle
index 2cf3439ea76..e65261b0cc4 100644
--- a/build.gradle
+++ b/build.gradle
@@ -21,7 +21,7 @@ subprojects {
apply plugin: "net.ltgt.errorprone"
group = "io.grpc"
- version = "1.81.0-SNAPSHOT" // CURRENT_GRPC_VERSION
+ version = "1.82.0-SNAPSHOT" // CURRENT_GRPC_VERSION
repositories {
maven { // The google mirror is less flaky than mavenCentral()
diff --git a/buildscripts/grpc-java-artifacts/Dockerfile.multiarch.base b/buildscripts/grpc-java-artifacts/Dockerfile.multiarch.base
index da2c46904ca..6b670994677 100644
--- a/buildscripts/grpc-java-artifacts/Dockerfile.multiarch.base
+++ b/buildscripts/grpc-java-artifacts/Dockerfile.multiarch.base
@@ -1,4 +1,7 @@
-FROM ubuntu:18.04
+FROM ubuntu:24.04
+
+# Redirect to the internal mirror to bypass the Kokoro network block
+RUN sed -i 's|http://archive.ubuntu.com/ubuntu/|http://mirror.bazel.build/archive.ubuntu.com/ubuntu/|g' /etc/apt/sources.list
RUN export DEBIAN_FRONTEND=noninteractive && \
apt-get update && \
@@ -9,8 +12,8 @@ RUN export DEBIAN_FRONTEND=noninteractive && \
curl \
g++-aarch64-linux-gnu \
g++-powerpc64le-linux-gnu \
- openjdk-8-jdk \
- pkg-config \
+ openjdk-11-jdk \
+ pkgconf \
&& \
rm -rf /var/lib/apt/lists/*
diff --git a/compiler/BUILD.bazel b/compiler/BUILD.bazel
index e8a0571e134..a9ffe77a55a 100644
--- a/compiler/BUILD.bazel
+++ b/compiler/BUILD.bazel
@@ -13,6 +13,7 @@ cc_binary(
],
visibility = ["//visibility:public"],
deps = [
+ "@abseil-cpp//absl/strings",
"@com_google_protobuf//:protoc_lib",
],
)
diff --git a/compiler/src/java_plugin/cpp/java_generator.cpp b/compiler/src/java_plugin/cpp/java_generator.cpp
index a81d54791b4..d0f8cdd13d5 100644
--- a/compiler/src/java_plugin/cpp/java_generator.cpp
+++ b/compiler/src/java_plugin/cpp/java_generator.cpp
@@ -46,6 +46,7 @@
#include